commit 0b251c596763c6e54e4efbd19236bdb3cca69f2d Author: Whyverum Date: Fri Jan 23 04:45:55 2026 +0700 First commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..65cd92a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Исключить скрытые системные каталоги, но не всё подряд +.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 +*.session +*.sessions 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/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/app.iml b/.idea/app.iml new file mode 100644 index 0000000..9ded49e --- /dev/null +++ b/.idea/app.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7c04147 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ 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..8c4259d --- /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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..849a0dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Используем официальный образ Python с подходящей версией +FROM python:3.12 + +# Устанавливаем 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..03c1650 Binary files /dev/null and b/README.md differ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..e99eb6f --- /dev/null +++ b/api/server.py @@ -0,0 +1,96 @@ +from typing import Any +from aiohttp import web +from userbot.client import userbot_send_message, userbot_edit_message + +from middleware import logger + + +class APIServer: + """ + Асинхронное API для связи aiogram ↔ pyrogram. + Предоставляет эндпоинты для отправки и редактирования сообщений через Premium-аккаунт. + """ + + def __init__(self, host: str = "0.0.0.0", port: int = 8081) -> None: + self.host = host + self.port = port + self.app = web.Application() + self.app.add_routes([ + web.post("/api/send", self.send_message), + web.post("/api/edit", self.edit_message), + web.get("/api/health", self.health_check) + ]) + self.runner: web.AppRunner | None = None + self.site: web.TCPSite | None = None + + @staticmethod + async def health_check(_: web.Request) -> web.Response: + """Простейший эндпоинт для проверки состояния API.""" + return web.json_response({"status": "ok"}) + + @staticmethod + async def send_message(request: web.Request) -> web.Response: + """ + Эндпоинт: /api/send + Ожидает JSON: + { + "chat_id": str | int, + "text": str, + "parse_mode": str | None + } + """ + try: + payload: dict[str, Any] = await request.json() + chat_id = payload.get("chat_id") + text = payload.get("text") + parse_mode = payload.get("parse_mode") + + if not chat_id or not text: + return web.json_response({"error": "chat_id and text required"}, status=400) + + message = await userbot_send_message(chat_id, text, parse_mode) + return web.json_response({"status": "ok", "message_id": message.id}) + except Exception as e: + logger.error(f"Ошибка отправки: {e}") + return web.json_response({"status": "error", "detail": str(e)}, status=500) + + @staticmethod + async def edit_message(request: web.Request) -> web.Response: + """ + Эндпоинт: /api/edit + Ожидает JSON: + { + "chat_id": str | int, + "message_id": int, + "text": str, + "parse_mode": str | None + } + """ + try: + payload: dict[str, Any] = await request.json() + chat_id = payload.get("chat_id") + message_id = payload.get("message_id") + text = payload.get("text") + parse_mode = payload.get("parse_mode") + + if not all([chat_id, message_id, text]): + return web.json_response({"error": "chat_id, message_id, text required"}, status=400) + + message = await userbot_edit_message(chat_id, message_id, text, parse_mode) + return web.json_response({"status": "ok", "message_id": message.id}) + except Exception as e: + logger.error(f"Ошибка редактирования: {e}") + return web.json_response({"status": "error", "detail": str(e)}, status=500) + + async def start(self) -> None: + """Запуск aiohttp API-сервера.""" + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + log.info(f"🚀 API запущено на http://{self.host}:{self.port}") + + async def stop(self) -> None: + """Остановка сервера.""" + if self.runner: + await self.runner.cleanup() 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..a2d9984 Binary files /dev/null and b/assets/start.jpg differ diff --git a/bot.db b/bot.db new file mode 100644 index 0000000..877628e Binary files /dev/null and b/bot.db 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..3e58d58 --- /dev/null +++ b/bot/core/bots.py @@ -0,0 +1,260 @@ +from asyncio import sleep +from datetime import datetime + +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, logger + +# Настройка экспорта в модули +__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: + """ + Класс для хранения и управления информацией о боте. + Все поля строго аннотированы, description заменено на widget. + """ + + id: int | None = None + url: str | None = None + first_name: str | None = None + last_name: str | None = None + username: str | None = None + widget: str | None = None # вместо description + description: str | None = None # вместо short_description + 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 + rights: ChatAdministratorRights | None = None + + @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: + """ + Установка или удаление вебхука для бота. + """ + try: + 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_url) + except TelegramRetryAfter as e: + logger.warning(f"Flood control при установке вебхука. Повтор через {e.retry_after} сек.") + await sleep(e.retry_after) + await bots.set_webhook(webhook_url) + except Exception as e: + logger.error(f"Ошибка при настройке вебхука: {e}") + + @classmethod + @log(level="INFO", log_type="BOT", text="Получение информации о боте") + async def info(cls, bots: Bot = bot) -> dict[str, object] | None: + """ + Получает и сохраняет основные данные о боте. + """ + try: + bot_info: User = await bots.get_me() + bot_description: BotDescription = await bots.get_my_description() + bot_short_description: BotShortDescription = await bots.get_my_short_description() + bot_rights: ChatAdministratorRights = await bot.get_my_default_administrator_rights() + + 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 + + # Описание (widget) и короткое описание (description) + cls.widget = bot_description.description or "" + cls.description = bot_short_description.short_description or "" + + cls.added_to_attachment_menu = getattr(bot_info, "added_to_attachment_menu", False) + cls.supports_inline_queries = getattr(bot_info, "supports_inline_queries", False) + cls.can_connect_to_business = getattr(bot_info, "can_connect_to_business", False) + cls.has_main_web_app = getattr(bot_info, "has_main_web_app", False) + 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) + cls.rights = bot_rights or None + + 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, + "widget": cls.widget, + "description": cls.description, + "added_to_attachment_menu": cls.added_to_attachment_menu, + "supports_inline_queries": cls.supports_inline_queries, + "can_connect_to_business": cls.can_connect_to_business, + "has_main_web_app": cls.has_main_web_app, + "can_join_groups": cls.can_join_groups, + "can_read_all_group_messages": cls.can_read_all_group_messages, + "prefix": cls.prefix, + "bot_owner": cls.bot_owner, + "rights": cls.rights, + } + except Exception as e: + logger.error(f"Ошибка при получении информации о боте: {e}") + return None + + @staticmethod + @log(level="INFO", log_type="BOT", text="Установка прав администратора") + async def set_administrator_rights(rights: ChatAdministratorRights = BotEdit.RIGHTS, bots: Bot = bot) -> None: + """ + Устанавливает дефолтные права администратора для бота. + """ + try: + current_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights() + if current_rights != rights: + await bots.set_my_default_administrator_rights(rights=rights) + await bots.set_my_default_administrator_rights(rights=rights, for_channels=True) + except Exception as e: + logger.error(f"Ошибка при установке прав администратора: {e}") + + @staticmethod + @log(level="INFO", log_type="BOT", text="Обновление имени бота") + async def set_name(new_name: str = BotEdit.NAME, bots: Bot = bot) -> None: + """ + Обновляет имя бота (от 1 до 32 символов). + """ + try: + 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(name=new_name) + except Exception as e: + logger.error(f"Ошибка при обновлении имени бота: {e}") + + @staticmethod + @log(level="INFO", log_type="BOT", text="Обновление виджета бота") + async def set_widget(new_widget: str = BotEdit.DESCRIPTION, bots: Bot = bot) -> None: + """ + Обновляет описание бота (widget). + """ + try: + current_widget: BotDescription = await bots.get_my_description() + if not (0 < len(new_widget) <= 255): + raise ValueError("Виджет должен быть от 1 до 255 символов.") + if current_widget.description != new_widget: + await bots.set_my_description(description=new_widget) + except Exception as e: + logger.error(f"Ошибка при обновлении виджета бота: {e}") + + @staticmethod + @log(level="INFO", log_type="BOT", text="Обновление короткого виджета бота") + async def set_short_widget(new_short: str = BotEdit.SHORT_DESCRIPTION, bots: Bot = bot) -> None: + """ + Обновляет короткое описание (short_widget). + """ + try: + current_short: BotShortDescription = await bots.get_my_short_description() + if not (0 < len(new_short) <= 120): + raise ValueError("Короткий виджет должен быть от 1 до 120 символов.") + if current_short.short_description != new_short: + await bots.set_my_short_description(short_description=new_short) + except Exception as e: + logger.error(f"Ошибка при обновлении короткого виджета бота: {e}") + + @staticmethod + def start_info_out(out: bool = True) -> str | None: + """ + Формирует и выводит стартовую информацию о боте. + """ + try: + 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_description: str = f" Описание бота: {BotInfo.description}\n" + bot_widget: str = f" Виджет бота: {BotInfo.widget}\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_prefixs: str = f" Префиксы команд бота: {BotInfo.prefix}\n" + + bot_all_info: str = ( + f"{bot_name} {bot_postname} {bot_description} {bot_widget} {bot_username} " + f"{bot_id} {bot_can_join_groups} {bot_can_read_all_group_messages} " + f"{bot_added_to_attachment_menu} {bot_supports_inline_queries} " + f"{bot_can_connect_to_business} {bot_has_main_web_app} {bot_prefixs}" + ) + + if out: + print(f"\033[34m{bot_all_info}\033[0m") + + with open("Logs/info.log", "w", encoding="utf-8") as log_file: + log_file.write(f"{bot_time}{bot_all_info}") + + 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: + logger.error(f"Ошибка при выводе стартовой информации: {e}") + return None + + @classmethod + @log(level="INFO", log_type="START", text="Процесс запуска бота!") + async def setup(cls, perm: bool = Permission.BOT_EDIT, bots: Bot = bot) -> None: + """ + Настройка и инициализация всех параметров бота при старте. + """ + try: + await cls.webhook(bots=bots) + await cls.info(bots=bots) + if perm: + await cls.set_administrator_rights(bots=bots) + await cls.set_widget(bots=bots) + await cls.set_short_widget(bots=bots) + await cls.set_name(bots=bots) + except Exception as e: + logger.error(f"Ошибка при запуске настройки бота: {e}") diff --git a/bot/core/webhook.py b/bot/core/webhook.py new file mode 100644 index 0000000..d3b276a --- /dev/null +++ b/bot/core/webhook.py @@ -0,0 +1,53 @@ +from typing import Any + +from aiohttp import web +from aiogram.types import Update + +from middleware.loggers import logger +from bot.core.bots import dp, bot + +# Настройки экспорта в модули +__all__ = ("WebhookApp",) + + +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: + logger.error(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() + + logger.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/data/__init__.py b/bot/data/__init__.py new file mode 100644 index 0000000..a373dcc --- /dev/null +++ b/bot/data/__init__.py @@ -0,0 +1 @@ +from .topic_map import * diff --git a/bot/data/topic_map.py b/bot/data/topic_map.py new file mode 100644 index 0000000..68ebe99 --- /dev/null +++ b/bot/data/topic_map.py @@ -0,0 +1,4 @@ +# bot/data/topic_map.py + +# ключ: (user_id, тип) → thread_id +user_topic_map: dict[tuple[int, str], int] = {} 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..1e0ed7b --- /dev/null +++ b/bot/filters/callback.py @@ -0,0 +1,35 @@ +from aiogram.filters import BaseFilter +from aiogram.types import CallbackQuery + +# Настройка экспорта в модули +__all__ = ("CallbackDataStartsWith", "CallbackStartsWith") + + +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)) + + +class CallbackStartsWith(BaseFilter): + """ + Фильтр для callback_data, которое начинается с команды из списка. + Игнорирует регистр. + """ + def __init__(self, commands: list[str]): + self.commands = [cmd.casefold() for cmd in commands] + + async def __call__(self, callback: CallbackQuery) -> bool: + data = callback.data.casefold() if callback.data else "" + return any(data.startswith(cmd) for cmd in self.commands) diff --git a/bot/filters/chat_rights.py b/bot/filters/chat_rights.py new file mode 100644 index 0000000..278c305 --- /dev/null +++ b/bot/filters/chat_rights.py @@ -0,0 +1,152 @@ +from typing import Any + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.filters import BaseFilter +from aiogram.types import Message, ResultChatMemberUnion, CallbackQuery + +from configs import ImportantID + +# Настройка экспорта в модули +__all__ = ("IsChatCreator", "IsAdmin", "IsModerator", "IsOwner",) + + +class IsOwner(BaseFilter): + """ + Фильтр для проверки, является ли пользователь владельцем бота. + + Args: + send_error_message (bool): Если True, при попытке не- владельца выполнить команду, + бот отправит сообщение об ошибке. + + Returns: + bool | dict[str, Any]: + - False, если пользователь не владелец и send_error_message=False + - True, если пользователь является владельцем + - dict с информацией о пользователе, если send_error_message=True + + Example: + @router.message(IsOwner()) + async def cmd_handler(message: Message): + ... + + @router.message(IsOwner(send_error_message=True)) + async def admin_only(message: Message): + ... + """ + + def __init__(self, send_error_message: bool = False) -> None: + """ + Инициализация фильтра. + + Args: + send_error_message: Нужно ли отправлять сообщение при запрещенном доступе + """ + self.send_error_message: bool = send_error_message + + async def __call__(self, update: Message | CallbackQuery, bot: Bot) -> bool | dict[str, Any]: + """ + Проверяет, является ли пользователь владельцем. + + Args: + update: Объект Message или CallbackQuery + bot: Экземпляр бота (не используется, но требуется сигнатурой) + + Returns: + bool | dict[str, Any]: Результат фильтра. Если пользователь владелец, + возвращается True или dict с info. Иначе False + """ + if not update.from_user: + # Без from_user невозможно определить владельца + return False + + user_id: int = update.from_user.id + is_owner: bool = user_id in ImportantID.OWNERS_ID + + if not is_owner and self.send_error_message: + # Отправляем предупреждение о доступе + if isinstance(update, Message): + await update.answer(text="⛔ Эта команда доступна только владельцу бота!") + elif isinstance(update, CallbackQuery): + await update.answer(text="⛔ Доступно только владельцу бота!", show_alert=True) + return False + + # Если пользователь владелец — возвращаем словарь с дополнительной информацией + if is_owner: + return { + "is_owner": True, + "user_id": user_id, + "owner_ids": ImportantID.OWNERS_ID + } + + # Если не владелец и send_error_message=False + return False + + +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..1ebdb1f --- /dev/null +++ b/bot/filters/chat_type.py @@ -0,0 +1,33 @@ +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..1e42fd8 --- /dev/null +++ b/bot/filters/message_content.py @@ -0,0 +1,71 @@ +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..78191fd --- /dev/null +++ b/bot/filters/subscrided.py @@ -0,0 +1,41 @@ +from typing import Union + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.filters import BaseFilter +from aiogram.types import Message, ResultChatMemberUnion + +# Настройка экспорта в модули +__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..38d40df --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,21 @@ +from aiogram import Router + +from .commands import router as cmd_routers +from .messages import router as messages_routers +from .form_utils import router as form_routers +from .union_utills import router as union_routers +from .custom import router as custom_routers + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( +custom_routers, + #cmd_routers, + + #messages_routers, + #form_routers, + #union_routers, +) diff --git a/bot/handlers/commands/__init__.py b/bot/handlers/commands/__init__.py new file mode 100644 index 0000000..b1cf29b --- /dev/null +++ b/bot/handlers/commands/__init__.py @@ -0,0 +1,21 @@ +from aiogram import Router + +from .admins import router as admin_cmd_router +from .special import router as special_cmd_router +from .users import router as users_cmd_router +from .users.cancel_cmd import router as cancel_cmd_router +from .settings import router as settings_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + cancel_cmd_router, + settings_cmd_router, + admin_cmd_router, + users_cmd_router, + special_cmd_router, + +) diff --git a/bot/handlers/commands/admins/__init__.py b/bot/handlers/commands/admins/__init__.py new file mode 100644 index 0000000..9829a5f --- /dev/null +++ b/bot/handlers/commands/admins/__init__.py @@ -0,0 +1,18 @@ +from aiogram import Router + +from .ban_cmd import router as ban_cmd_router +from .all_cmd import router as all_cmd_router +from .pin_cmd import router as pin_cmd_router +from .kick_cmd import router as kick_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +router.include_routers( +ban_cmd_router, + kick_cmd_router, + pin_cmd_router, + all_cmd_router, + +) diff --git a/bot/handlers/commands/admins/all_cmd.py b/bot/handlers/commands/admins/all_cmd.py new file mode 100644 index 0000000..a6b714b --- /dev/null +++ b/bot/handlers/commands/admins/all_cmd.py @@ -0,0 +1,80 @@ +from asyncio import create_task + +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message +from aiogram.exceptions import TelegramBadRequest +from aiogram.fsm.context import FSMContext + +from bot.core.bots import bot, BotInfo +from bot.filters import IsOwner +from bot.utils import status_clear, auto_delete_message, hidden_admins_message +from configs import COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) + +# Ключ для команды +CMD: str = "all" +# Инициализация роутера +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.message( + F.text.lower().regexp(rf"^({'|'.join(COMMANDS[CMD])})\s?.*"), # ловим текст без префикса + F.chat.type.in_({"supergroup", "group"}), + IsOwner() +) +@router.message( + Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), + IsOwner() +) +async def notify_all_text(message: Message, state: FSMContext) -> None: + """ + Обработчик команды /all, /call и текстовых эквивалентов типа "Калл Привет всем". + + Функционал: + 1. Считывает весь текст после команды. + 2. Формирует скрытое сообщение для администраторов. + 3. Отправляет сообщение в чат. + 4. Автоматически удаляет сообщение через неделю. + 5. Пытается закрепить сообщение в чате. + + Args: + message (Message): Объект входящего сообщения. + state (FSMContext): Контекст FSM, используется для очистки состояния. + """ + # Очистка состояния FSM перед выполнением команды + await status_clear(message=message, state=state) + + # Извлечение текста после команды + parts: list[str] = message.text.split(" ", 1) + custom_text: str = parts[1] if len(parts) > 1 else "⚡ Внимание всем!" + + # Формирование скрытого текста для администраторов + hidden_text: str = await hidden_admins_message(message=message, text=custom_text) + + # Отправка сообщения в чат + sent_message: Message = await message.answer(hidden_text) + + # Запуск асинхронной задачи по удалению сообщения через 7 дней + create_task( + auto_delete_message( + chat_id=message.chat.id, + message_id=sent_message.message_id, + delay=604800 # 7 дней в секундах + ) + ) + + # Попытка закрепить сообщение и удалить "системное" сообщение о закреплении + try: + await bot.pin_chat_message( + chat_id=message.chat.id, + message_id=sent_message.message_id, + disable_notification=False + ) + # Иногда Telegram создает дополнительное уведомление при закреплении + await bot.delete_message(chat_id=message.chat.id, message_id=sent_message.message_id + 1) + logger.debug(f"[ALL] Сообщение закреплено: {custom_text}") + except TelegramBadRequest as e: + logger.error(f"[ALL] Ошибка закрепления сообщения: {e}") diff --git a/bot/handlers/commands/admins/ban_cmd.py b/bot/handlers/commands/admins/ban_cmd.py new file mode 100644 index 0000000..2167e32 --- /dev/null +++ b/bot/handlers/commands/admins/ban_cmd.py @@ -0,0 +1,258 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, User +from html import escape + +from bot.filters import IsAdmin +from bot.utils import status_clear +from configs import COMMANDS +from database import db + +# Настройки роутера +__all__ = ("router",) + +from middleware import logger + +CMD: str = "ban" +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin()) +async def ban_user_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /ban для блокировки пользователей. + Использование: /ban или ответ на сообщение пользователя + /ban + """ + await status_clear(message=message, state=state) + + try: + # Проверяем есть ли ответ на сообщение + if message.reply_to_message: + # Бан по ответу на сообщение + target_user: User | None = message.reply_to_message.from_user + if not target_user: + await message.answer("❌ Не удалось определить пользователя") + return + + target_user_id: int = target_user.id + target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}" + + # Проверяем, не пытаемся ли забанить бота + if target_user_id == message.bot.id: + await message.answer("❌ Нельзя заблокировать бота!") + return + + # Баним пользователя + success: bool = await _ban_user(target_user_id, target_username, message) + + if success: + safe_username: str = escape(target_username) + response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) заблокирован!" + + # Пытаемся забанить в чате (если команда вызвана в группе/чате) + if message.chat.type in ["group", "supergroup"]: + try: + await message.bot.ban_chat_member( + chat_id=message.chat.id, + user_id=target_user_id + ) + response_text += "\n🚫 Пользователь исключен из чата." + except Exception as e: + logger.warning(f"Не удалось исключить пользователя из чата: {e}") + response_text += "\n⚠️ Не удалось исключить пользователя из чата." + + await message.answer( + text=response_text, + parse_mode=None # Отключаем разметку + ) + else: + await message.answer("❌ Не удалось заблокировать пользователя") + + else: + # Бан по ID пользователя + command_parts: list[str] = message.text.split() + if len(command_parts) < 2: + await message.answer( + "ℹ️ Использование команды:\n" + "• Ответьте на сообщение пользователя командой /ban\n" + "• Или укажите ID: /ban " + ) + return + + try: + target_user_id: int = int(command_parts[1]) + + # Проверяем, не пытаемся ли забанить бота + if target_user_id == message.bot.id: + await message.answer("❌ Нельзя заблокировать бота!") + return + + success: bool = await _ban_user(target_user_id, f"ID{target_user_id}", message) + + if success: + response_text = f"✅ Пользователь (ID: {target_user_id}) заблокирован!" + + # Пытаемся забанить в чате + if message.chat.type in ["group", "supergroup"]: + try: + await message.bot.ban_chat_member( + chat_id=message.chat.id, + user_id=target_user_id + ) + response_text += "\n🚫 Пользователь исключен из чата." + except Exception as e: + logger.warning(f"Не удалось исключить пользователя из чата: {e}") + response_text += "\n⚠️ Не удалось исключить пользователя из чата." + + await message.answer( + text=response_text, + parse_mode=None + ) + else: + await message.answer("❌ Пользователь не найден или уже заблокирован") + + except ValueError: + await message.answer("❌ Неверный формат ID пользователя") + + except Exception as e: + logger.error(f"Ошибка в команде /ban: {e}") + await message.answer( + "⚠️ Произошла непредвиденная ошибка при выполнении команды.\n" + "Попробуйте повторить действие позже или нажмите /start" + ) + + +async def _ban_user(user_id: int, username: str, message: Message) -> bool: + """ + Внутренняя функция для блокировки пользователя. + """ + try: + # Сначала проверяем существует ли пользователь + user: User | None = await db.get_user(user_id) + + if not user: + # Если пользователя нет - создаем его забаненным + await db.add_user( + user_id=user_id, + username=username, + full_name=username + ) + + # Баним пользователя + await db.ban_user(user_id) + + # Логируем действие + admin_username = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}" + logger.info(f"🛑 Админ @{admin_username} заблокировал пользователя @{username} (ID: {user_id})") + + return True + + except Exception as e: + logger.error(f"❌ Ошибка при блокировке пользователя {user_id}: {e}") + return False + + +@router.message(Command("unban", ignore_case=True), IsAdmin()) +async def unban_user_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /unban для разблокировки пользователей. + """ + await status_clear(message=message, state=state) + + try: + if message.reply_to_message: + target_user: User | None = message.reply_to_message.from_user + if not target_user: + await message.answer("❌ Не удалось определить пользователя") + return + + target_user_id: int = target_user.id + target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}" + else: + command_parts: list[str] = message.text.split() + if len(command_parts) < 2: + await message.answer( + "ℹ️ Использование команды:\n" + "• Ответьте на сообщение пользователя командой /unban\n" + "• Или укажите ID: /unban " + ) + return + + try: + target_user_id: int = int(command_parts[1]) + target_username: str = f"ID{target_user_id}" + except ValueError: + await message.answer("❌ Неверный формат ID пользователя") + return + + # Разбаниваем пользователя + await db.unban_user(target_user_id) + + # Логируем действие + admin_username: str = message.from_user.username or message.from_user.full_name or f"ID{message.from_user.id}" + logger.info(f"🔓 Админ @{admin_username} разблокировал пользователя @{target_username} (ID: {target_user_id})") + + # Экранируем специальные символы + safe_username: str = escape(target_username) + + response_text = f"✅ Пользователь {safe_username} (ID: {target_user_id}) разблокирован!" + + # Пытаемся разбанить в чате + if message.chat.type in ["group", "supergroup"]: + try: + await message.bot.unban_chat_member( + chat_id=message.chat.id, + user_id=target_user_id + ) + response_text += "\n👥 Пользователь может вернуться в чат." + except Exception as e: + logger.warning(f"Не удалось разблокировать пользователя в чате: {e}") + + await message.answer( + text=response_text, + parse_mode=None + ) + + except Exception as e: + logger.error(f"❌ Ошибка при разблокировке пользователя: {e}") + await message.answer("❌ Не удалось разблокировать пользователя") + + +@router.message(Command("banned_list", ignore_case=True), IsAdmin()) +async def banned_list_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /banned_list для просмотра списка забаненных пользователей. + """ + await status_clear(message=message, state=state) + + try: + # Получаем всех пользователей включая забаненных + all_users: list[User] = await db.get_all_users(include_banned=True) + + # Фильтруем только забаненных + banned_users: list[User] = [user for user in all_users if getattr(user, 'status', None) == "banned"] + + if not banned_users: + await message.answer("📭 Список забаненных пользователей пуст") + return + + # Формируем сообщение со списком + banned_list: str = "🚫 Заблокированные пользователи:\n\n" + + for user in banned_users[:50]: # Ограничиваем вывод + username: str = f"@{user.username}" if getattr(user, 'username', None) else getattr(user, 'full_name', + 'Неизвестно') + # Экранируем специальные символы + safe_username = escape(username) + user_id = getattr(user, 'id', 'N/A') + banned_list += f"• {safe_username} (ID: {user_id})\n" + + if len(banned_users) > 50: + banned_list += f"\n... и еще {len(banned_users) - 50} пользователей" + + await message.answer(banned_list, parse_mode=None) + + except Exception as e: + logger.error(f"❌ Ошибка при получении списка забаненных: {e}") + await message.answer("❌ Не удалось получить список забаненных пользователей") diff --git a/bot/handlers/commands/admins/kick_cmd.py b/bot/handlers/commands/admins/kick_cmd.py new file mode 100644 index 0000000..0da12db --- /dev/null +++ b/bot/handlers/commands/admins/kick_cmd.py @@ -0,0 +1,278 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, User +from html import escape + +from bot import bot +from bot.filters import IsAdmin +from bot.utils import status_clear +from configs import COMMANDS + +# Настройки роутера +__all__ = ("router",) + +from middleware import logger + +CMD: str = "kick" +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.message(Command(*COMMANDS[CMD], ignore_case=True), IsAdmin()) +async def kick_user_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /kick для кика пользователей из чата. + Использование: /kick или ответ на сообщение пользователя + /kick + """ + await status_clear(message=message, state=state) + + # Проверяем, что команда используется в группе/супергруппе + if message.chat.type not in ["group", "supergroup"]: + await message.answer("❌ Эта команда работает только в группах и супергруппах!") + return + + # Проверяем есть ли ответ на сообщение + if message.reply_to_message: + # Кик по ответу на сообщение + target_user: User | None = message.reply_to_message.from_user + target_user_id: int = target_user.id + target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}" + + # Кикаем пользователя + success: bool = await _kick_user(target_user_id, target_username, message) + + if success: + safe_username: str = escape(target_username) + await message.answer( + text=f"👢 Пользователь {safe_username} (ID: {target_user_id}) кикнут из чата!", + parse_mode=None # Отключаем разметку + ) + else: + await message.answer("❌ Не удалось кикнуть пользователя") + + else: + # Кик по ID пользователя + command_parts: list[str] = message.text.split() + if len(command_parts) < 2: + await message.answer( + "ℹ️ Использование команды:\n" + "• Ответьте на сообщение пользователя командой /kick\n" + "• Или укажите ID: /kick " + ) + return + + try: + target_user_id: int = int(command_parts[1]) + success: bool = await _kick_user(target_user_id, f"ID{target_user_id}", message) + + if success: + await message.answer( + text=f"👢 Пользователь (ID: {target_user_id}) кикнут из чата!", + parse_mode=None # Отключаем разметку + ) + else: + await message.answer("❌ Пользователь не найден или не удалось кикнуть") + + except ValueError: + await message.answer("❌ Неверный формат ID пользователя") + + +async def _kick_user(user_id: int, username: str, message: Message) -> bool: + """ + Внутренняя функция для кика пользователя из чата. + + Args: + user_id: ID пользователя для кика + username: Имя пользователя для логов + message: Объект сообщения для контекста + + Returns: + bool: Успешно ли кикнут пользователь + """ + try: + # Проверяем, что бот имеет права администратора в чате + bot_member = await bot.get_chat_member(message.chat.id, bot.id) + if not bot_member.can_restrict_members: + await message.answer("❌ У меня нет прав для кика пользователей!") + return False + + # Проверяем, что целевой пользователь не является администратором/владельцем + target_member = await bot.get_chat_member(message.chat.id, user_id) + if target_member.status in ["creator", "administrator"]: + await message.answer("❌ Нельзя кикнуть администратора или создателя чата!") + return False + + # Проверяем, что отправитель команды имеет права администратора + admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id) + if admin_member.status not in ["creator", "administrator"]: + await message.answer("❌ У вас нет прав для кика пользователей!") + return False + + # Кикаем пользователя из чата + await bot.ban_chat_member( + chat_id=message.chat.id, + user_id=user_id, + revoke_messages=False # Не удаляем сообщения пользователя + ) + + # Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению + await bot.unban_chat_member( + chat_id=message.chat.id, + user_id=user_id + ) + + # Логируем действие + admin_username = message.from_user.username or message.from_user.full_name + logger.info( + f"👢 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title}") + + return True + + except Exception as e: + logger.error(f"❌ Ошибка при кике пользователя {user_id}: {e}") + await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}") + return False + + +@router.message(Command("kick_ban", ignore_case=True), IsAdmin()) +async def kick_ban_user_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /kick_ban для кика пользователя с удалением сообщений. + Использование: /kick_ban или ответ на сообщение пользователя + /kick_ban + """ + await status_clear(message=message, state=state) + + # Проверяем, что команда используется в группе/супергруппе + if message.chat.type not in ["group", "supergroup"]: + await message.answer("❌ Эта команда работает только в группах и супергруппах!") + return + + # Проверяем есть ли ответ на сообщение + if message.reply_to_message: + # Кик по ответу на сообщение + target_user: User | None = message.reply_to_message.from_user + target_user_id: int = target_user.id + target_username: str = target_user.username or target_user.full_name or f"ID{target_user_id}" + + # Кикаем пользователя с удалением сообщений + success: bool = await _kick_ban_user(target_user_id, target_username, message) + + if success: + safe_username: str = escape(target_username) + await message.answer( + text=f"💥 Пользователь {safe_username} (ID: {target_user_id}) кикнут с удалением сообщений!", + parse_mode=None # Отключаем разметку + ) + else: + await message.answer("❌ Не удалось кикнуть пользователя") + + else: + # Кик по ID пользователя + command_parts: list[str] = message.text.split() + if len(command_parts) < 2: + await message.answer( + "ℹ️ Использование команды:\n" + "• Ответьте на сообщение пользователя командой /kick_ban\n" + "• Или укажите ID: /kick_ban " + ) + return + + try: + target_user_id: int = int(command_parts[1]) + success: bool = await _kick_ban_user(target_user_id, f"ID{target_user_id}", message) + + if success: + await message.answer( + text=f"💥 Пользователь (ID: {target_user_id}) кикнут с удалением сообщений!", + parse_mode=None # Отключаем разметку + ) + else: + await message.answer("❌ Пользователь не найден или не удалось кикнуть") + + except ValueError: + await message.answer("❌ Неверный формат ID пользователя") + + +async def _kick_ban_user(user_id: int, username: str, message: Message) -> bool: + """ + Внутренняя функция для кика пользователя с удалением сообщений. + + Args: + user_id: ID пользователя для кика + username: Имя пользователя для логов + message: Объект сообщения для контекста + + Returns: + bool: Успешно ли кикнут пользователь + """ + try: + # Проверяем, что бот имеет права администратора в чате + bot_member = await bot.get_chat_member(message.chat.id, bot.id) + if not bot_member.can_restrict_members: + await message.answer("❌ У меня нет прав для кика пользователей!") + return False + + # Проверяем, что целевой пользователь не является администратором/владельцем + target_member = await bot.get_chat_member(message.chat.id, user_id) + if target_member.status in ["creator", "administrator"]: + await message.answer("❌ Нельзя кикнуть администратора или создателя чата!") + return False + + # Проверяем, что отправитель команды имеет права администратора + admin_member = await bot.get_chat_member(message.chat.id, message.from_user.id) + if admin_member.status not in ["creator", "administrator"]: + await message.answer("❌ У вас нет прав для кика пользователей!") + return False + + # Кикаем пользователя из чата с удалением сообщений + await bot.ban_chat_member( + chat_id=message.chat.id, + user_id=user_id, + revoke_messages=True # Удаляем сообщения пользователя + ) + + # Сразу разбаниваем, чтобы пользователь мог вернуться по приглашению + await bot.unban_chat_member( + chat_id=message.chat.id, + user_id=user_id + ) + + # Логируем действие + admin_username = message.from_user.username or message.from_user.full_name + logger.info( + f"💥 Админ @{admin_username} кикнул пользователя @{username} (ID: {user_id}) из чата {message.chat.title} с удалением сообщений") + + return True + + except Exception as e: + logger.error(f"❌ Ошибка при кике пользователя {user_id} с удалением сообщений: {e}") + await message.answer(f"❌ Ошибка при кике пользователя: {str(e)}") + return False + + +@router.message(Command("kick_list", ignore_case=True), IsAdmin()) +async def kick_help_cmd(message: Message, state: FSMContext) -> None: + """ + Команда /kick_list для показа справки по командам кика. + """ + await status_clear(message=message, state=state) + + help_text = """ +🤖 **Команды модерации:** + +**👢 /kick** - Кикнуть пользователя (может вернуться по приглашению) +• Ответьте на сообщение пользователя с командой /kick +• Или используйте: /kick + +**💥 /kick_ban** - Кикнуть пользователя с удалением сообщений +• Ответьте на сообщение пользователя с командой /kick_ban +• Или используйте: /kick_ban + +**🚫 /ban** - Полностью забанить пользователя +**🔓 /unban** - Разбанить пользователя +**📋 /banned_list** - Список забаненных + +⚠️ *Команды работают только в группах и требуют прав администратора* + """ + + await message.answer(help_text, parse_mode=None) diff --git a/bot/handlers/commands/admins/mute_cmd.py b/bot/handlers/commands/admins/mute_cmd.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/commands/admins/pin_cmd.py b/bot/handlers/commands/admins/pin_cmd.py new file mode 100644 index 0000000..fe239fa --- /dev/null +++ b/bot/handlers/commands/admins/pin_cmd.py @@ -0,0 +1,55 @@ +from asyncio import create_task +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery +from bot.core.bots import BotInfo, bot +from bot.filters import IsOwner +from bot.templates import msg +from bot.utils import status_clear +from bot.utils.auto_delete import auto_delete_message +from configs import COMMANDS + +__all__ = ("router",) +CMD: str = "pin".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner()) +async def pin_cmd(message: Message, state: FSMContext) -> None: + """ + Обработчик команды /pin для закрепления последнего сообщения или ответа. + """ + await status_clear(message=message, state=state) + + # Если есть reply → закрепляем его, иначе закрепляем саму команду + target = message.reply_to_message or message + + await bot.pin_chat_message(chat_id=message.chat.id, + message_id=target.message_id, + disable_notification=False) + + # Автоудаление через 7 суток + create_task(auto_delete_message(chat_id=message.chat.id, + message_id=target.message_id, + delay=604800)) + + await msg(message=message, text="✅ Сообщение успешно закреплено") + + +@router.callback_query(F.data.casefold().isin(COMMANDS[CMD]), IsOwner()) +async def pin_callback(callback: CallbackQuery, state: FSMContext) -> None: + """ + Обработчик кнопки с callback_data="pin". + """ + await status_clear(message=callback.message, state=state) + + await bot.pin_chat_message(chat_id=callback.message.chat.id, + message_id=callback.message.message_id, + disable_notification=False) + + create_task(auto_delete_message(chat_id=callback.message.chat.id, + message_id=callback.message.message_id, + delay=604800)) + + await callback.answer("✅ Сообщение закреплено") 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/admins/varn_cmd.py b/bot/handlers/commands/admins/varn_cmd.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/commands/settings/__init__.py b/bot/handlers/commands/settings/__init__.py new file mode 100644 index 0000000..af7d05f --- /dev/null +++ b/bot/handlers/commands/settings/__init__.py @@ -0,0 +1,19 @@ +from aiogram import Router + +from .set_description_cmd import router as set_description_cmd_router +from .set_name_cmd import router as set_name_cmd_router +from .set_widget_cmd import router as set_widget_cmd_router +from .settings_cmd import router as settings_cmd_router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + + +# Подключение роутеров +router.include_routers( +settings_cmd_router, + set_name_cmd_router, + set_description_cmd_router, + set_widget_cmd_router, +) \ No newline at end of file diff --git a/bot/handlers/commands/settings/set_description_cmd.py b/bot/handlers/commands/settings/set_description_cmd.py new file mode 100644 index 0000000..683d14e --- /dev/null +++ b/bot/handlers/commands/settings/set_description_cmd.py @@ -0,0 +1,167 @@ +from aiogram import Router, F, Bot +from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter +from aiogram.filters import Command, CommandObject +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import StatesGroup, State +from aiogram.types import Message, CallbackQuery +from aiogram.utils.i18n import gettext as _ + +from bot.core.bots import BotInfo +from bot.filters import IsOwner +from bot.handlers.commands.settings.settings_cmd import settings_keyboard +from bot.templates import msg +from bot.utils import format_retry_time, status_clear +from configs import COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) + +# Название команды +CMD: str = "set_description".lower() + +# Роутер для обработки команды /set_description +router: Router = Router(name=f"{CMD}_cmd_router") + + +class SetBotDescriptionForm(StatesGroup): + """Состояния FSM для изменения короткого описания бота.""" + new_description: State = State() + + +async def handle_set_bot_description( + description: str, + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot +) -> None: + """ + Установка короткого описания (short description) бота с обработкой FSM и ошибок API. + + Args: + description (str): Новый текст описания (до 120 символов). + message (Message | CallbackQuery): Сообщение или callback-запрос. + state (FSMContext): Контекст FSM. + bot (Bot): Экземпляр бота. + """ + # Проверка ограничения Telegram + if len(description) > 120: + await msg( + message=message, + text=_("❌ Короткое описание бота должно быть не более 120 символов. Текущая длина: {length}").format( + length=len(description) + ), + markup=settings_keyboard(), + ) + return + + try: + # Установка нового короткого описания + await bot.set_my_short_description(short_description=description) + + # Сохраняем текущее значение в BotInfo + BotInfo.short_description = description + + # Сбрасываем состояние FSM + await state.clear() + + # Отправляем сообщение об успехе + await msg( + message=message, + text=_("✅ Короткое описание бота успешно изменено на: {description}").format( + description=description + ), + markup=settings_keyboard(), + ) + + logger.info(f"Короткое описание бота изменено на: {description}") + + except TelegramRetryAfter as e: + retry_text: str = format_retry_time(e.retry_after) + logger.warning(f"Превышен лимит запросов при смене short description. Попробуйте через {retry_text}") + await msg( + message=message, + text=_("⚠️ Слишком частая смена короткого описания!\nПопробуйте снова через: {retry_text}").format( + retry_text=retry_text + ), + markup=settings_keyboard(), + ) + + except TelegramAPIError as e: + logger.error(f"Ошибка Telegram API при изменении короткого описания: {e}") + await msg( + message=message, + text=_("❌ Ошибка Telegram API при изменении короткого описания:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + except Exception as e: + logger.error(f"Непредвиденная ошибка при изменении короткого описания: {e}") + await msg( + message=message, + text=_("❌ Непредвиденная ошибка при изменении короткого описания:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + +@router.callback_query(F.data.lower() == CMD, IsOwner()) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner()) +async def settings_cmd( + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot, + command: CommandObject | None = None +) -> None: + """ + Обработчик команды /set_description для короткого описания. + + Поддерживает: + 1. Немедленное изменение через аргумент (/set_description TEXT). + 2. Callback-запрос. + 3. FSM-ввод. + """ + current_description: str = BotInfo.description + + # Вариант 1: если пользователь передал аргумент к команде + if command and command.args: + description: str = command.args.strip() + if len(description) > 120: + await msg( + message=message, + text=_("❌ Короткое описание не должно превышать 120 символов. Текущая длина: {length}").format( + length=len(description) + ), + markup=settings_keyboard(), + ) + return + + await handle_set_bot_description(description, message, state, bot) + return + + # Вариант 2: без аргумента → включаем FSM + await status_clear(message=message, state=state) + text: str = _( + "📝 Смена короткого описания бота\n\n" + "Текущее короткое описание: {current}\n\n" + "Введите новое короткое описание (максимум 120 символов):" + ).format(current=current_description) + + await msg(message=message, text=text, markup=settings_keyboard()) + await state.set_state(SetBotDescriptionForm.new_description) + + +@router.message(SetBotDescriptionForm.new_description, IsOwner()) +async def process_new_bot_description( + message: Message, + state: FSMContext, + bot: Bot +) -> None: + """ + Обработка ввода нового короткого описания через FSM. + """ + description: str = message.text.strip() + + if not description: + await message.answer(_("❌ Пожалуйста, введите корректное короткое описание.")) + return + + await handle_set_bot_description(description, message, state, bot) diff --git a/bot/handlers/commands/settings/set_name_cmd.py b/bot/handlers/commands/settings/set_name_cmd.py new file mode 100644 index 0000000..ee72300 --- /dev/null +++ b/bot/handlers/commands/settings/set_name_cmd.py @@ -0,0 +1,151 @@ +from aiogram import Router, F, Bot +from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter +from aiogram.filters import Command, CommandObject +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import StatesGroup, State +from aiogram.types import Message, CallbackQuery +from aiogram.utils.i18n import gettext as _ + +from bot.core.bots import BotInfo +from bot.filters import IsOwner +from bot.handlers.commands.settings.settings_cmd import settings_keyboard +from bot.templates import msg +from configs import COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) +CMD: str = "set_name".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +class SetNameForm(StatesGroup): + new_name: State = State() + + +def format_retry_time(retry_after: int) -> str: + """Форматирование времени повторной попытки в читаемом виде""" + hours, remainder = divmod(retry_after, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours > 0: + return f"{hours} часов, {minutes} минут, {seconds} секунд" + elif minutes > 0: + return f"{minutes} минут, {seconds} секунд" + else: + return f"{seconds} секунд" + + +async def handle_set_name( + new_name: str, + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot +) -> None: + """ + Установка имени бота с проверкой длины, обработкой перегрузки и логированием + """ + if len(new_name) > 64: + await msg( + message=message, + text=_("❌ Имя бота должно быть не более 64 символов. Текущая длина: {length}").format( + length=len(new_name) + ), + markup=settings_keyboard(), + ) + return + + try: + await bot.set_my_name(new_name) + BotInfo.first_name = new_name + await state.clear() + await msg( + message=message, + text=_("✅ Имя бота успешно изменено на: {new_name}").format(new_name=new_name), + markup=settings_keyboard(), + ) + logger.info(f"Имя бота изменено на: {new_name}") + + except TelegramRetryAfter as e: + retry_text: str = format_retry_time(e.retry_after) + logger.warning(f"Превышен контроль перегрузки при смене имени. Попробуйте через {retry_text}") + await msg( + message=message, + text=_("⚠️ Слишком частая смена имени!\nПопробуйте снова через: {retry_text}").format( + retry_text=retry_text + ), + markup=settings_keyboard(), + ) + + except TelegramAPIError as e: + logger.error(f"Ошибка Telegram API при изменении имени: {e}") + await msg( + message=message, + text=_("❌ Ошибка Telegram API:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + except Exception as e: + logger.error(f"Непредвиденная ошибка при изменении имени: {e}") + await msg( + message=message, + text=_("❌ Непредвиденная ошибка:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + +@router.callback_query(F.data.lower() == CMD, IsOwner()) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner()) +async def settings_cmd( + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot, + command: CommandObject | None = None +): + """ + Обработчик команды /set_name с поддержкой: + 1. Immediate установки через аргумент команды + 2. Callback query + 3. FSM ввод + """ + current_name = getattr(BotInfo, "first_name", "") or _("Не установлено") + + # Immediate установка через аргумент команды + if command and command.args: + new_name = command.args.strip() + if len(new_name) > 64: + await msg( + message=message, + text=_("❌ Имя не должно превышать 64 символа. Текущая длина: {length}").format( + length=len(new_name) + ), + markup=settings_keyboard(), + ) + return + await handle_set_name(new_name, message, state, bot) + return + + # Для callback query или пустой команды — показываем текущее имя и запускаем FSM + await state.clear() + if isinstance(message, CallbackQuery): + await message.answer() + text: str = _( + "🤖 Смена имени бота\n\n" + "Текущее имя: {current}\n\n" + "Пожалуйста, введите новое имя для бота (максимум 64 символа):" + ).format(current=current_name) + await msg(message=message, text=text, markup=settings_keyboard()) + await state.set_state(SetNameForm.new_name) + + +@router.message(SetNameForm.new_name, IsOwner()) +async def process_new_name(message: Message, state: FSMContext, bot: Bot): + """ + Обработка ввода нового имени через FSM + """ + new_name: str = message.text.strip() + + if not new_name: + await message.answer(_("❌ Пожалуйста, введите корректное имя.")) + return + + await handle_set_name(new_name, message, state, bot) diff --git a/bot/handlers/commands/settings/set_widget_cmd.py b/bot/handlers/commands/settings/set_widget_cmd.py new file mode 100644 index 0000000..d03b5f1 --- /dev/null +++ b/bot/handlers/commands/settings/set_widget_cmd.py @@ -0,0 +1,168 @@ +from aiogram import Router, F, Bot +from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter +from aiogram.filters import Command, CommandObject +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import StatesGroup, State +from aiogram.types import Message, CallbackQuery +from aiogram.utils.i18n import gettext as _ + +from bot.core.bots import BotInfo +from bot.filters import IsOwner +from bot.handlers.commands.settings.settings_cmd import settings_keyboard +from bot.templates import msg +from bot.utils import format_retry_time, status_clear +from configs import COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) +CMD: str = "set_widget".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +class SetWidgetForm(StatesGroup): + """Состояния FSM для изменения виджета (описания бота).""" + new_widget: State = State() + + +async def handle_set_widget( + new_widget: str, + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot +) -> None: + """ + Устанавливает новое значение виджета (описания бота). + + Args: + new_widget (str): Новый текст виджета. + message (Message | CallbackQuery): Объект сообщения или callback-запроса. + state (FSMContext): Контекст состояния FSM. + bot (Bot): Экземпляр текущего бота. + """ + # Проверка длины текста (Telegram API ограничивает description до 512 символов) + if len(new_widget) > 512: + await msg( + message=message, + text=_("❌ Виджет бота должен быть не более 512 символов. Текущая длина: {length}").format( + length=len(new_widget) + ), + markup=settings_keyboard(), + ) + return + + try: + # Устанавливаем описание через Telegram API + await bot.set_my_description(description=new_widget) + + # Сохраняем в BotInfo для локального использования + BotInfo.widget = new_widget + + # Очищаем состояние FSM + await state.clear() + + # Отправляем уведомление пользователю + await msg( + message=message, + text=_("✅ Виджет бота успешно изменён на: {new_widget}").format( + new_widget=new_widget + ), + markup=settings_keyboard(), + ) + + logger.info(f"Виджет бота изменён на: {new_widget}") + + except TelegramRetryAfter as e: + # Если запрос слишком частый + retry_text: str = format_retry_time(e.retry_after) + logger.warning(f"Превышен лимит запросов при смене виджета. Попробуйте через {retry_text}") + await msg( + message=message, + text=_("⚠️ Слишком частая смена виджета!\nПопробуйте снова через: {retry_text}").format( + retry_text=retry_text + ), + markup=settings_keyboard(), + ) + + except TelegramAPIError as e: + # Ошибка Telegram API + logger.error(f"Ошибка Telegram API при изменении виджета: {e}") + await msg( + message=message, + text=_("❌ Ошибка Telegram API при изменении виджета:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + except Exception as e: + # Непредвиденная ошибка + logger.error(f"Непредвиденная ошибка при изменении виджета: {e}") + await msg( + message=message, + text=_("❌ Непредвиденная ошибка при изменении виджета:
{error}
").format(error=str(e)), + markup=settings_keyboard(), + ) + + +@router.callback_query(F.data.lower() == CMD, IsOwner()) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner()) +async def settings_cmd( + message: Message | CallbackQuery, + state: FSMContext, + bot: Bot, + command: CommandObject | None = None +) -> None: + """ + Обработчик команды /set_widget. + + Поддерживает: + 1. Немедленное изменение через аргумент команды (/set_widget TEXT). + 2. Callback-запрос. + 3. FSM ввод. + """ + # Получаем текущее значение виджета + current_widget: str = BotInfo.widget + + # Вариант 1: пользователь ввёл аргумент сразу (/set_widget TEXT) + if command and command.args: + new_widget: str = command.args.strip() + if len(new_widget) > 512: + await msg( + message=message, + text=_("❌ Виджет не должен превышать 512 символов. Текущая длина: {length}").format( + length=len(new_widget) + ), + markup=settings_keyboard(), + ) + return + + await handle_set_widget(new_widget, message, state, bot) + return + + # Вариант 2: Callback query или пустая команда → запускаем FSM + await status_clear(message=message, state=state) + text: str = _( + "📝 Смена виджета бота\n\n" + "Текущий виджет: {current}\n\n" + "Пожалуйста, введите новый виджет для бота (максимум 512 символов):" + ).format(current=current_widget) + + await msg(message=message, text=text, markup=settings_keyboard()) + await state.set_state(SetWidgetForm.new_widget) + + +@router.message(SetWidgetForm.new_widget, IsOwner()) +async def process_new_widget( + message: Message, + state: FSMContext, + bot: Bot +) -> None: + """ + Обрабатывает ввод нового текста виджета через FSM. + """ + new_widget: str = message.text.strip() + + # Проверяем, что пользователь что-то ввёл + if not new_widget: + await message.answer(_("❌ Пожалуйста, введите корректный виджет.")) + return + + await handle_set_widget(new_widget, message, state, bot) diff --git a/bot/handlers/commands/settings/settings_cmd.py b/bot/handlers/commands/settings/settings_cmd.py new file mode 100644 index 0000000..05ef322 --- /dev/null +++ b/bot/handlers/commands/settings/settings_cmd.py @@ -0,0 +1,48 @@ +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.i18n import gettext as _ +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from bot.core.bots import BotInfo +from bot.filters import IsOwner +from bot.templates import msg +from bot.utils import status_clear +from configs import COMMANDS + +# Настройки экспорта и роутера +__all__ = ("router", "settings_keyboard",) +CMD: str = "settings".lower() +router: Router = Router(name=f"{CMD}_cmd_router") + + +def settings_keyboard() -> InlineKeyboardBuilder: + """Клавиатура настроек""" + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="🔙 Вернуться", callback_data="settings")) + return ikb + + +@router.callback_query(F.data.lower() == CMD, IsOwner()) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True), IsOwner()) +async def settings_cmd(message: Message | CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /settings""" + await status_clear(message=message, state=state) + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Имя бота⚜️", callback_data='set_name')) + ikb.row(InlineKeyboardButton(text="Описание бота📝", callback_data='set_description')) + ikb.row(InlineKeyboardButton(text="Виджет🧩", callback_data='set_widget')) + ikb.row(InlineKeyboardButton(text="Назад◀️", callback_data='menu')) + + # Формируем приветственное сообщение + text: str = _(""" +⚙️ Настройки +""" + ).format( + ) + + # Отправляем сообщение + await msg(message=message, text=text, markup=ikb) diff --git a/bot/handlers/commands/special/__init__.py b/bot/handlers/commands/special/__init__.py new file mode 100644 index 0000000..4bf8601 --- /dev/null +++ b/bot/handlers/commands/special/__init__.py @@ -0,0 +1,9 @@ +from aiogram import Router + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +# router.include_routers( +# ) diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py new file mode 100644 index 0000000..015e8e4 --- /dev/null +++ b/bot/handlers/commands/users/__init__.py @@ -0,0 +1,22 @@ +from aiogram import Router + +#from .active import router as active_cmd_router +from .start_cmd import router as start_cmd_router +#from .union_cmd import router as union_cmd_router +from .new_cmd import router as new_cmd_router +#from .create_cmd import router as create_cmd_router +#from .anon import router as anon_router +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + + +# Подключение роутеров +router.include_routers( + start_cmd_router, + #active_cmd_router, + #union_cmd_router, + new_cmd_router, + #create_cmd_router, +#anon_router, +) diff --git a/bot/handlers/commands/users/active.py b/bot/handlers/commands/users/active.py new file mode 100644 index 0000000..fa10e9d --- /dev/null +++ b/bot/handlers/commands/users/active.py @@ -0,0 +1,42 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, CallbackQuery + +from bot.core.bots import BotInfo +from bot.templates import msg_photo +from bot.utils import status_clear +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 status_clear(message=message, state=state) + + # Получить статистику сообщений пользователя + 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/anon.py b/bot/handlers/commands/users/anon.py new file mode 100644 index 0000000..859b436 --- /dev/null +++ b/bot/handlers/commands/users/anon.py @@ -0,0 +1,117 @@ +from typing import Dict, Tuple +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.fsm.context import FSMContext +from bot.utils import status_clear + +# ------------------- +# Router +# ------------------- +router: Router = Router(name="anon_router") + +# ------------------- +# Конфигурация +# ------------------- +# CHAT_ID в формате "-100000_29" -> chat_id + thread_id +CHAT_ID: str = "-1003098225669_724" + +def parse_chat_id(chat_id_str: str) -> Tuple[int, int]: + chat_str, thread_str = chat_id_str.split("_") + return int(chat_str), int(thread_str) + +ADMIN_CHAT_ID, ADMIN_THREAD_ID = parse_chat_id(CHAT_ID) + +# ------------------- +# FSM состояния +# ------------------- +class AnonStates: + USER_WAITING_TEXT = "user_waiting_text" + ADMIN_WAITING_REPLY = "admin_waiting_reply" + +# ------------------- +# Словари для отслеживания сообщений +# ------------------- +# user_id -> message_id в админском топике +user_to_admin_map: Dict[int, int] = {} +# admin_message_id -> user_id +admin_to_user_map: Dict[int, int] = {} + +# ------------------- +# Команда /anon или callback +# ------------------- +@router.callback_query(F.data.casefold() == "anon") +@router.message(Command("anon")) +async def anon_start(message: Message | CallbackQuery, state: FSMContext) -> None: + """Начало анонимного сообщения. Ждём текст пользователя.""" + await status_clear(message=message, state=state) + await state.clear() + await state.set_state(AnonStates.USER_WAITING_TEXT) + + text = "Напишите сообщение, которое вы хотите отправить анонимно администраторам." + if isinstance(message, Message): + await message.reply(text) + else: + await message.message.answer(text) + +# ------------------- +# Получение текста от пользователя +# ------------------- +@router.message(F.text, F.state == AnonStates.USER_WAITING_TEXT) +async def anon_send_text(message: Message, state: FSMContext) -> None: + """Пересылает текст пользователя в админский топик анонимно.""" + anon_text = message.text.strip() + if not anon_text: + await message.reply("Сообщение не может быть пустым. Попробуйте снова.") + return + + forwarded_text = f"Сообщение от [пользователя](tg://user?id={message.from_user.id}):\n{anon_text}" + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Ответить", callback_data=f"anon_reply:{message.from_user.id}")] + ] + ) + + sent_msg = await message.bot.send_message( + chat_id=ADMIN_CHAT_ID, + message_thread_id=ADMIN_THREAD_ID, + text=forwarded_text, + parse_mode="Markdown", + reply_markup=keyboard + ) + + user_to_admin_map[message.from_user.id] = sent_msg.message_id + admin_to_user_map[sent_msg.message_id] = message.from_user.id + + await message.reply("Ваше сообщение отправлено анонимно администраторам.") + await state.clear() + +# ------------------- +# Кнопка "Ответить" админа +# ------------------- +@router.callback_query(F.data.startswith("anon_reply:")) +async def anon_admin_reply(callback: CallbackQuery, state: FSMContext) -> None: + """Начинаем сессию ответа админа пользователю.""" + user_id = int(callback.data.split(":")[1]) + await state.set_state(AnonStates.ADMIN_WAITING_REPLY) + await state.update_data(reply_to_user=user_id) + await callback.message.answer(f"Введите ответ для пользователя [id={user_id}]:") + await callback.answer() + +# ------------------- +# Текст ответа админа +# ------------------- +@router.message(F.text, F.state == AnonStates.ADMIN_WAITING_REPLY) +async def anon_send_admin_text(message: Message, state: FSMContext) -> None: + """Пересылает текст админа пользователю.""" + data = await state.get_data() + reply_to_user = data.get("reply_to_user") + + if reply_to_user: + await message.bot.send_message( + chat_id=reply_to_user, + text=f"Ответ администратора:\n{message.text}" + ) + await message.reply("Сообщение отправлено пользователю.") + await state.clear() diff --git a/bot/handlers/commands/users/cancel_cmd.py b/bot/handlers/commands/users/cancel_cmd.py new file mode 100644 index 0000000..03156db --- /dev/null +++ b/bot/handlers/commands/users/cancel_cmd.py @@ -0,0 +1,27 @@ +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from bot import BotInfo +from bot.utils import status_clear +from configs import COMMANDS +from middleware.loggers import logger + +__all__ = ("router",) +CMD: str = "cancel".casefold() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.callback_query(F.data.casefold() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +@router.message(F.text.casefold().in_(COMMANDS[CMD])) +async def cancel_handler(message: Message, state: FSMContext, text: str = "❌ Отмена предыдущего действия!"): + """ + Позволяет пользователю отменить процесс смены описания + """ + await status_clear(message=message, state=state) + + logger.info(text=text) + + await message.answer(text) diff --git a/bot/handlers/commands/users/create_cmd.py b/bot/handlers/commands/users/create_cmd.py new file mode 100644 index 0000000..0c2aaac --- /dev/null +++ b/bot/handlers/commands/users/create_cmd.py @@ -0,0 +1,49 @@ +# bot/handlers/commands/create_cmd.py +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from aiogram.fsm.context import FSMContext + +from bot.core import BotInfo +from bot.states.anketa_states import StartForm +from bot.templates import msg_photo +from middleware import log + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name="create_cmd_router") + + + +@router.callback_query(F.data == "create") +@router.message(Command('create','скуфеу', 'анкета', prefix=BotInfo.prefix, ignore_case=True)) +@log(level='INFO', log_type='Start', text="использовал(а) команду /create") +async def create_cmd(message: Message|CallbackQuery, state: FSMContext) -> None: + """ + Обработчик команды /create. + """ + # Сбросим все состояния (отменим создание поста, если оно было) + await state.clear() + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Правила❗️", url='https://teletype.in/@velli_arsaan/XxUiHcB4Puj')) + ikb.row(InlineKeyboardButton(text="Назад↪️", callback_data='start')) + + # Создание базовых переменных сообщения + caption: str = f""" +Если вы хотели бы вступить в наш проект, то напоминаю, что вам сначала нужно ознакомиться с инфо-каналом! При продолжении диалога вы автоматически подтверждаете то, что прочитали все правила и в курсе, что мы ролевой проект, не флуд. +
Чтобы вступить к вам мы просим вас заполнить небольшую анкету: +1. Желаемая роль; +2. Кого бы вы хотели в соролы?; +3. Кодовая фраза из наших правил;
+[‼️] Оно состоит всего из 4 слов, которые разбросаны в верном порядке по статьям о правилах. +""" + # Установим состояние ожидания анкеты + await state.set_state(StartForm.waiting_for_application) + + # Обработчик ответа на сообщение + await msg_photo(message=message, text=caption, file='assets/help.png', markup=ikb) + diff --git a/bot/handlers/commands/users/new_cmd.py b/bot/handlers/commands/users/new_cmd.py new file mode 100644 index 0000000..d267193 --- /dev/null +++ b/bot/handlers/commands/users/new_cmd.py @@ -0,0 +1,368 @@ +from typing import Dict +from aiogram import Router, F +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.types import Message, InlineKeyboardButton, CallbackQuery +from aiogram.utils.keyboard import InlineKeyboardBuilder +from bot.core.bots import BotInfo +from bot.utils import status_clear +from configs import COMMANDS, ImportantID +from middleware.loggers import log + +# user_id -> thread_id (топик пользователя) +user_topic_map: Dict[int, int] = {} +# message_id в топике -> user_id +topic_message_map: Dict[int, int] = {} + +__all__ = ("router", "user_topic_map") +CMD: str = "new" +router: Router = Router(name=f"{CMD}_cmd_router") + +STATE_WAITING_REQUEST = "waiting_request" + + +def has_active_topic(user_id: int) -> bool: + """Проверяет, есть ли у пользователя активный топик""" + return user_id in user_topic_map + + +async def send_topic_message(user_id: int, text: str, reply_markup=None): + """Отправляет сообщение в топик пользователя""" + thread_id = user_topic_map.get(user_id) + if not thread_id: + return False + + try: + await BotInfo.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=text, + parse_mode="HTML", + reply_markup=reply_markup + ) + return True + except Exception as e: + log(level='ERROR', log_type='TOPIC_SEND', text=f"Ошибка отправки в топик: {e}") + return False + + +# ===================== Продолжение диалога ===================== +@router.callback_query(F.data == "continue_dialog") +async def continue_dialog_callback(callback: CallbackQuery, state: FSMContext) -> None: + """Обработчик продолжения существующего диалога""" + user_id = callback.from_user.id + + if not has_active_topic(user_id): + await callback.answer("❌ Активный диалог не найден", show_alert=True) + return + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + await callback.message.edit_text( + text="💬 У вас уже есть активный диалог с поддержкой. Просто отправьте ваше сообщение (не через reply) и оно будет переслано администратору.", + reply_markup=ikb.as_markup() + ) + await callback.answer() + + +# ===================== Обработчик callback /new ===================== +@router.callback_query(F.data.casefold() == CMD) +@log(level='INFO', log_type=f"{CMD.upper()}_CBD", text=f"использовал команду /{CMD} через кнопку") +async def new_cmd_callback(callback: CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /new из callback кнопки""" + user_id = callback.from_user.id + + # Проверяем, есть ли уже активный топик + if has_active_topic(user_id): + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Продолжить диалог💬", callback_data='continue_dialog')) + ikb.row(InlineKeyboardButton(text="Создать новый📝", callback_data='force_new')) + + await callback.message.edit_text( + text="⚠️ У вас уже есть активный диалог с поддержкой.\n\n" + "• Продолжить текущий - чтобы писать в существующий диалог\n" + "• Создать новый - если хотите начать новый запрос (старый диалог будет архивирован)", + reply_markup=ikb.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + return + + await status_clear(message=callback.message, state=state) + await state.set_state(STATE_WAITING_REQUEST) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + try: + await callback.message.edit_text( + text="Отправьте свой запрос:", + reply_markup=ikb.as_markup() + ) + except Exception: + await callback.message.answer( + text="Отправьте свой запрос:", + reply_markup=ikb.as_markup() + ) + await callback.answer() + + +# ===================== Принудительное создание нового топика ===================== +@router.callback_query(F.data == "force_new") +async def force_new_callback(callback: CallbackQuery, state: FSMContext) -> None: + """Принудительное создание нового топика (при наличии активного)""" + user_id = callback.from_user.id + + # Уведомляем в старом топике о создании нового + if has_active_topic(user_id): + await send_topic_message( + user_id, + f"🔔 Пользователь начал новый запрос\n" + f"Старый топик будет архивирован." + ) + # Не удаляем старый топик из мапы сразу - он перезапишется при создании нового + + await status_clear(message=callback.message, state=state) + await state.set_state(STATE_WAITING_REQUEST) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + await callback.message.edit_text( + text="📝 Создание нового запроса\n\nОтправьте ваш запрос:", + reply_markup=ikb.as_markup(), + parse_mode="HTML" + ) + await callback.answer() + + +# ===================== Обработчик сообщения /new ===================== +@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: Message, state: FSMContext) -> None: + """Обработчик команды /new из текстового сообщения""" + user_id = message.from_user.id + + # Проверяем, есть ли уже активный топик + if has_active_topic(user_id): + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Продолжить диалог💬", callback_data='continue_dialog')) + + await message.answer( + text="⚠️ У вас уже есть активный диалог с поддержкой.\n\n" + "Используйте кнопку ниже чтобы продолжить общение в существующем диалоге.", + reply_markup=ikb.as_markup(), + parse_mode="HTML" + ) + return + + await status_clear(message=message, state=state) + await state.set_state(STATE_WAITING_REQUEST) + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start')) + + await message.answer( + text="Отправьте свой запрос:", + reply_markup=ikb.as_markup() + ) + + +# ===================== Создание топика и отправка запроса ===================== +@router.message(StateFilter(STATE_WAITING_REQUEST)) +async def process_request(message: Message, state: FSMContext) -> None: + """Создание топика и отправка запроса пользователя""" + text = message.text.strip() + if not text: + await message.reply("⚠️ Пожалуйста, отправьте непустое сообщение.") + return + + user = message.from_user + + try: + # Создаем новый топик для пользователя + topic_name = f"👤 {user.full_name} (ID: {user.id})" + topic_result = await message.bot.create_forum_topic( + chat_id=ImportantID.SUPPORT_CHAT_ID, + name=topic_name + ) + + thread_id = topic_result.message_thread_id + + # Отправляем сообщение пользователя в новый топик + formatted_text = f"📩 Сообщение от {user.full_name}:\n{text}" + sent_msg = await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=formatted_text, + parse_mode="HTML" + ) + + # Отправляем сообщение с уведомлением (со звуком) + await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text="🔔 Новый запрос создан\nАдминистратор уведомлен.", + parse_mode="HTML" + ) + + # Сохраняем связь пользователя и топика + user_topic_map[user.id] = thread_id + topic_message_map[sent_msg.message_id] = user.id + + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Перейти к диалогу💬", callback_data='continue_dialog')) + ikb.row(InlineKeyboardButton(text="В меню↩️", callback_data='start')) + + await message.answer( + text="✅ Запрос отправлен!\n\n" + "Администратор ответит в этом боте. Вы можете продолжить общение через меню.", + reply_markup=ikb.as_markup(), + parse_mode="HTML" + ) + await state.clear() + + except Exception as e: + await message.reply(f"⚠️ Не удалось создать запрос: {e}") + + +# ===================== Пересылка сообщений пользователя в топик ===================== +@router.message(F.chat.type == "private", ~F.reply_to_message) +async def forward_user_to_admin(message: Message) -> None: + """Пересылает сообщения пользователя в топик (если есть активный диалог)""" + if message.from_user.is_bot: + return + + user_id = message.from_user.id + + # Проверяем, есть ли активный топик + if not has_active_topic(user_id): + return # Нет активного топика - игнорируем + + # Получаем топик пользователя + thread_id = user_topic_map.get(user_id) + if not thread_id: + return + + try: + # Отправляем сообщение пользователя в топик + if message.text: + formatted_text = f"💬 Сообщение от {message.from_user.full_name}:\n{message.html_text}" + sent_msg = await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=formatted_text, + parse_mode="HTML" + ) + topic_message_map[sent_msg.message_id] = user_id + + elif message.photo: + caption = f"💬 Сообщение от {message.from_user.full_name}:\n{message.html_text}" if message.caption else f"💬 Сообщение от {message.from_user.full_name}:" + sent_msg = await message.bot.send_photo( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + photo=message.photo[-1].file_id, + caption=caption, + parse_mode="HTML" + ) + topic_message_map[sent_msg.message_id] = user_id + + await message.answer("✅ Сообщение отправлено администратору") + + except Exception as e: + await message.answer(f"⚠️ Не удалось отправить сообщение: {e}") + + +# ===================== Пересылка ответов админа пользователю ===================== +@router.message(F.chat.id == ImportantID.SUPPORT_CHAT_ID, F.message_thread_id) +async def forward_admin_to_user(message: Message) -> None: + """Пересылает сообщения админа из топика пользователю""" + if message.from_user.is_bot: + return + + thread_id = message.message_thread_id + + # Ищем пользователя по 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 # Не наш топик + + try: + # Пересылаем сообщение админа пользователю + if message.text: + text = f"👨‍💼 Ответ администратора:\n{message.html_text}" + sent_msg = await message.bot.send_message( + chat_id=user_id, + text=text, + parse_mode="HTML" + ) + # Сохраняем связь для возможного ответа пользователя + topic_message_map[sent_msg.message_id] = user_id + + elif message.photo: + caption = f"👨‍💼 Ответ администратора:\n{message.html_text}" if message.caption else "👨‍💼 Ответ администратора:" + await message.bot.send_photo( + chat_id=user_id, + photo=message.photo[-1].file_id, + caption=caption, + parse_mode="HTML" + ) + + except Exception as e: + log(level='ERROR', log_type='FORWARD', text=f"Ошибка пересылки админ->пользователь: {e}") + + +# ===================== Пересылка ответов пользователя в топик ===================== +@router.message(F.chat.type == "private", F.reply_to_message) +async def forward_user_reply_to_admin(message: Message) -> None: + """Пересылает ответы пользователя (reply) в топик""" + if message.from_user.is_bot: + return + + user_id = message.from_user.id + reply_to_id = message.reply_to_message.message_id + + # Проверяем, является ли это ответом на сообщение из топика + original_user_id = topic_message_map.get(reply_to_id) + if not original_user_id or original_user_id != user_id: + return + + # Получаем топик пользователя + thread_id = user_topic_map.get(user_id) + if not thread_id: + await message.reply("⚠️ Не найден активный диалог. Используйте /new для нового запроса.") + return + + try: + # Отправляем ответ пользователя в топик + if message.text: + formatted_text = f"💬 Ответ от {message.from_user.full_name}:\n{message.html_text}" + sent_msg = await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=formatted_text, + parse_mode="HTML" + ) + topic_message_map[sent_msg.message_id] = user_id + + elif message.photo: + caption = f"💬 Ответ от {message.from_user.full_name}:\n{message.html_text}" if message.caption else f"💬 Ответ от {message.from_user.full_name}:" + sent_msg = await message.bot.send_photo( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + photo=message.photo[-1].file_id, + caption=caption, + parse_mode="HTML" + ) + topic_message_map[sent_msg.message_id] = user_id + + await message.reply("✅ Ответ отправлен администратору.") + + except Exception as e: + await message.reply(f"⚠️ Не удалось отправить ответ: {e}") diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py new file mode 100644 index 0000000..359353e --- /dev/null +++ b/bot/handlers/commands/users/start_cmd.py @@ -0,0 +1,68 @@ +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.i18n import gettext as _ +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from bot.core.bots import BotInfo +from bot.templates import msg_photo +from configs import COMMANDS, RpValue +from .new_cmd import user_topic_map # Импортируем мапу топиков из модуля new + +__all__ = ("router",) +CMD: str = "start".casefold() +router: Router = Router(name=f"{CMD}_cmd_router") + + +@router.callback_query(F.data.casefold() == CMD) +@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True)) +async def start_cmd(update: Message | CallbackQuery, state: FSMContext) -> None: + """Обработчик команды /start""" + # Определяем тип update + if isinstance(update, CallbackQuery): + message = update.message + callback = update + else: + message = update + callback = None + + # Проверяем, есть ли у пользователя активный топик + user_id = update.from_user.id + has_active_topic = user_id in user_topic_map + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Википедия🌐", url="https://t.me/PrimoWiki")) + + if has_active_topic: + # Если есть активный топик, показываем кнопку "Продолжить диалог" + ikb.row(InlineKeyboardButton(text="Продолжить диалог💬", callback_data='continue_dialog')) + else: + # Если нет активного топика, показываем кнопку "Связаться" + ikb.row(InlineKeyboardButton(text="Связаться👀", callback_data='new')) + + # Формируем приветственное сообщение + text: str = _( + """Добро пожаловать, {name}! + +Я ваш помощник по проекту — PrimoWiki! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! + +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! +""" + ).format( + url=update.from_user.url, + name=update.from_user.first_name, + rp_name=RpValue.RP_NAME, + ) + + # Добавляем информацию об активном диалоге, если есть + if has_active_topic: + text += "\n\n💬 У вас есть активный диалог с поддержкой!" + + # Отправляем сообщение + await msg_photo(message=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb) + + if callback: + await callback.answer() diff --git a/bot/handlers/commands/users/union_cmd.py b/bot/handlers/commands/users/union_cmd.py new file mode 100644 index 0000000..f537fd8 --- /dev/null +++ b/bot/handlers/commands/users/union_cmd.py @@ -0,0 +1,58 @@ +# bot/handlers/commands/union_cmd.py +from aiogram import Router, F +from aiogram.filters import Command +from aiogram.types import Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from aiogram.fsm.context import FSMContext + +from bot.core import BotInfo +from bot.states.union_states import UnionStates +from bot.templates import msg +from middleware import log + +# Настройка экспорта и роутера +__all__ = ("router",) + + +router: Router = Router(name="union_cmd_router") + + + +@router.callback_query(F.data == "union") +@router.message(Command('union','гтшщт', 'союз', prefix=BotInfo.prefix, ignore_case=True)) +@log(level='INFO', log_type='Start', text="использовал(а) команду /union") +async def create_cmd(message: Message|CallbackQuery, state: FSMContext) -> None: + """ + Обработчик команды /union. + """ + # Сбросим все состояния (отменим создание поста, если оно было) + #await state.clear() + + # Создание инлайн-клавиатуры + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="Правила❗️", url='https://teletype.in/@velli_arsaan/XxUiHcB4Puj')) + ikb.row(InlineKeyboardButton(text="Назад↪️", callback_data='start')) + + # Создание базовых переменных сообщения + caption: str = f""" +Приветствуем! Это бот для связи по вопросам союзов проекта ˚₊· ‌‌‌‌➳ 𝑆𝑦𝑠𝑡𝑒𝑚 𝑅𝑒𝑠𝑒𝑡 ·₊˚. +Задайте свой вопрос, и мы постараемся ответить вам в ближайшее время — в некотором случае можем попроосить вас дать юз/ссылку на ваш проект. + +Предложение о заключении союзов должно выглядеть вот так: +– Название +– Юз и ссылка на инфо +– Юз и ссылка на лайф +– Условия союзов +– Юзер следящего с вашей стороны +– Желаемый следящий с нашей стороны (мы будем в праве поставить вам другого, но тот, которого вы назовёте, будет в приоритете) +– Кодовое предложение из условий союзов. Оно состоит из 4 слов, которые расположены в верном порядке в статье о наших условиях сотрудничества. + +Имейте ввиду, что мы можем отказаться от союза без объяснения причин! +""" + + # Обработчик ответа на сообщение + await msg(message=message, text=caption, markup=ikb) + + # Установим состояние ожидания анкеты + await state.set_state(UnionStates.waiting_for_union) diff --git a/bot/handlers/custom/__init__.py b/bot/handlers/custom/__init__.py new file mode 100644 index 0000000..9677220 --- /dev/null +++ b/bot/handlers/custom/__init__.py @@ -0,0 +1,12 @@ +# bot/handlers/__init__.py +from aiogram import Router +from .econom import router as economy_router + +# Настройка экспорта и роутера +__all__ = ('router',) +router: Router = Router(name=__name__) + +# Подготовка мастер-роутера +router.include_routers( + economy_router, +) diff --git a/bot/handlers/custom/econom.py b/bot/handlers/custom/econom.py new file mode 100644 index 0000000..33e28d8 --- /dev/null +++ b/bot/handlers/custom/econom.py @@ -0,0 +1,286 @@ +# modules/economy.py +import json +from pathlib import Path +from typing import Dict, Optional, Tuple, List + +import aiofiles +from aiogram import Router +from aiogram.filters import Command, CommandObject +from aiogram.types import Message, User +from aiogram.utils.markdown import hbold + +from bot.filters import IsOwner + +# ==================== Конфигурация ==================== +ECONOMY_FILE = Path("data/economy.json") +ECONOMY_FILE.parent.mkdir(parents=True, exist_ok=True) +CURRENCY_NAME = "коинов" + + +# ==================== Хранилище ==================== +class Economy: + def __init__(self): + self.data: Dict[int, dict] = {} # user_id → {balance, username, full_name} + self.username_to_id: Dict[str, int] = {} # username.lower() → user_id + + async def load(self): + if not ECONOMY_FILE.exists(): + return + try: + async with aiofiles.open(ECONOMY_FILE, "r", encoding="utf-8") as f: + content = await f.read() + if not content.strip(): + return + raw = json.loads(content) + self.data = {int(uid): info for uid, info in raw.items()} + self.username_to_id = {} + for uid, info in self.data.items(): + username = info.get("username") + if username: + self.username_to_id[username.lower()] = uid + except Exception as e: + print(f"[Economy] Load error: {e}") + + async def save(self): + try: + async with aiofiles.open(ECONOMY_FILE, "w", encoding="utf-8") as f: + await f.write(json.dumps(self.data, indent=2, ensure_ascii=False)) + except Exception as e: + print(f"[Economy] Save error: {e}") + + async def ensure_user(self, user_id: int, username: Optional[str] = None, full_name: Optional[str] = None): + """Создаёт пользователя с 0 балансом, если его нет""" + if user_id not in self.data: + self.data[user_id] = { + "balance": 50, + "username": username, + "full_name": full_name or "Unknown User" + } + if username: + self.username_to_id[username.lower()] = user_id + await self.save() + + # Обновляем данные, если изменились + updated = False + if username and self.data[user_id]["username"] != username: + old = self.data[user_id]["username"] + if old and old.lower() in self.username_to_id: + del self.username_to_id[old.lower()] + self.data[user_id]["username"] = username + self.username_to_id[username.lower()] = user_id + updated = True + + if full_name and self.data[user_id]["full_name"] != full_name: + self.data[user_id]["full_name"] = full_name + updated = True + + if updated: + await self.save() + + async def get_balance(self, user: User) -> int: + await self.ensure_user(user.id, user.username, user.full_name) + return self.data[user.id]["balance"] + + async def modify_balance(self, user_id: int, delta: int, username: Optional[str] = None, full_name: Optional[str] = None) -> int: + await self.ensure_user(user_id, username, full_name) + self.data[user_id]["balance"] += delta + await self.save() + return self.data[user_id]["balance"] + + async def set_balance(self, user_id: int, amount: int, username: Optional[str] = None, full_name: Optional[str] = None): + await self.ensure_user(user_id, username, full_name) + self.data[user_id]["balance"] = amount + await self.save() + + async def delete_user(self, user_id: int) -> bool: + if user_id in self.data: + username = self.data[user_id].get("username") + if username and username.lower() in self.username_to_id: + del self.username_to_id[username.lower()] + del self.data[user_id] + await self.save() + return True + return False + + def resolve_id(self, username: str) -> Optional[int]: + return self.username_to_id.get(username.removeprefix("@").lower()) + + def get_top(self, limit: int = 20) -> List[Tuple[int, int, str, str]]: + """Топ только с положительным балансом""" + items = [] + for uid, info in self.data.items(): + bal = info["balance"] + if bal <= -1000: + continue # ← НЕ ПОКАЗЫВАЕМ НУЛЕВЫЕ БАЛАНСЫ + items.append(( + uid, + bal, + info.get("username") or "", + info.get("full_name") or f"User#{uid}" + )) + return sorted(items, key=lambda x: x[1], reverse=True)[:limit] + + +economy = Economy() +router = Router(name="economy") + + +# ==================== Утилиты ==================== +def fmt(num: int) -> str: + return f"{num:,}".replace(",", " ") + + +def user_mention(user: Optional[User] = None, username: str = "", full_name: str = "") -> str: + if user: + if user.username: + return f"@{user.username}" + return hbold(user.full_name or "Unknown") + if username: + return f"@{username}" + return hbold(full_name or "Unknown User") + + +# ==================== Функция для регистрации при любом сообщении ==================== +async def register_user_on_message(message: Message): + """Вызывай эту функцию в глобальном обработчике сообщений""" + if message.from_user: + await economy.ensure_user( + user_id=message.from_user.id, + username=message.from_user.username, + full_name=message.from_user.full_name + ) + + +# ==================== Команды ==================== + +@router.message(Command("balance")) +async def cmd_balance(message: Message, command: CommandObject): + target = message.from_user + + if message.reply_to_message and message.reply_to_message.from_user: + target = message.reply_to_message.from_user + elif command.args: + uid = economy.resolve_id(command.args.strip()) + if uid: + info = economy.data[uid] + name = user_mention(username=info.get("username"), full_name=info.get("full_name")) + await message.answer(f"Баланс {name}: {hbold(fmt(info['balance']))} {CURRENCY_NAME}") + return + + bal = await economy.get_balance(target) + await message.answer(f"Баланс {user_mention(target)}: {hbold(fmt(bal))} {CURRENCY_NAME}") + + +async def _get_target(message: Message, arg: Optional[str] = None): + if message.reply_to_message and message.reply_to_message.from_user: + return message.reply_to_message.from_user, None, None + + if arg: + username_raw = arg.strip().removeprefix("@") + uid = economy.resolve_id("@" + username_raw) or economy.resolve_id(username_raw) + return None, uid, username_raw + + return message.from_user, None, None + + +@router.message(Command("setbalance"), IsOwner(send_error_message=True)) +async def cmd_setbalance(message: Message, command: CommandObject): + if not command.args: + return await message.answer("Использование: /setbalance <сумма> [@username | реплай]") + + parts = command.args.strip().split(maxsplit=2) + try: + amount = int(parts[0]) + except ValueError: + return await message.answer("Сумма должна быть числом.") + + user_obj, uid, username = await _get_target(message, parts[1] if len(parts) > 1 else None) + target_id = user_obj.id if user_obj else uid + + if not target_id: + return await message.answer("Пользователь не найден.") + + await economy.set_balance(target_id, amount, username, user_obj.full_name if user_obj else None) + await message.answer(f"Баланс {user_mention(user_obj, username)} → {hbold(fmt(amount))} {CURRENCY_NAME}") + return None + + +@router.message(Command("plusbalance"), IsOwner(send_error_message=True)) +async def cmd_plusbalance(message: Message, command: CommandObject): + if not command.args: + return await message.answer("Использование: /plusbalance <сумма> [@username | реплай]") + + parts = command.args.strip().split(maxsplit=2) + try: + delta = int(parts[0]) + except ValueError: + return await message.answer("Сумма должна быть числом.") + + user_obj, uid, username = await _get_target(message, parts[1] if len(parts) > 1 else None) + target_id = user_obj.id if user_obj else uid + + if not target_id: + return await message.answer("Пользователь не найден.") + + new_bal = await economy.modify_balance(target_id, delta, username, user_obj.full_name if user_obj else None) + await message.answer(f"{user_mention(user_obj, username)} +{fmt(delta)} → {hbold(fmt(new_bal))} {CURRENCY_NAME}") + return None + + +@router.message(Command("minbalance"), IsOwner(send_error_message=True)) +async def cmd_minbalance(message: Message, command: CommandObject): + if not command.args: + return await message.answer("Использование: /minbalance <сумма> [@username | реплай]") + + parts = command.args.strip().split(maxsplit=2) + try: + delta = int(parts[0]) + except ValueError: + return await message.answer("Сумма должна быть числом.") + + user_obj, uid, username = await _get_target(message, parts[1] if len(parts) > 1 else None) + target_id = user_obj.id if user_obj else uid + + if not target_id: + return await message.answer("Пользователь не найден.") + + new_bal = await economy.modify_balance(target_id, -delta, username, user_obj.full_name if user_obj else None) + await message.answer(f"{user_mention(user_obj, username)} -{fmt(delta)} → {hbold(fmt(new_bal))} {CURRENCY_NAME}") + return None + + +@router.message(Command("top")) +async def cmd_top(message: Message): + top = economy.get_top(20) + if not top: + return await message.answer("Топ пустой — никто ещё не заработал коины!") + + lines = ["Топ-20 богачей:"] + for i, (_, bal, username, full_name) in enumerate(top, 1): + medal = ["1st", "2nd", "3rd"][i-1] if i <= 3 else f"{i}." + name = f"@{username}" if username else hbold(full_name) + lines.append(f"{medal} {name} — {hbold(fmt(bal))} {CURRENCY_NAME}") + + await message.answer("\n".join(lines)) + return None + + +@router.message(Command("deletebalance"), IsOwner(send_error_message=True)) +async def cmd_deletebalance(message: Message): + user_obj, uid, username = await _get_target(message) + if not (user_obj or uid): + return await message.answer("Укажи пользователя реплаем или @username") + + target_id = user_obj.id if user_obj else uid + deleted = await economy.delete_user(target_id) + name = user_mention(user_obj, username) + await message.answer(f"Запись {name} {'удалена' if deleted else 'не существовала'}") + return None + + +# ==================== Запуск ==================== +async def on_startup(_): + await economy.load() + + +__all__ = ["router", "on_startup", "register_user_on_message"] \ No newline at end of file diff --git a/bot/handlers/form_utils/__init__.py b/bot/handlers/form_utils/__init__.py new file mode 100644 index 0000000..09edcad --- /dev/null +++ b/bot/handlers/form_utils/__init__.py @@ -0,0 +1,16 @@ +# bot/handlers/__init__.py +from aiogram import Router +from .form_answer import router as form_answer_router +from .topic_replies import router as topic_replies_router +from .form_callback import router as form_callback_router + +# Настройка экспорта и роутера +__all__ = ('router',) +router: Router = Router(name="handlers_router") + +# Подготовка мастер-роутера +router.include_routers( + form_answer_router, + topic_replies_router, + form_callback_router, +) diff --git a/bot/handlers/form_utils/form_answer.py b/bot/handlers/form_utils/form_answer.py new file mode 100644 index 0000000..b45c389 --- /dev/null +++ b/bot/handlers/form_utils/form_answer.py @@ -0,0 +1,45 @@ +from aiogram import Router +from aiogram.types import Message +from aiogram.fsm.context import FSMContext +from bot.data.topic_map import user_topic_map +from bot.keyboards import decision_keyboard +from bot.states.anketa_states import StartForm +from configs import ImportantID + +router = Router(name="form_handlers") + +TOPIC_TYPE = "anketa" + +@router.message(StartForm.waiting_for_application) +async def handle_application(message: Message, state: FSMContext): + await state.clear() + await message.answer("Спасибо! Ваша анкета отправлена на рассмотрение!") + + user = message.from_user + user_id = user.id + if user.username: + users = f' от @{user.username}' + else: + users = '' + text = f'Анкета{users}\n\n{message.html_text}' + + key = (user_id, TOPIC_TYPE) # Ключ с типом топика + + if key in user_topic_map: + thread_id = user_topic_map[key] + else: + topic = await message.bot.create_forum_topic( + chat_id=ImportantID.SUPPORT_CHAT_ID, + name=f"Анкета от {user.full_name}" + ) + thread_id = topic.message_thread_id + user_topic_map[key] = thread_id + + await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=thread_id, + text=text, + reply_markup=decision_keyboard(thread_id, TOPIC_TYPE) # <--- Передаём оба аргумента + ) + + diff --git a/bot/handlers/form_utils/form_callback.py b/bot/handlers/form_utils/form_callback.py new file mode 100644 index 0000000..7a41680 --- /dev/null +++ b/bot/handlers/form_utils/form_callback.py @@ -0,0 +1,44 @@ +from aiogram import Router, F +from aiogram.types import CallbackQuery +from bot.data.topic_map import user_topic_map + +router = Router(name="form_callbacks") + +TEXTS = { + "anketa": { + "accept": "🎉 Ваша анкета принята!\n\nДобро пожаловать в проект!", + "reject": "❌ Ваша анкета отклонена.\n\nВы можете попробовать позже." + }, + "application": { + "accept": "🎉 Ваша анкета принята!\n\nДобро пожаловать в проект!", + "reject": "❌ Ваша анкета отклонена.\n\nВы можете попробовать позже." + }, + "union": { + "accept": "🤝 Союз одобрен!\n\nТеперь вы в союзе.", + "reject": "💔 Союз отклонён.\n\nВы можете обсудить детали с администрацией." + } +} + +@router.callback_query(F.data.regexp(r"^([a-z_]+):(accept|reject):(\d+)$")) +async def process_decision_callback(callback: CallbackQuery): + kind, action, thread_id_str = callback.data.split(":") + thread_id = int(thread_id_str) + + 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 = TEXTS.get(kind, {}).get(action) + if not text_to_send: + await callback.answer("Некорректные данные.", show_alert=True) + return + + await callback.message.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("Ответ отправлен пользователю.") diff --git a/bot/handlers/form_utils/topic_replies.py b/bot/handlers/form_utils/topic_replies.py new file mode 100644 index 0000000..3dbc703 --- /dev/null +++ b/bot/handlers/form_utils/topic_replies.py @@ -0,0 +1,29 @@ +from aiogram import Router, F +from aiogram.types import Message +from bot.data.topic_map import user_topic_map + +router: Router = Router(name="topic_replies") + +@router.message(F.is_topic_message, F.reply_to_message, ~F.from_user.is_bot) +async def forward_reply_to_user(message: Message): + thread_id = message.message_thread_id + if thread_id is None: + return # нет thread_id, выходим + + # Найдем user_id по thread_id + user_id = None + for (uid, kind), tid in user_topic_map.items(): + if tid == thread_id: + user_id = uid + break + + if user_id is None: + return # Топик не зарегистрирован + + reply_text = 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}") + diff --git a/bot/handlers/messages/__init__.py b/bot/handlers/messages/__init__.py new file mode 100644 index 0000000..a2fff88 --- /dev/null +++ b/bot/handlers/messages/__init__.py @@ -0,0 +1,15 @@ +from aiogram import Router + +from .default_msg import router as default_message_router +from .ping_test import router as ping_test_message_router + +# Настройка экспорта и роутера +router: Router = Router(name=__name__) + +# Подготовка роутера команд +router.include_routers( + ping_test_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..7d8cfbb --- /dev/null +++ b/bot/handlers/messages/default.py @@ -0,0 +1,14 @@ +from aiogram import Router +from aiogram.types import Message + +from bot.handlers.custom.econom import register_user_on_message + +# Настройки экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + + +@router.message() +async def default_messages(message: Message) -> None: + """Обработчик всех необработанных сообщений.""" + await register_user_on_message(message) \ No newline at end of file diff --git a/bot/handlers/messages/default_msg.py b/bot/handlers/messages/default_msg.py new file mode 100644 index 0000000..517c143 --- /dev/null +++ b/bot/handlers/messages/default_msg.py @@ -0,0 +1,11 @@ +from aiogram import Router +from aiogram.types import Message + +# Настройки экспорта и роутера +router: Router = Router(name=__name__) + + +@router.message() +async def default_msg(message: Message) -> None: + """Обработчик всех необработанных сообщений.""" + return diff --git a/bot/handlers/messages/ping_test.py b/bot/handlers/messages/ping_test.py new file mode 100644 index 0000000..8e2bf2a --- /dev/null +++ b/bot/handlers/messages/ping_test.py @@ -0,0 +1,32 @@ +from aiogram import Router +from aiogram.types import Message + +router: Router = Router(name=__name__) + +# Словарь с ответами по ключам +RESPONSE_DICT: dict[str, str] = { + "пинг": "Понг! 🏓", + "понг": "Пинг!", + "бот": "На месте! 🤖", +} + + +@router.message() +async def auto_response_handler(message: Message) -> None: + """Обработчик автоматических ответов по ключевым словам.""" + if not message.text: + return + + text_lower: str = message.text.casefold().strip() + + # Поиск точного совпадения + if text_lower in RESPONSE_DICT: + response: str = RESPONSE_DICT[text_lower] + await message.answer(response) + return + + # Поиск частичного совпадения (если хотите расширенную функциональность) + for key, response in RESPONSE_DICT.items(): + if key in text_lower and len(key) > 3: # Только для ключей длиннее 3 символов + await message.answer(response) + return diff --git a/bot/handlers/union_utills/__init__.py b/bot/handlers/union_utills/__init__.py new file mode 100644 index 0000000..2d47179 --- /dev/null +++ b/bot/handlers/union_utills/__init__.py @@ -0,0 +1,11 @@ +# bot/handlers/__init__.py +from aiogram import Router +from .union_handlers import router as union_router + +# Настройка экспорта и роутера +router: Router = Router(name=__name__) + +# Подготовка мастер-роутера +router.include_routers( + union_router, +) diff --git a/bot/handlers/union_utills/union_handlers.py b/bot/handlers/union_utills/union_handlers.py new file mode 100644 index 0000000..261664d --- /dev/null +++ b/bot/handlers/union_utills/union_handlers.py @@ -0,0 +1,44 @@ +from aiogram import Router +from aiogram.types import Message +from aiogram.fsm.context import FSMContext +from bot.data.topic_map import user_topic_map +from bot.keyboards import decision_keyboard +from bot.states.union_states import UnionStates +from bot.utils import status_clear +from configs import ImportantID + +router: Router = Router(name="union_handlers") + + + +@router.message(UnionStates.waiting_for_union) +async def handle_union(message: Message, state: FSMContext) -> None: + await message.answer("Спасибо! Ваше сообщение отправлено.") + + user = message.from_user + user_id = user.id + msg_type = "union" + text = f"СОЮЗ\n\n{message.html_text}" + + key = (user_id, msg_type) + + if key in user_topic_map: + thread_id = user_topic_map[key] + else: + topic = await message.bot.create_forum_topic( + chat_id=ImportantID.SUPPORT_CHAT_ID, + name=f"Сообщение от {user.full_name}" + ) + thread_id = topic.message_thread_id + user_topic_map[key] = thread_id + + await message.bot.send_message( + chat_id=ImportantID.SUPPORT_CHAT_ID, + message_thread_id=724, + text=text, + parse_mode="HTML", + reply_markup=decision_keyboard(thread_id, "union") + ) + + await status_clear(message=message, state=state) + diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..93f594b --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1,2 @@ +from .inline import * +from .reply 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..e9bb032 --- /dev/null +++ b/bot/keyboards/inline/decision.py @@ -0,0 +1,18 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +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..5738326 --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1,55 @@ +from aiogram import Dispatcher, Bot + +from configs import ImportantID +from .error_mdw import ErrorHandlingMiddleware +from .logging_mdw import LoggingMiddleware +from .msg_mdw import MessageCounterMiddleware +from .referal_mdw import ReferralMiddleware +from .spam_mdw import RateLimitMiddleware +from .subscription_mdw import SubscriptionMiddleware +from .time_mdw import TimingMiddleware +from .ban_user_mdw import BanCheckMiddleware + +# Настройки экспорта +__all__ = ( + "LoggingMiddleware", + "SubscriptionMiddleware", + "RateLimitMiddleware", + "ErrorHandlingMiddleware", + "TimingMiddleware", + "MessageCounterMiddleware", + "setup_middlewares", + "ReferralMiddleware", + "BanCheckMiddleware", +) + + +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 = [ + BanCheckMiddleware(), + # RateLimitMiddleware(rate_limit=3, time_period=5.0), # Антифлуд + # SubscriptionMiddleware(bot=bot, channel_ids=channel_ids), # Проверка подписки + MessageCounterMiddleware(), # Подсчет сообщений + ReferralMiddleware(), # Проверка реф-ссылок + ] + + # Регистрируем 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/ban_user_mdw.py b/bot/middlewares/ban_user_mdw.py new file mode 100644 index 0000000..f327b5a --- /dev/null +++ b/bot/middlewares/ban_user_mdw.py @@ -0,0 +1,108 @@ +from typing import Callable, Awaitable, Any, Dict + +from aiogram import BaseMiddleware +from aiogram.types import Message, CallbackQuery, TelegramObject + +from database import db + +__all__ = ("BanCheckMiddleware",) + +class BanCheckMiddleware(BaseMiddleware): + """ + Middleware для проверки забанен ли пользователь. + Если пользователь забанен в боте - блокирует все его действия. + """ + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + """ + Проверяет каждый входящий запрос на наличие пользователя в черном списке. + + Args: + handler: Следующий обработчик + event: Событие (сообщение, callback и т.д.) + data: Данные контекста + + Returns: + Результат обработчика или None если пользователь забанен + """ + # Извлекаем информацию о пользователе из события + user = await self._extract_user(event) + + if user is None: + # Не смогли определить пользователя - пропускаем + return await handler(event, data) + + # Проверяем в базе данных статус пользователя + user_db = await db.get_user(user.id) + + if user_db and user_db.status == "banned": + # Пользователь забанен - блокируем запрос + await self._send_ban_message(event, data) + return None + + # Пользователь не забанен - пропускаем запрос дальше + return await handler(event, data) + + @staticmethod + async def _extract_user(event: TelegramObject) -> Any: + """ + Извлекает пользователя из разных типов событий. + + Args: + event: Событие Telegram + + Returns: + Объект пользователя или None + """ + if isinstance(event, Message): + return event.from_user + elif isinstance(event, CallbackQuery): + return event.from_user + # Можно добавить другие типы событий при необходимости + return None + + @staticmethod + async def _send_ban_message(event: TelegramObject, data: Dict[str, Any]) -> None: + """ + Отправляет сообщение о бане пользователю. + + Args: + event: Событие которое triggered проверку + data: Данные контекста с ботом + """ + bot = data.get('bot') + + if not bot: + return + + chat_id = None + message_id = None + + # Определяем куда отправлять сообщение в зависимости от типа события + if isinstance(event, Message): + chat_id = event.chat.id + message_id = event.message_id + elif isinstance(event, CallbackQuery) and event.message: + chat_id = event.message.chat.id + message_id = event.message.message_id + + if chat_id: + try: + if isinstance(event, CallbackQuery): + # Для callback запросов отвечаем уведомлением + await event.answer("🚫 Вы заблокированы в боте!", show_alert=True) + else: + # Для сообщений отправляем новое сообщение + await bot.send_message( + chat_id=chat_id, + text="🚫 Вы заблокированы в боте!", + reply_to_message_id=message_id + ) + except Exception: + # Игнорируем ошибки отправки (пользователь мог заблокировать бота) + pass diff --git a/bot/middlewares/error_mdw.py b/bot/middlewares/error_mdw.py new file mode 100644 index 0000000..4ce5870 --- /dev/null +++ b/bot/middlewares/error_mdw.py @@ -0,0 +1,201 @@ +from typing import Callable, Awaitable, Any, Dict + +from aiogram import Bot, BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery, Update + +from middleware.loggers import logger + + +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: str = self._extract_user_info(event) + + # Логируем ошибку + error_message: str = f"Ошибка в хендлере: {type(e).__name__}: {str(e)}" + + logger.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: 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: str = "" + + # Для Message + if isinstance(event, Message) and hasattr(event, 'text') and event.text: + event_text: str = event.text + # Для CallbackQuery + elif isinstance(event, CallbackQuery) and hasattr(event, 'data') and event.data: + event_text: str = f"callback: {event.data}" + # Для Update + elif isinstance(event, Update): + if event.message and event.message.text: + event_text: str = event.message.text + elif event.callback_query and event.callback_query.data: + event_text: str = f"callback: {event.callback_query.data}" + elif event.edited_message and event.edited_message.text: + event_text: str = 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: + """Уведомляет администраторов об ошибке.""" + bot: Bot = event.bot if hasattr(event, 'bot') else None + + if bot: + for admin_id in self.admin_ids: + try: + event_info: str = f"Событие: {type(event).__name__}" + event_text: str = self._extract_event_text(event) + if event_text: + event_info += f", текст: {event_text}" + + full_message: str = ( + f"🚨 Ошибка в боте:\n\n" + f"Пользователь: {user_str}\n" + f"Ошибка: {error_message}\n" + f"{event_info}" + ) + + await bot.send_message(admin_id, full_message) + + logger.info( + text=f"Администратор {admin_id} уведомлен об ошибке", + log_type="ADMIN_NOTIFIED", + user=user_str + ) + + except Exception as e: + logger.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: str = ( + "⚠️ Произошла непредвиденная ошибка. " + "Разработчики уже уведомлены и работают над исправлением.\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) + + logger.info( + text="Пользователю отправлено сообщение об ошибке", + log_type="ERROR_MESSAGE_SENT", + user=user_str + ) + + except Exception as e: + logger.error( + text=f"Не удалось отправить сообщение об ошибке: {e}", + log_type="ERROR_MESSAGE_FAILED", + user=user_str + ) diff --git a/bot/middlewares/logging_mdw.py b/bot/middlewares/logging_mdw.py new file mode 100644 index 0000000..74779ad --- /dev/null +++ b/bot/middlewares/logging_mdw.py @@ -0,0 +1,272 @@ +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 configs import BotSettings, COMMANDS # импортируем настройки и команды +from middleware.loggers import logger # ваш глобальный логгер + + +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) + + # Логируем получение события с префиксом проекта + logger.info( + text=log_text, + log_type=prefixed_log_type, + user=user_str + ) + + try: + # Передаем событие следующему обработчику + result: Any = await handler(event, data) + + # Логируем успешное выполнение для команд + if log_type == "CMD": + logger.info( + text=f"[SUCCESS] команда обработана", + log_type=prefixed_log_type, + user=user_str + ) + + return result + + except Exception as e: + # Логируем ошибку при обработке с префиксом проекта + logger.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: Message | None = ( + 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: Message | None = 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: User | None = 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: User | None = 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: User | None = 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: Message | None = 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 diff --git a/bot/middlewares/msg_mdw.py b/bot/middlewares/msg_mdw.py new file mode 100644 index 0000000..50c80bc --- /dev/null +++ b/bot/middlewares/msg_mdw.py @@ -0,0 +1,57 @@ +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: int = message.from_user.id + message_text: str = 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, + ) diff --git a/bot/middlewares/referal_mdw.py b/bot/middlewares/referal_mdw.py new file mode 100644 index 0000000..0a6b34f --- /dev/null +++ b/bot/middlewares/referal_mdw.py @@ -0,0 +1,59 @@ +from typing import Callable, Awaitable, Any, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.filters.command import CommandObject +from aiogram.types import TelegramObject, Message + +from middleware.loggers import logger + + +class ReferralMiddleware(BaseMiddleware): + """ + Middleware для перехвата и обработки реферальных ссылок (?start=...). + + Основные задачи: + - Отслеживание перехода по deep-link (например, /start ref123) + - Централизованное логирование + - Возможность передачи кода дальше в хендлеры + - Подготовка к сохранению кода в базу данных + """ + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + """ + Перехватывает входящие сообщения и извлекает deep-link аргумент, + если пользователь зашёл по реферальной ссылке. + + Args: + handler: Следующий обработчик в цепочке middleware + event: Входящее событие (Message, CallbackQuery и др.) + data: Контекстные данные, доступные хендлеру + + Returns: + Результат работы следующего обработчика + """ + # Проверяем, что событие — это именно сообщение + if isinstance(event, Message): + # Извлекаем объект команды (если был установлен фильтр CommandStart) + command: Optional[CommandObject] = data.get("command") + + # Проверяем, что это именно команда /start с аргументом + if command and command.command.casefold() == "start" and command.args: + ref_code: str = command.args + user_id: int = event.from_user.id + username: Optional[str] = event.from_user.username + + # 👉 Здесь можно сохранить код в БД + logger.debug( + f"[Referral] user={user_id}, username={username}, ref={ref_code}" + ) + + # Пробрасываем реф-код в data, чтобы использовать в хендлере + data["ref_code"] = ref_code + + # Передаём управление дальше + return await handler(event, data) diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py new file mode 100644 index 0000000..e20593e --- /dev/null +++ b/bot/middlewares/spam_mdw.py @@ -0,0 +1,98 @@ +import time +from collections import defaultdict +from typing import Callable, Awaitable, Any, Dict + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery + +from middleware.loggers import logger # ваш логгер + + +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]: dict[int, list[float]] = [ + call_time for call_time in self.user_calls[user_id] + if current_time - call_time < self.time_period + ] + + # Логируем текущее состояние rate limit + if log: + logger.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: + logger.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) + + logger.debug( + text=f"Запрос добавлен в rate limit", + log_type="RATE_LIMIT_ADDED", + user=user_str + ) + + return await handler(event, data) diff --git a/bot/middlewares/subscription_mdw.py b/bot/middlewares/subscription_mdw.py new file mode 100644 index 0000000..96dd33a --- /dev/null +++ b/bot/middlewares/subscription_mdw.py @@ -0,0 +1,110 @@ +from typing import Callable, Awaitable, Any, Dict + +from aiogram import BaseMiddleware, Bot +from aiogram.exceptions import TelegramBadRequest +from aiogram.types import TelegramObject, Message, CallbackQuery, InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from middleware.loggers import logger + + +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}" + + # Логируем начало проверки подписки + logger.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: + logger.error( + text=f"Ошибка проверки подписки на канал {channel_id}: {e}", + log_type="SUBSCRIPTION_ERROR", + user=user_str + ) + + # Если пользователь не подписан на некоторые каналы + if not_subscribed_channels: + logger.warning( + text=f"Пользователь не подписан на каналы: {', '.join(not_subscribed_channels)}", + log_type="SUBSCRIPTION_FAILED", + user=user_str + ) + + warning_text: str = ( + "📢 Для использования бота необходимо подписаться на наши каналы!\n\n" + "После подписки нажмите /start для продолжения." + ) + + # Создаем кнопку "Проверить подписку" + ikb: InlineKeyboardBuilder = InlineKeyboardBuilder() + ikb.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription")) + + if isinstance(event, Message): + await event.answer(warning_text, reply_markup=ikb.as_markup()) + elif isinstance(event, CallbackQuery): + await event.message.answer(warning_text, reply_markup=ikb.as_markup()) + await event.answer() + + return None + + # Логируем успешную проверку подписки + logger.info( + text="Пользователь подписан на все required каналы", + log_type="SUBSCRIPTION_SUCCESS", + user=user_str + ) + + # Если подписка есть, продолжаем обработку + return await handler(event, data) diff --git a/bot/middlewares/time_mdw.py b/bot/middlewares/time_mdw.py new file mode 100644 index 0000000..c5eea15 --- /dev/null +++ b/bot/middlewares/time_mdw.py @@ -0,0 +1,83 @@ +from time import time +from typing import Callable, Awaitable, Any, Dict + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Message, CallbackQuery, Update + +from middleware.loggers import logger + + +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: Any = 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: # Медленные запросы + logger.warning( + text=f"Медленный хендлер: {execution_time:.2f}сек", + log_type="SLOW_HANDLER", + user=user_str + ) + elif execution_time > 0.5 and perm == "medium": # Средние запросы + logger.info( + text=f"Среднее время выполнения: {execution_time:.3f}сек", + log_type="HANDLER_TIMING", + user=user_str + ) + elif perm == "fast": # Быстрые запросы + logger.debug( + text=f"Быстрое выполнение: {execution_time:.3f}сек", + log_type="HANDLER_TIMING_FAST", + user=user_str + ) diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..44939f4 --- /dev/null +++ b/bot/states/__init__.py @@ -0,0 +1,2 @@ +from .anketa_states import * +from .new_states import * diff --git a/bot/states/anketa_states.py b/bot/states/anketa_states.py new file mode 100644 index 0000000..b15d8b7 --- /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() diff --git a/bot/states/new_states.py b/bot/states/new_states.py new file mode 100644 index 0000000..fd93f8b --- /dev/null +++ b/bot/states/new_states.py @@ -0,0 +1,10 @@ +# bot/states/new_states.py +from aiogram.fsm.state import State, StatesGroup + +__all__ = ("NewStates",) + +class NewStates(StatesGroup): + role: State = State() + sorol: State = State() + code_phrase: State = State() + rules: State = State() diff --git a/bot/states/union_states.py b/bot/states/union_states.py new file mode 100644 index 0000000..7c31b15 --- /dev/null +++ b/bot/states/union_states.py @@ -0,0 +1,5 @@ +# bot/states/union_states.py +from aiogram.fsm.state import State, StatesGroup + +class UnionStates(StatesGroup): + waiting_for_union = 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..a864c81 --- /dev/null +++ b/bot/templates/message_callback.py @@ -0,0 +1,88 @@ +from typing import Union + +from aiogram.types import ( + FSInputFile, + CallbackQuery, + Message, + ReplyKeyboardMarkup, + InlineKeyboardMarkup +) +from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder + +# Настройка экспорта в модули +__all__ = ('msg', 'msg_photo', 'markups',) + + +def markups(markup: Union[ + InlineKeyboardBuilder, + ReplyKeyboardBuilder, + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + None] = None, ) -> None: + """Получение маркапа""" + 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) + elif isinstance(markup, (InlineKeyboardMarkup, ReplyKeyboardMarkup)): + reply_markup = markup + return reply_markup + + +async def msg( + message: Message | CallbackQuery, + text: str = "Сообщение отправлено!", + markup: Union[ + InlineKeyboardBuilder, + ReplyKeyboardBuilder, + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + None, + ] = None, +) -> None: + """ + Шаблон для отправки текстового сообщения или ответа на callback-запрос. + + Args: + message (Message | CallbackQuery): Сообщение или callback-запрос. + text (str): Текст сообщения. + markup (Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, InlineKeyboardMarkup, ReplyKeyboardMarkup, None]): + Клавиатура для сообщения. Может быть билдера или готовый объект. + """ + + if isinstance(message, Message): + await message.reply(text=text, reply_markup=markups(markup)) + else: + await message.message.reply(text=text, reply_markup=markups(markup)) + + +async def msg_photo( + message: Message | CallbackQuery, + file: str = "assets/default.jpg", + text: str = "Сообщение отправлено!", + markup: Union[ + InlineKeyboardBuilder, + ReplyKeyboardBuilder, + InlineKeyboardMarkup, + ReplyKeyboardMarkup, + None, + ] = None, +) -> None: + """ + Шаблон для отправки фотографии с подписью или ответа на callback-запрос. + + Args: + message (Message | CallbackQuery): Сообщение или callback-запрос. + file (str): Путь к файлу фотографии. + text (str): Подпись к фото. + markup (Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, InlineKeyboardMarkup, ReplyKeyboardMarkup, None]): + Клавиатура для сообщения. Может быть билдера или готовый объект. + """ + + if isinstance(message, Message): + await message.reply_photo(photo=FSInputFile(file), caption=text, reply_markup=markups(markup)) + else: + await message.message.reply_photo(photo=FSInputFile(file), caption=text, reply_markup=markups(markup)) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..4dc4556 --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,9 @@ +from .argument import * +from .clear_status import * +from .format_time import * +from .interesting_facts import * +from .pagination import * +from .type_message import * +from .usernames import * +from .auto_delete import * +from .hidden_username import * diff --git a/bot/utils/argument.py b/bot/utils/argument.py new file mode 100644 index 0000000..b0bb8fc --- /dev/null +++ b/bot/utils/argument.py @@ -0,0 +1,61 @@ +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/auto_delete.py b/bot/utils/auto_delete.py new file mode 100644 index 0000000..5fbf7a0 --- /dev/null +++ b/bot/utils/auto_delete.py @@ -0,0 +1,19 @@ +from asyncio import sleep +from aiogram.exceptions import TelegramBadRequest + +from bot import bot +from middleware import logger + +__all__ = ("auto_delete_message",) + +async def auto_delete_message(chat_id: int, message_id: int, delay: int = 604800) -> None: + """ + Автоматически удаляет сообщение через указанный промежуток времени. + По умолчанию — 7 суток (604800 секунд). + """ + await sleep(delay=delay) + try: + await bot.delete_message(chat_id=chat_id, message_id=message_id) + logger.info("Закрепленное сообщение удалено") + except TelegramBadRequest as e: + logger.error(f"[ALL] Ошибка при автоудалении: {e}") diff --git a/bot/utils/clear_status.py b/bot/utils/clear_status.py new file mode 100644 index 0000000..fc43e61 --- /dev/null +++ b/bot/utils/clear_status.py @@ -0,0 +1,17 @@ +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +# Настройка экспорта в модули +__all__ = ("status_clear", "inline_clear") + + +async def inline_clear(message: Message | CallbackQuery) -> None: + """Очищает все статусы инлайн сообщений""" + if isinstance(message, CallbackQuery): + await message.answer() + + +async def status_clear(message: Message | CallbackQuery, state: FSMContext) -> None: + """Очищает все статусы, и отвечает на сообщения""" + await state.clear() + await inline_clear(message=message) diff --git a/bot/utils/format_time.py b/bot/utils/format_time.py new file mode 100644 index 0000000..5a93e0e --- /dev/null +++ b/bot/utils/format_time.py @@ -0,0 +1,23 @@ +# Настройка экспорта в модули +__all__ = ("format_retry_time",) + + +def format_retry_time(retry_after: int) -> str: + """ + Форматирование времени повторной попытки в читаемом виде. + + Args: + retry_after (int): Время в секундах до следующей попытки. + + Returns: + str: Строка в формате X часов, Y минут, Z секунд. + """ + hours, remainder = divmod(retry_after, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours > 0: + return f"{hours} часов, {minutes} минут, {seconds} секунд" + elif minutes > 0: + return f"{minutes} минут, {seconds} секунд" + else: + return f"{seconds} секунд" diff --git a/bot/utils/hidden_username.py b/bot/utils/hidden_username.py new file mode 100644 index 0000000..9814f56 --- /dev/null +++ b/bot/utils/hidden_username.py @@ -0,0 +1,21 @@ +from aiogram.types import Message +from aiogram.utils.markdown import hide_link + +from bot import bot + +__all__ = ("hidden_admins_message",) + + +async def hidden_admins_message(message: Message, + text: str = "") -> str: + """ + Формирует текст с упоминанием всех админов через скрытые ссылки. + """ + admins = await bot.get_chat_administrators(message.chat.id) + + hidden_links: str = "".join( + hide_link(f"tg://user?id={admin.user.id}") + for admin in admins if not admin.user.is_bot + ) + + return f"{hidden_links}{text}" diff --git a/bot/utils/interesting_facts.py b/bot/utils/interesting_facts.py new file mode 100644 index 0000000..eaab805 --- /dev/null +++ b/bot/utils/interesting_facts.py @@ -0,0 +1,29 @@ +from random import choice + +from configs.config import Lists + +# Настройка экспорта в модули +__all__ = ("interesting_fact",) + + +def interesting_fact(mode: str = "факт", lists: list[str] = None) -> str: + """ + Возвращает случайный факт, анекдот или цитату, в зависимости от режима. + + :param mode: Строка, определяющая тип контента ("факт", "анекдот", "цитата"). + :param lists: Необязательный список строк, из которого можно выбирать вручную. + :return: Случайный элемент из соответствующего списка. + """ + if lists is not None: + return choice(lists) + + mode: str = mode.lower() + + if mode == "анекдот": + source: list[str] = Lists.jokes + elif mode == "цитата": + source: list[str] = Lists.quotes + else: + source: list[str] = Lists.facts + + return choice(source) diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py new file mode 100644 index 0000000..34d0254 --- /dev/null +++ b/bot/utils/pagination.py @@ -0,0 +1,29 @@ +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/type_message.py b/bot/utils/type_message.py new file mode 100644 index 0000000..d7e7b13 --- /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..1bda013 --- /dev/null +++ b/bot/utils/usernames.py @@ -0,0 +1,23 @@ +from aiogram.types import Message, CallbackQuery + +# Настройка экспорта в модули +__all__ = ('username',) + + +# Функция получения юзера или ID пользователя +def username(message: Message | CallbackQuery) -> 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..e935ffa --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1,3 @@ +from .cmd_list import * +from .config import * +from .roles import * diff --git a/configs/cmd_list.py b/configs/cmd_list.py new file mode 100644 index 0000000..9a7693b --- /dev/null +++ b/configs/cmd_list.py @@ -0,0 +1,104 @@ +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", + ], + "all": [ + "all", "фдд", "norify", "тщкшан", "call", "сфдд", "калл", "rfkk", + ], + "pin": [ + "pin", "зшт", "закреп", "pfrhtg", "закрепить", + "pfrhtgbnm", + ], + "set_name": [ + "set_name", "setname", "ыуетфьу", "ыуе_тфьу", + ], + "set_description": [ + "set_description", "setdescription", "ыуе_вшыскшзещт", "ыуевшыскшзещт", + ], + "set_widget": [ + "set_widget", "setwidget", "ыуе_цшвпук", "ыуецшвпук", + ], + "set_rights": [ + "set_rights", "setrights", "ыуе_кшпреы", "ыуекшпреы", + ], + + "new": [ + "new", "туц", "вступление", + "cnegktybt", "ym.", "нью", + ], + "active": [ + "active", + ] +} diff --git a/configs/config.py b/configs/config.py new file mode 100644 index 0000000..776b709 --- /dev/null +++ b/configs/config.py @@ -0,0 +1,399 @@ +from pathlib import Path +from typing import ClassVar, Final, Optional, Any +from urllib.parse import urlparse, ParseResult + +from aiogram.types import ChatAdministratorRights +from pydantic import field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +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 = False + 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 + SESSION_STRING: Optional[str] = None + + # Идентификаторы + OWNERS_ID: list[int] = [6751720805, ] + ADMIN_ID: list[int] = [6751720805, ] + MODERATOR_ID: int = 0 + IMPORTANT_ID: int = 0 + IMPORTANT_GROUP_ID: int = 0 + IMPORTANT_CHANNEL_ID: int = 0 + SUPPORT_CHAT_ID: int = 0 + SUPPORT_CHAT_ID_TOPIC: 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 + SESSION_STRING: Final[str] = settings.SESSION_STRING + + +class ImportantID: + """Алиасы для важных ID.""" + OWNERS_ID: Final[list[int]] = settings.OWNERS_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 + SUPPORT_CHAT_ID_TOPIC: Final[int] = settings.SUPPORT_CHAT_ID_TOPIC + + +class BotEdit: + """Алиасы для настроек редактирования бота.""" + ALLOW: 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..0b44cc1 --- /dev/null +++ b/configs/roles.py @@ -0,0 +1,328 @@ +from typing import Dict + +from database import RoleRegion + +# Настройка экспорта +__all__ = ("genshin_roles", "hsr_roles", "ID_TO_ROLE", "all_roles",) + +# Словарь с ID пользователей и их ролями +ID_TO_ROLE: Dict[int, str] = { + 6639261502: "Рацио", + 7435095514: "Панталоне", + 6250345032: "Сандэй", + 5683309573: "Хохо", + 833230790: "Сампо", + 6688236743: "Аглая", + 459453807: "Флинс", + 7831579419: "Анакса", + 7749831743: "Венти", + 1364984004: "Аргенти", + 1369873051: "Альбедо", + 1222399228: "Химеко", + 8199185983: "Лоча", + 7576341592: "Фуга", + 5426987140: "Варка", + 1316852704: "Аха", + 1764269904: "Цзин Юань", + 1992416693: "Бутхилл", + 1314539668: "Кафка", + 1207917053: "Топаз", + 5025299829: "Вельт", + 991994028: "Авантюрин", + 1362425172: "Цифер", + 2006013059: "Жуань Мэй", + 7794291575: "Стивен Ллойд", + 6751720805: "Дотторе", + 5260895056: "Фэйсяо", + 1438721683: "Бай Чжу", +} + +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/data/economy.json b/data/economy.json new file mode 100644 index 0000000..5ae6fb5 --- /dev/null +++ b/data/economy.json @@ -0,0 +1,12 @@ +{ + "6751720805": { + "balance": 0, + "username": "verdise", + "full_name": "Лейн" + }, + "7051557370": { + "balance": 23, + "username": "exetreon", + "full_name": "" + } +} \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..89578ee --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .database import * diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..9f1fd35 --- /dev/null +++ b/database/database.py @@ -0,0 +1,1182 @@ +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" +) + +from middleware import logger + + +# ====================================================== +# База декларативных моделей (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 setup(self, check_connection: bool = True, init_role: bool = True) -> None: + """ + Создаёт все таблицы в базе данных. + + :param check_connection: Разрешение на проверку соединения. + :param init_role: Разрешение на инициализацию ролей. + """ + # Создаем таблицы только если они еще не созданы + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # if check_connection: + # if not await self.check_connection(): # ← ИСПРАВЛЕНО: self вместо db + # logger.error("Не удалось подключиться к БД!") + # return + # + # if init_role: + # await self.init_default_roles() # ← ИСПРАВЛЕНО: self вместо db + + 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.setup() + + 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..c826c72 --- /dev/null +++ b/main.py @@ -0,0 +1,71 @@ +from asyncio import run, sleep +from sys import exit + +from bot import * +from configs.config import Webhook +from database import db +from middleware import logger + + +async def on_startup() -> None: + """Действия при запуске бота.""" + # Создание логера + logger.setup() + + # Создание базы данных + await db.setup() + + # Настройка информации о боте + 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 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__": + try: + run(main()) + except (KeyboardInterrupt, SystemExit): + logger.info("❌ Бот остановлен!") + 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..95468f2 --- /dev/null +++ b/middleware/loggers/logs.py @@ -0,0 +1,233 @@ +from pathlib import Path +from functools import wraps +from sys import stderr as console +from inspect import iscoroutinefunction +from typing import Any, Callable, Optional, TypeVar, cast, Final + +from loguru import logger as logs +from aiogram.types import Message, User + +from configs.config import BotEdit, LogConfig + +# Экспортируемые объекты +__all__ = ('Logger', 'logger', '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 + + # Полная очистка настроек + logs.remove() + + # Создание директории для файловых логов + log_dir: Path = Path(getattr(LogConfig, 'DIR', 'logs')) + log_dir.mkdir(parents=True, exist_ok=True) + + # Консольный лог + if getattr(LogConfig, 'CONSOLE', False): + logs.add( + sink=console, + format=self._log_format, + colorize=True, + level='DEBUG', + filter=lambda rec: rec['extra'].get('log_type') != 'DEBUG' + ) + + # Файловые логи + if getattr(LogConfig, 'FILE', False): + # Общий лог + logs.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']: + logs.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) + logs.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) + + +# Создаем глобальный экземпляр логгера +logger: Logger = Logger() + +# Экспортируемые функции для обратной совместимости +log = logger.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/poetry.lock b/poetry.lock new file mode 100644 index 0000000..489c50e --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1732 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + +[[package]] +name = "aiogram" +version = "3.22.0" +description = "Modern and fully asynchronous framework for Telegram Bot API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiogram-3.22.0-py3-none-any.whl", hash = "sha256:1c6eceb078ff62cf0556a5466cf3e7e8119678c26cc56803b7ac5f73633934a8"}, + {file = "aiogram-3.22.0.tar.gz", hash = "sha256:c483f81e37aeea8e7f592c9bd14f6acc80d9b7a2698e296a45bf47ff60a98510"}, +] + +[package.dependencies] +aiofiles = ">=23.2.1,<24.2" +aiohttp = ">=3.9.0,<3.13" +certifi = ">=2023.7.22" +magic-filter = ">=1.0.12,<1.1" +pydantic = ">=2.4.1,<2.12" +typing-extensions = ">=4.7.0,<=5.0" + +[package.extras] +cli = ["aiogram-cli (>=1.1.0,<2.0.0)"] +dev = ["black (>=24.4.2,<24.5.0)", "isort (>=5.13.2,<5.14.0)", "motor-types (>=1.0.0b4,<1.1.0)", "mypy (>=1.10.0,<1.11.0)", "packaging (>=24.1,<25.0)", "pre-commit (>=3.5,<4.0)", "ruff (>=0.5.1,<0.6.0)", "toml (>=0.10.2,<0.11.0)"] +docs = ["furo (>=2024.8.6,<2024.9.0)", "markdown-include (>=0.8.1,<0.9.0)", "pygments (>=2.18.0,<2.19.0)", "pymdown-extensions (>=10.3,<11.0)", "sphinx (>=8.0.2,<8.1.0)", "sphinx-autobuild (>=2024.9.3,<2024.10.0)", "sphinx-copybutton (>=0.5.2,<0.6.0)", "sphinx-intl (>=2.2.0,<2.3.0)", "sphinx-substitution-extensions (>=2024.8.6,<2024.9.0)", "sphinxcontrib-towncrier (>=0.4.0a0,<0.5.0)", "towncrier (>=24.8.0,<24.9.0)"] +fast = ["aiodns (>=3.0.0)", "uvloop (>=0.17.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version < \"3.13\"", "uvloop (>=0.21.0) ; (sys_platform == \"darwin\" or sys_platform == \"linux\") and platform_python_implementation != \"PyPy\" and python_version >= \"3.13\""] +i18n = ["babel (>=2.13.0,<3)"] +mongo = ["motor (>=3.3.2,<3.7.0)", "pymongo (>4.5,<4.11)"] +proxy = ["aiohttp-socks (>=0.8.3,<0.9.0)"] +redis = ["redis[hiredis] (>=5.0.1,<5.3.0)"] +signature = ["cryptography (>=43.0.0)"] +test = ["aresponses (>=2.1.6,<2.2.0)", "pycryptodomex (>=3.19.0,<3.20.0)", "pytest (>=7.4.2,<7.5.0)", "pytest-aiohttp (>=1.0.5,<1.1.0)", "pytest-asyncio (>=0.21.1,<0.22.0)", "pytest-cov (>=4.1.0,<4.2.0)", "pytest-html (>=4.0.2,<4.1.0)", "pytest-lazy-fixture (>=0.6.3,<0.7.0)", "pytest-mock (>=3.12.0,<3.13.0)", "pytest-mypy (>=0.10.3,<0.11.0)", "pytz (>=2023.3,<2024.0)"] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, + {file = "aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6"}, + {file = "aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065"}, + {file = "aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1"}, + {file = "aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a"}, + {file = "aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe"}, + {file = "aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc4fbc61bb3548d3b482f9ac7ddd0f18c67e4225aaa4e8552b9f1ac7e6bda9e5"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7fbc8a7c410bb3ad5d595bb7118147dfbb6449d862cc1125cf8867cb337e8728"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74dad41b3458dbb0511e760fb355bb0b6689e0630de8a22b1b62a98777136e16"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6f0af863cf17e6222b1735a756d664159e58855da99cfe965134a3ff63b0b0"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b7fe4972d48a4da367043b8e023fb70a04d1490aa7d68800e465d1b97e493b"}, + {file = "aiohttp-3.12.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6443cca89553b7a5485331bc9bedb2342b08d073fa10b8c7d1c60579c4a7b9bd"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c5f40ec615e5264f44b4282ee27628cea221fcad52f27405b80abb346d9f3f8"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2abbb216a1d3a2fe86dbd2edce20cdc5e9ad0be6378455b05ec7f77361b3ab50"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:db71ce547012a5420a39c1b744d485cfb823564d01d5d20805977f5ea1345676"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ced339d7c9b5030abad5854aa5413a77565e5b6e6248ff927d3e174baf3badf7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7c7dd29c7b5bda137464dc9bfc738d7ceea46ff70309859ffde8c022e9b08ba7"}, + {file = "aiohttp-3.12.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:421da6fd326460517873274875c6c5a18ff225b40da2616083c5a34a7570b685"}, + {file = "aiohttp-3.12.15-cp311-cp311-win32.whl", hash = "sha256:4420cf9d179ec8dfe4be10e7d0fe47d6d606485512ea2265b0d8c5113372771b"}, + {file = "aiohttp-3.12.15-cp311-cp311-win_amd64.whl", hash = "sha256:edd533a07da85baa4b423ee8839e3e91681c7bfa19b04260a469ee94b778bf6d"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444"}, + {file = "aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545"}, + {file = "aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea"}, + {file = "aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3"}, + {file = "aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1"}, + {file = "aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd"}, + {file = "aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d"}, + {file = "aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64"}, + {file = "aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51"}, + {file = "aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0"}, + {file = "aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:691d203c2bdf4f4637792efbbcdcd157ae11e55eaeb5e9c360c1206fb03d4d98"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e995e1abc4ed2a454c731385bf4082be06f875822adc4c6d9eaadf96e20d406"}, + {file = "aiohttp-3.12.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bd44d5936ab3193c617bfd6c9a7d8d1085a8dc8c3f44d5f1dcf554d17d04cf7d"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46749be6e89cd78d6068cdf7da51dbcfa4321147ab8e4116ee6678d9a056a0cf"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0c643f4d75adea39e92c0f01b3fb83d57abdec8c9279b3078b68a3a52b3933b6"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0a23918fedc05806966a2438489dcffccbdf83e921a1170773b6178d04ade142"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74bdd8c864b36c3673741023343565d95bfbd778ffe1eb4d412c135a28a8dc89"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a146708808c9b7a988a4af3821379e379e0f0e5e466ca31a73dbdd0325b0263"}, + {file = "aiohttp-3.12.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7011a70b56facde58d6d26da4fec3280cc8e2a78c714c96b7a01a87930a9530"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3bdd6e17e16e1dbd3db74d7f989e8af29c4d2e025f9828e6ef45fbdee158ec75"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57d16590a351dfc914670bd72530fd78344b885a00b250e992faea565b7fdc05"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:bc9a0f6569ff990e0bbd75506c8d8fe7214c8f6579cca32f0546e54372a3bb54"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:536ad7234747a37e50e7b6794ea868833d5220b49c92806ae2d7e8a9d6b5de02"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f0adb4177fa748072546fb650d9bd7398caaf0e15b370ed3317280b13f4083b0"}, + {file = "aiohttp-3.12.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14954a2988feae3987f1eb49c706bff39947605f4b6fa4027c1d75743723eb09"}, + {file = "aiohttp-3.12.15-cp39-cp39-win32.whl", hash = "sha256:b784d6ed757f27574dca1c336f968f4e81130b27595e458e69457e6878251f5d"}, + {file = "aiohttp-3.12.15-cp39-cp39-win_amd64.whl", hash = "sha256:86ceded4e78a992f835209e236617bffae649371c4a50d5e5a3987f237db84b8"}, + {file = "aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "aiosqlite" +version = "0.21.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0"}, + {file = "aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.1)", "black (==24.3.0)", "build (>=1.2)", "coverage[toml] (==7.6.10)", "flake8 (==7.0.0)", "flake8-bugbear (==24.12.12)", "flit (==3.10.1)", "mypy (==1.14.1)", "ufmt (==2.5.1)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "apscheduler" +version = "3.11.1" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2"}, + {file = "apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] + +[[package]] +name = "babel" +version = "2.17.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"}, + {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, +] + +[[package]] +name = "click" +version = "8.3.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.116.2" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db"}, + {file = "fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.49.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31"}, + {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, + {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079"}, + {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, + {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6"}, + {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, + {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504"}, + {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, + {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, + {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, + {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:18d9260df2b5fbf41ae5139e1be4e796d99655f023a636cd0e11e6406cca7d58"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:671df96c1f23c4a0d4077a325483c1503c96a1b7d9db26592ae770daa41233d4"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:16458c245a38991aa19676900d48bd1a6f2ce3e16595051a4db9d012154e8433"}, + {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, + {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, + {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "magic-filter" +version = "1.0.12" +description = "" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6"}, + {file = "magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9"}, +] + +[package.extras] +dev = ["black (>=22.8.0,<22.9.0)", "flake8 (>=5.0.4,<5.1.0)", "isort (>=5.11.5,<5.12.0)", "mypy (>=1.4.1,<1.5.0)", "pre-commit (>=2.20.0,<2.21.0)", "pytest (>=7.1.3,<7.2.0)", "pytest-cov (>=3.0.0,<3.1.0)", "pytest-html (>=3.1.1,<3.2.0)", "types-setuptools (>=65.3.0,<65.4.0)"] + +[[package]] +name = "multidict" +version = "6.7.0" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e"}, + {file = "multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62"}, + {file = "multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111"}, + {file = "multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36"}, + {file = "multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85"}, + {file = "multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7"}, + {file = "multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721"}, + {file = "multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8"}, + {file = "multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b"}, + {file = "multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34"}, + {file = "multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff"}, + {file = "multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81"}, + {file = "multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45"}, + {file = "multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1"}, + {file = "multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a"}, + {file = "multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8"}, + {file = "multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4"}, + {file = "multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b"}, + {file = "multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159"}, + {file = "multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf"}, + {file = "multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd"}, + {file = "multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288"}, + {file = "multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17"}, + {file = "multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390"}, + {file = "multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb"}, + {file = "multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad"}, + {file = "multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762"}, + {file = "multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6"}, + {file = "multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d"}, + {file = "multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6"}, + {file = "multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b"}, + {file = "multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1"}, + {file = "multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f"}, + {file = "multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f"}, + {file = "multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885"}, + {file = "multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c"}, + {file = "multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718"}, + {file = "multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a"}, + {file = "multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9"}, + {file = "multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0"}, + {file = "multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13"}, + {file = "multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd"}, + {file = "multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40"}, + {file = "multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e"}, + {file = "multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e"}, + {file = "multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4"}, + {file = "multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91"}, + {file = "multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f"}, + {file = "multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546"}, + {file = "multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3"}, + {file = "multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] + +[[package]] +name = "pydantic" +version = "2.11.10" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a"}, + {file = "pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyrogram" +version = "2.0.106" +description = "Elegant, modern and asynchronous Telegram MTProto API framework in Python for users and bots" +optional = false +python-versions = "~=3.7" +groups = ["main"] +files = [ + {file = "Pyrogram-2.0.106-py3-none-any.whl", hash = "sha256:32b62b3d93030b1080cbe45b88c0e8790cf4e2179cf3321ede810d48edf97bc7"}, + {file = "Pyrogram-2.0.106.tar.gz", hash = "sha256:30202995758fcb6e0f91224704ab7fedefea454297724bd70a6a71a23748c16f"}, +] + +[package.dependencies] +pyaes = "1.6.1" +pysocks = "1.7.1" + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, + {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<9" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.44" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.44-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:471733aabb2e4848d609141a9e9d56a427c0a038f4abf65dd19d7a21fd563632"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48bf7d383a35e668b984c805470518b635d48b95a3c57cb03f37eaa3551b5f9f"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf4bb6b3d6228fcf3a71b50231199fb94d2dd2611b66d33be0578ea3e6c2726"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:e998cf7c29473bd077704cea3577d23123094311f59bdc4af551923b168332b1"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:ebac3f0b5732014a126b43c2b7567f2f0e0afea7d9119a3378bde46d3dcad88e"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-win32.whl", hash = "sha256:3255d821ee91bdf824795e936642bbf43a4c7cedf5d1aed8d24524e66843aa74"}, + {file = "SQLAlchemy-2.0.44-cp37-cp37m-win_amd64.whl", hash = "sha256:78e6c137ba35476adb5432103ae1534f2f5295605201d946a4198a0dea4b38e7"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c77f3080674fc529b1bd99489378c7f63fcb4ba7f8322b79732e0258f0ea3ce"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26ef74ba842d61635b0152763d057c8d48215d5be9bb8b7604116a059e9985"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4a172b31785e2f00780eccab00bc240ccdbfdb8345f1e6063175b3ff12ad1b0"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9480c0740aabd8cb29c329b422fb65358049840b34aba0adf63162371d2a96e"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:17835885016b9e4d0135720160db3095dc78c583e7b902b6be799fb21035e749"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cbe4f85f50c656d753890f39468fcd8190c5f08282caf19219f684225bfd5fd2"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-win32.whl", hash = "sha256:2fcc4901a86ed81dc76703f3b93ff881e08761c63263c46991081fd7f034b165"}, + {file = "sqlalchemy-2.0.44-cp310-cp310-win_amd64.whl", hash = "sha256:9919e77403a483ab81e3423151e8ffc9dd992c20d2603bf17e4a8161111e55f5"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3"}, + {file = "sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4"}, + {file = "sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73"}, + {file = "sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2fc44e5965ea46909a416fff0af48a219faefd5773ab79e5f8a5fcd5d62b2667"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dc8b3850d2a601ca2320d081874033684e246d28e1c5e89db0864077cfc8f5a9"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d733dec0614bb8f4bcb7c8af88172b974f685a31dc3a65cca0527e3120de5606"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22be14009339b8bc16d6b9dc8780bacaba3402aa7581658e246114abbd2236e3"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:357bade0e46064f88f2c3a99808233e67b0051cdddf82992379559322dfeb183"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4848395d932e93c1595e59a8672aa7400e8922c39bb9b0668ed99ac6fa867822"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-win32.whl", hash = "sha256:2f19644f27c76f07e10603580a47278abb2a70311136a7f8fd27dc2e096b9013"}, + {file = "sqlalchemy-2.0.44-cp38-cp38-win_amd64.whl", hash = "sha256:1df4763760d1de0dfc8192cc96d8aa293eb1a44f8f7a5fbe74caf1b551905c5e"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7027414f2b88992877573ab780c19ecb54d3a536bef3397933573d6b5068be4"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fe166c7d00912e8c10d3a9a0ce105569a31a3d0db1a6e82c4e0f4bf16d5eca9"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3caef1ff89b1caefc28f0368b3bde21a7e3e630c2eddac16abd9e47bd27cc36a"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc2856d24afa44295735e72f3c75d6ee7fdd4336d8d3a8f3d44de7aa6b766df2"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:11bac86b0deada30b6b5f93382712ff0e911fe8d31cb9bf46e6b149ae175eff0"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4d18cd0e9a0f37c9f4088e50e3839fcb69a380a0ec957408e0b57cff08ee0a26"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-win32.whl", hash = "sha256:9e9018544ab07614d591a26c1bd4293ddf40752cc435caf69196740516af7100"}, + {file = "sqlalchemy-2.0.44-cp39-cp39-win_amd64.whl", hash = "sha256:8e0e4e66fd80f277a8c3de016a81a554e76ccf6b8d881ee0b53200305a8433f6"}, + {file = "sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05"}, + {file = "sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.48.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, + {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] +markers = {dev = "python_version < \"3.13\""} + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "uvicorn" +version = "0.35.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[[package]] +name = "yarl" +version = "1.22.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, +] + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "8c50ce60466e3dd5f712c682c765a400cb77a196ff506c6598d1cc45a76af51d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fde7d43 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "primoexamplebot" +version = "0.1.0" +description = "none" +authors = [ + {name = "admin",email = "inkscaper0349@outlook.com"} +] +license = {text = "MIT License"} +readme = "README.md" +requires-python = ">=3.10,<4.0" +dependencies = [ + "aiogram (>=3.22.0,<4.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "uvicorn (>=0.35.0,<0.36.0)", + "fastapi (>=0.116.1,<0.117.0)", + "pydantic-settings (>=2.10.1,<3.0.0)", + "sqlalchemy (>=2.0.43,<3.0.0)", + "babel (>=2.17.0,<3.0.0)", + "aiosqlite (>=0.21.0,<0.22.0)", + "email-validator (>=2.3.0,<3.0.0)", + "apscheduler (>=3.11.0,<4.0.0)", + "pyrogram (>=2.0.106,<3.0.0)", +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry] +package-mode = false + +[tool.poetry.group.dev.dependencies] +pytest = "^8.4.1" +pytest-asyncio = "^1.1.0" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" diff --git a/userbot/__init__.py b/userbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/userbot/client.py b/userbot/client.py new file mode 100644 index 0000000..bd477bc --- /dev/null +++ b/userbot/client.py @@ -0,0 +1,39 @@ +from pyrogram import Client, types +from configs import UserIn + +userbot = Client( + name="premium_user", + api_id=UserIn.TG_API_UID, + api_hash=UserIn.TG_API_HASH, + session_string=UserIn.SESSION_STRING +) + +async def userbot_send_message(chat_id: int | str, text: str, parse_mode: str | None = None) -> types.Message: + """ + Отправка сообщения от имени Premium-аккаунта. + Поддерживает premium emoji, HTML и Markdown форматирование. + """ + if not userbot.is_connected: + await userbot.start() + + log.info(f"📤 Отправка сообщения через Premium user в {chat_id}") + return await userbot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode + ) + +async def userbot_edit_message(chat_id: int | str, message_id: int, text: str, parse_mode: str | None = None) -> types.Message: + """ + Редактирование сообщения от имени Premium-аккаунта. + """ + if not userbot.is_connected: + await userbot.start() + + log.info(f"✏️ Редактирование сообщения {message_id} в чате {chat_id}") + return await userbot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=text, + parse_mode=parse_mode + )