diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 3b3bd2c..7216542 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -91,4 +91,4 @@ jobs: run: uv sync --all-groups - name: Run Lizard complexity check - run: uv run lizard -l python --CCN 15 -w botspot + run: uv run lizard -l python --CCN 20 -w botspot diff --git a/.github/workflows/push-checks.yml b/.github/workflows/push-checks.yml index 28efd22..ee6e0d3 100644 --- a/.github/workflows/push-checks.yml +++ b/.github/workflows/push-checks.yml @@ -21,7 +21,7 @@ jobs: run: uv sync --all-groups - name: Run Pyright type checker - run: uv run pyright botspot + run: uv run pyright src continue-on-error: true tests: diff --git a/botspot/components/data/access_control.py b/botspot/components/data/access_control.py index 9b38096..669a68f 100644 --- a/botspot/components/data/access_control.py +++ b/botspot/components/data/access_control.py @@ -9,12 +9,13 @@ from aiogram.filters import Command from aiogram.types import Message +from botspot.components.middlewares.i18n import t from botspot.utils.admin_filter import AdminFilter from botspot.utils.internal import get_logger from pydantic_settings import BaseSettings if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection + from pymongo.asynchronous.collection import AsyncCollection # noqa: vulture logger = get_logger() @@ -39,7 +40,7 @@ class AccessControl: """Manages persistent friends and admins lists with MongoDB support.""" def __init__( - self, settings: AccessControlSettings, collection: Optional["AsyncIOMotorCollection"] = None + self, settings: AccessControlSettings, collection: Optional["AsyncCollection"] = None ): self.settings = settings self._mongo_available = None @@ -276,18 +277,18 @@ async def add_friend_command_handler(message: Message): ) if username is None: - await message.reply("❌ Failed to get username. Operation cancelled.") + await message.reply(t("access_control.add_friend_failed")) return try: success = await add_friend(username) if success: - await message.reply(f"✅ Successfully added {username} to friends list!") + await message.reply(t("access_control.add_friend_success", username=username)) else: - await message.reply(f"ℹ️ {username} is already in the friends list.") + await message.reply(t("access_control.add_friend_already_exists", username=username)) except Exception as e: logger.error(f"Error adding friend {username}: {e}") - await message.reply(f"❌ Error adding friend: {str(e)}") + await message.reply(t("access_control.add_friend_error", error=str(e))) async def remove_friend_command_handler(message: Message): @@ -307,18 +308,18 @@ async def remove_friend_command_handler(message: Message): ) if username is None: - await message.reply("❌ Failed to get username. Operation cancelled.") + await message.reply(t("access_control.remove_friend_failed")) return try: success = await remove_friend(username) if success: - await message.reply(f"✅ Successfully removed {username} from friends list!") + await message.reply(t("access_control.remove_friend_success", username=username)) else: - await message.reply(f"ℹ️ {username} was not in the friends list.") + await message.reply(t("access_control.remove_friend_not_found", username=username)) except Exception as e: logger.error(f"Error removing friend {username}: {e}") - await message.reply(f"❌ Error removing friend: {str(e)}") + await message.reply(t("access_control.remove_friend_error", error=str(e))) async def list_friends_command_handler(message: Message): @@ -329,20 +330,20 @@ async def list_friends_command_handler(message: Message): friends = await get_friends() if not friends: - await message.reply("ℹ️ No friends in the list.") + await message.reply(t("access_control.list_friends_empty")) return - response = "👥 Friends List:\n\n" + response = t("access_control.list_friends_header") for i, friend in enumerate(friends, 1): response += f"{i}. {friend}\n" - response += f"\nTotal: {len(friends)} friends" + response += t("access_control.list_friends_total", count=len(friends)) await message.reply(response) except Exception as e: logger.error(f"Error listing friends: {e}") - await message.reply(f"❌ Error listing friends: {str(e)}") + await message.reply(t("access_control.list_friends_error", error=str(e))) def setup_dispatcher(dp): diff --git a/botspot/components/data/contact_data.py b/botspot/components/data/contact_data.py index 88795c0..0315358 100644 --- a/botspot/components/data/contact_data.py +++ b/botspot/components/data/contact_data.py @@ -15,7 +15,8 @@ # from botspot.utils.internal import get_logger # # if TYPE_CHECKING: -# from motor.motor_asyncio import AsyncIOMotorCollection, AsyncIOMotorDatabase # noqa: F401 +# from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 +# from pymongo.asynchronous.database import AsyncDatabase # noqa: F401 # # from botspot.core.botspot_settings import BotspotSettings # diff --git a/botspot/components/data/mongo_database.py b/botspot/components/data/mongo_database.py index f03a142..9becf79 100644 --- a/botspot/components/data/mongo_database.py +++ b/botspot/components/data/mongo_database.py @@ -6,8 +6,8 @@ from botspot.utils.internal import get_logger if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorClient # noqa: F401 - from motor.motor_asyncio import AsyncIOMotorDatabase # noqa: F401 + from pymongo import AsyncMongoClient # noqa: F401 + from pymongo.asynchronous.database import AsyncDatabase # noqa: F401 logger = get_logger() @@ -28,7 +28,7 @@ def setup_dispatcher(dp): return dp -def get_database() -> "AsyncIOMotorDatabase": +def get_database() -> "AsyncDatabase": """Get MongoDB database instance from dependency manager.""" from botspot.core.dependency_manager import get_dependency_manager @@ -38,7 +38,7 @@ def get_database() -> "AsyncIOMotorDatabase": return db -def get_mongo_client() -> "AsyncIOMotorClient": +def get_mongo_client() -> "AsyncMongoClient": """Get MongoDB client instance from dependency manager.""" from botspot.core.dependency_manager import get_dependency_manager @@ -52,34 +52,34 @@ def get_mongo_client() -> "AsyncIOMotorClient": def initialize( settings: MongoDatabaseSettings, -) -> Tuple[Optional["AsyncIOMotorClient"], Optional["AsyncIOMotorDatabase"]]: +) -> Tuple[Optional["AsyncMongoClient"], Optional["AsyncDatabase"]]: """Initialize MongoDB connection and return both client and database. Args: settings: MongoDB settings Returns: - Tuple of (AsyncIOMotorClient, AsyncIOMotorDatabase) or (None, None) if disabled + Tuple of (AsyncMongoClient, AsyncDatabase) or (None, None) if disabled Raises: - ImportError: If motor is not installed + ImportError: If pymongo is not installed """ # Check if MongoDB is enabled if not settings.enabled: logger.info("MongoDB is disabled") return None, None - # Check if motor is installed + # Check if pymongo is installed try: - from motor.motor_asyncio import AsyncIOMotorClient + from pymongo import AsyncMongoClient except ImportError: - logger.error("motor package is not installed. Please install it to use MongoDB.") + logger.error("pymongo package is not installed. Please install it to use MongoDB.") raise ImportError( - "motor package is not installed. Run 'poetry add motor' or 'pip install motor'" + "pymongo package is not installed. Run 'uv add pymongo' or 'pip install pymongo'" ) # Initialize client and database - client = AsyncIOMotorClient(settings.conn_str.get_secret_value()) + client = AsyncMongoClient(settings.conn_str.get_secret_value()) db = client[settings.database] logger.info(f"MongoDB client initialized. Database: {settings.database}") return client, db diff --git a/botspot/components/data/user_data.py b/botspot/components/data/user_data.py index 0b5a327..7f17e96 100644 --- a/botspot/components/data/user_data.py +++ b/botspot/components/data/user_data.py @@ -10,8 +10,8 @@ from botspot.utils.internal import get_logger if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 - from motor.motor_asyncio import AsyncIOMotorDatabase # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 + from pymongo.asynchronous.database import AsyncDatabase # noqa: F401 from botspot.core.botspot_settings import BotspotSettings @@ -57,7 +57,7 @@ class UserManager(Generic[UserT]): def __init__( self, - db: "AsyncIOMotorDatabase", + db: "AsyncDatabase", collection: str, user_class: Type[UserT], settings: Optional["BotspotSettings"] = None, @@ -108,7 +108,7 @@ async def has_user(self, user_id: int) -> bool: return bool(await self.get_user(user_id)) @property - def users_collection(self) -> "AsyncIOMotorCollection": + def users_collection(self) -> "AsyncCollection": return self.db[self.collection] async def get_users(self, query: Optional[dict] = None) -> list[UserT]: @@ -337,20 +337,20 @@ def initialize(settings: "BotspotSettings", user_class=None) -> UserManager: Raises: RuntimeError: If MongoDB is not enabled or initialized - ImportError: If motor package is not installed + ImportError: If pymongo package is not installed """ - # Check that motor is installed + # Check that pymongo is installed try: - import motor # noqa: F401 + import pymongo # noqa: F401 except ImportError: from botspot.utils.internal import get_logger logger = get_logger() logger.error( - "motor package is not installed. Please install it to use the user_data component." + "pymongo package is not installed. Please install it to use the user_data component." ) raise ImportError( - "motor package is not installed. Please install it with 'poetry add motor' or 'pip install motor' to use the user_data component." + "pymongo package is not installed. Please install it with 'uv add pymongo' or 'pip install pymongo' to use the user_data component." ) # Check that MongoDB component is enabled diff --git a/botspot/components/features/multi_forward_handler.py b/botspot/components/features/multi_forward_handler.py index 890d763..f84b1ca 100644 --- a/botspot/components/features/multi_forward_handler.py +++ b/botspot/components/features/multi_forward_handler.py @@ -6,6 +6,7 @@ from pydantic_settings import BaseSettings # from dev.draft.easter_eggs.main import get_easter_egg +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger logger = get_logger() @@ -68,7 +69,7 @@ async def chat_handler(self, message: Message, app: MyApp): await queue.put(message) return # test: send user the message count - await message.answer(f"Received {len(messages)} messages") + await message.answer(t("multi_forward.message_count", count=len(messages))) text = await self.compose_messages(messages) if self.send_as_file: @@ -144,20 +145,20 @@ async def set_send_as_file(self, message: Message, app: MyApp): text = self.strip_command(text) if text.lower() in ["0", "false", "no", "off", "disable"]: self.send_as_file = False - await message.answer("Send as file disabled") + await message.answer(t("multi_forward.send_as_file_disabled")) else: self.send_as_file = True - await message.answer("Send as file enabled") + await message.answer(t("multi_forward.send_as_file_enabled")) async def enable_send_as_file(self, message: Message, app: MyApp): self.send_as_file = True - await message.answer("Send as file enabled") + await message.answer(t("multi_forward.send_as_file_enabled")) async def disable_send_as_file(self, message: Message, app: MyApp): self.send_as_file = False - await message.answer("Send as file disabled") + await message.answer(t("multi_forward.send_as_file_disabled")) async def multi_forward_handler(): diff --git a/botspot/components/features/user_interactions.py b/botspot/components/features/user_interactions.py index f4864b5..3382def 100644 --- a/botspot/components/features/user_interactions.py +++ b/botspot/components/features/user_interactions.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from pydantic_settings import BaseSettings +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger logger = get_logger() @@ -146,9 +147,11 @@ async def _ask_user_base( except asyncio.TimeoutError: if notify_on_timeout: if default_choice is not None: - question += f"\n\n⏰ Auto-selected: {default_choice}" + question += "\n\n" + t( + "user_interactions.timeout_auto_selected", choice=default_choice + ) else: - question += "\n\n⏰ No response received within the time limit." + question += "\n\n" + t("user_interactions.timeout") await sent_message.edit_text(question) return default_choice # None if no default choice finally: @@ -274,7 +277,7 @@ async def handle_user_input(message: types.Message, state: FSMContext) -> None: bot: Bot = deps.bot await bot.send_message( chat_id, - "Sorry, this response came too late or was for a different question. Please try again.", + t("user_interactions.late_response"), ) await state.clear() return @@ -396,9 +399,7 @@ async def ask_user_choice_raw( # If adding hint is requested displayed_question = question if add_hint: - displayed_question = ( - f"{question}\n\nTip: You can choose an option or type your own response." - ) + displayed_question = f"{question}\n\n{t('user_interactions.hint')}" return await _ask_user_base( chat_id=chat_id, @@ -426,13 +427,13 @@ async def handle_choice_callback(callback_query: types.CallbackQuery, state: FSM active_request = input_manager.get_active_request(chat_id) if not active_request or active_request.handler_id != handler_id: - await callback_query.answer("This choice is no longer valid.") + await callback_query.answer(t("user_interactions.choice_invalid")) return # Protection against multiple callbacks for the same request if active_request.response is not None: # Request already has a response, this is likely a retry - await callback_query.answer("Your choice has already been recorded.") + await callback_query.answer(t("user_interactions.choice_recorded")) return choice = callback_query.data[7:] @@ -444,7 +445,7 @@ async def handle_choice_callback(callback_query: types.CallbackQuery, state: FSM assert not isinstance(callback_query.message, InaccessibleMessage) # Edit the message to remove buttons and show selection - new_text = f"{callback_query.message.text}\n\nSelected: {choice}" + new_text = f"{callback_query.message.text}\n\n{t('user_interactions.selected', choice=choice)}" try: await callback_query.message.edit_text(new_text) except TelegramBadRequest as e: diff --git a/botspot/components/main/telethon_manager.py b/botspot/components/main/telethon_manager.py index 0e93bba..94fe8a2 100644 --- a/botspot/components/main/telethon_manager.py +++ b/botspot/components/main/telethon_manager.py @@ -10,6 +10,7 @@ from pydantic_settings import BaseSettings from botspot.components.features.user_interactions import ask_user +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger from botspot.utils.send_safe import send_safe @@ -113,9 +114,7 @@ async def get_client(self, user_id: int, state=None) -> "TelegramClient": # No client could be initialized or created from botspot.core.errors import TelethonClientNotConnectedError - raise TelethonClientNotConnectedError( - f"Client for user {user_id} not found. Please run the /setup_telethon command to authenticate." - ) + raise TelethonClientNotConnectedError(t("telethon.not_connected", user_id=user_id)) async def disconnect_all(self): """Disconnect all clients""" @@ -150,8 +149,7 @@ async def setup_client( existing_client = await self.get_client(user_id) await send_safe( user_id, - "You already have an active Telethon session! " - "Use /setup_telethon_force to create a new one.", + t("telethon.already_active"), ) return existing_client except Exception: @@ -172,13 +170,13 @@ async def setup_client( # Ask for phone number phone = await ask_user( user_id, - "Please enter your phone number (including country code, e.g., +1234567890):", + t("telethon.phone_prompt"), state, timeout=60.0, ) if not phone: - await send_safe(user_id, "Setup cancelled - no phone number provided.") + await send_safe(user_id, t("telethon.cancelled_no_phone")) return None # Send code request @@ -187,20 +185,20 @@ async def setup_client( # Ask for verification code code = await ask_user( user_id, - "Please enter MODIFIED verification code as follows: YOUR CODE splitted with spaces e.g '21694' -> '2 1 6 9 4' or telegram WILL BLOCK IT", + t("telethon.code_prompt"), state, timeout=300.0, cleanup=True, ) if not code: - await send_safe(user_id, "Setup cancelled - no verification code provided.") + await send_safe(user_id, t("telethon.cancelled_no_code")) return None if " " not in code.strip(): await send_safe( user_id, - "Setup cancelled - YOU DID NOT split the code with spaces like this: '2 1 6 9 4'", + t("telethon.code_no_spaces"), ) return None @@ -214,14 +212,14 @@ async def setup_client( # 2FA is enabled, ask for password password = await ask_user( user_id, - "Two-factor authentication is enabled. Please enter your 2FA password:", + t("telethon.password_prompt"), state, timeout=300.0, cleanup=True, ) if not password: - await send_safe(user_id, "Setup cancelled - no password provided.") + await send_safe(user_id, t("telethon.cancelled_no_password")) return None password = password.replace(" ", "") @@ -235,12 +233,12 @@ async def setup_client( self.clients[user_id] = client await send_safe( user_id, - "Successfully set up Telethon client! The session is saved and ready to use.", + t("telethon.setup_success"), ) return client except Exception as e: - await send_safe(user_id, f"Error during setup: {str(e)}") + await send_safe(user_id, t("telethon.setup_error", error=str(e))) if session_file.exists(): session_file.unlink() return None @@ -321,8 +319,6 @@ async def check_telethon_handler(message: Message) -> None: if client and await client.is_user_authorized(): me = await client.get_me() assert me.first_name - await message.reply(f"Active Telethon session found for {me.first_name}!") + await message.reply(t("telethon.active_session_found", name=me.first_name)) else: - await message.reply( - "No active Telethon session found. Use /setup_telethon to create one." - ) + await message.reply(t("telethon.no_session")) diff --git a/botspot/components/main/trial_mode.py b/botspot/components/main/trial_mode.py index 15ff34e..dd33fa2 100644 --- a/botspot/components/main/trial_mode.py +++ b/botspot/components/main/trial_mode.py @@ -8,6 +8,7 @@ from aiogram.types import Message from pydantic_settings import BaseSettings +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger logger = get_logger() @@ -63,7 +64,11 @@ async def wrapped(message: Message, **kwargs): remaining_seconds = int(user_func_usage[0] + period - current_time) remaining_time = format_remaining_time(remaining_seconds) await message.answer( - f"You have reached your usage limit for the {func_name} command. Reset in: {remaining_time}." + t( + "trial_mode.limit_reached", + func_name=func_name, + remaining_time=remaining_time, + ) ) return @@ -96,7 +101,11 @@ async def wrapped(message: Message, **kwargs): remaining_seconds = int(global_usage[0] + period - current_time) remaining_time = format_remaining_time(remaining_seconds) await message.answer( - f"The {func.__name__} command has reached its global usage limit. Please try again later. Reset in: {remaining_time}." + t( + "trial_mode.global_limit", + func_name=func.__name__, + remaining_time=remaining_time, + ) ) return @@ -137,9 +146,7 @@ async def __call__(self, handler, event, data): remaining_seconds = int(self.global_usage[0] + self.global_period - current_time) remaining_time = format_remaining_time(remaining_seconds) - await message.answer( - f"The bot has reached its global usage limit. Please try again later. Reset in: {remaining_time}." - ) + await message.answer(t("trial_mode.bot_limit", remaining_time=remaining_time)) return if user_id and self.limit_per_user: @@ -155,9 +162,7 @@ async def __call__(self, handler, event, data): self.user_usage[user_id][0] + self.period_per_user - current_time ) remaining_time = format_remaining_time(remaining_seconds) - await message.answer( - f"You have reached your personal usage limit. Please try again later. Reset in: {remaining_time}." - ) + await message.answer(t("trial_mode.personal_limit", remaining_time=remaining_time)) return self.user_usage[user_id].append(current_time) diff --git a/botspot/components/middlewares/error_handler.py b/botspot/components/middlewares/error_handler.py index 15d50f6..a0a78d7 100644 --- a/botspot/components/middlewares/error_handler.py +++ b/botspot/components/middlewares/error_handler.py @@ -4,6 +4,7 @@ from aiogram import Bot, Dispatcher, types from pydantic_settings import BaseSettings +from botspot.components.middlewares.i18n import t from botspot.utils.easter_eggs import get_easter_egg from botspot.utils.internal import get_logger @@ -60,14 +61,14 @@ async def error_handler(event: types.ErrorEvent, bot: Bot): if error.user_message: response = error.user_message else: - response = "Oops, something went wrong :(" + response = t("error_handler.something_went_wrong") if error.easter_eggs and settings.easter_eggs: - response += f"\nHere, take this instead: \n{get_easter_egg()}" + response += "\n" + t("error_handler.easter_egg", easter_egg=get_easter_egg()) else: - response = "Oops, something went wrong :(" + response = t("error_handler.something_went_wrong") if settings.easter_eggs: - response += f"\nHere, take this instead: \n{get_easter_egg()}" + response += "\n" + t("error_handler.easter_egg", easter_egg=get_easter_egg()) if event.update.message: await answer_safe(event.update.message, response) diff --git a/botspot/components/middlewares/i18n.py b/botspot/components/middlewares/i18n.py new file mode 100644 index 0000000..172619b --- /dev/null +++ b/botspot/components/middlewares/i18n.py @@ -0,0 +1,100 @@ +"""Simple dict-based i18n for 2+ languages (en/ru).""" + +from contextvars import ContextVar +from typing import Any, Awaitable, Callable, Dict, Optional + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from pydantic_settings import BaseSettings + +_current_lang: ContextVar[str] = ContextVar("_current_lang", default="en") +_STRINGS: dict[str, dict[str, str]] = {} +_botspot_strings_loaded: bool = False + + +class I18nSettings(BaseSettings): + enabled: bool = True + default_locale: str = "en" + + class Config: + env_prefix = "BOTSPOT_I18N_" + env_file = ".env" + env_file_encoding = "utf-8" + extra = "ignore" + + +def set_lang(lang: str) -> None: + _current_lang.set(lang) + + +def get_lang() -> str: + return _current_lang.get() + + +def _ensure_botspot_strings() -> None: + global _botspot_strings_loaded + if not _botspot_strings_loaded: + _botspot_strings_loaded = True + from botspot.components.middlewares.i18n_strings import BOTSPOT_STRINGS + + _STRINGS.update(BOTSPOT_STRINGS) + + +def t(_key: str, /, **kwargs: Any) -> str: + """Get translated string by key, formatted with kwargs.""" + _ensure_botspot_strings() + lang = _current_lang.get() + entry = _STRINGS.get(_key) + if entry is None: + return _key + text = entry.get(lang) or entry.get("en", _key) + if kwargs: + text = text.format(**kwargs) + return text + + +def register_strings(strings: dict[str, dict[str, str]]) -> None: + """Merge app-specific strings into the global registry.""" + _STRINGS.update(strings) + + +def _resolve_locale(event: TelegramObject, default: str = "en") -> str: + """Extract locale from Telegram event's from_user.language_code.""" + from_user = getattr(event, "from_user", None) + if from_user is None: + # Try nested: callback_query.message doesn't have from_user directly + message = getattr(event, "message", None) + if message is not None: + from_user = getattr(message, "from_user", None) + if from_user is not None: + lang_code = getattr(from_user, "language_code", None) + if lang_code and lang_code.startswith("ru"): + return "ru" + return default + + +class I18nMiddleware(BaseMiddleware): + """Middleware that sets the language context for each update.""" + + def __init__( + self, + default_locale: str = "en", + locale_resolver: Optional[Callable[[TelegramObject], Optional[str]]] = None, + ): + self.default_locale = default_locale + self.locale_resolver = locale_resolver + + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any], + ) -> Any: + # Custom resolver takes priority (e.g. user settings in whisper-bot) + locale = None + if self.locale_resolver is not None: + locale = self.locale_resolver(event) + if locale is None: + locale = _resolve_locale(event, self.default_locale) + set_lang(locale) + return await handler(event, data) diff --git a/botspot/components/middlewares/i18n_strings.py b/botspot/components/middlewares/i18n_strings.py new file mode 100644 index 0000000..b732206 --- /dev/null +++ b/botspot/components/middlewares/i18n_strings.py @@ -0,0 +1,320 @@ +"""All botspot framework i18n strings (en/ru).""" + +BOTSPOT_STRINGS: dict[str, dict[str, str]] = { + # -- access_control -- + "access_control.add_friend_failed": { + "en": "❌ Failed to get username. Operation cancelled.", + "ru": "❌ Не удалось получить имя пользователя. Операция отменена.", + }, + "access_control.add_friend_success": { + "en": "✅ Successfully added {username} to friends list!", + "ru": "✅ {username} успешно добавлен в список друзей!", + }, + "access_control.add_friend_already_exists": { + "en": "ℹ️ {username} is already in the friends list.", + "ru": "ℹ️ {username} уже в списке друзей.", + }, + "access_control.add_friend_error": { + "en": "❌ Error adding friend: {error}", + "ru": "❌ Ошибка добавления друга: {error}", + }, + "access_control.remove_friend_failed": { + "en": "❌ Failed to get username. Operation cancelled.", + "ru": "❌ Не удалось получить имя пользователя. Операция отменена.", + }, + "access_control.remove_friend_success": { + "en": "✅ Successfully removed {username} from friends list!", + "ru": "✅ {username} успешно удалён из списка друзей!", + }, + "access_control.remove_friend_not_found": { + "en": "ℹ️ {username} was not in the friends list.", + "ru": "ℹ️ {username} не был в списке друзей.", + }, + "access_control.remove_friend_error": { + "en": "❌ Error removing friend: {error}", + "ru": "❌ Ошибка удаления друга: {error}", + }, + "access_control.list_friends_empty": { + "en": "ℹ️ No friends in the list.", + "ru": "ℹ️ Список друзей пуст.", + }, + "access_control.list_friends_header": { + "en": "👥 Friends List:\n\n", + "ru": "👥 Список друзей:\n\n", + }, + "access_control.list_friends_total": { + "en": "\nTotal: {count} friends", + "ru": "\nВсего: {count} друзей", + }, + "access_control.list_friends_error": { + "en": "❌ Error listing friends: {error}", + "ru": "❌ Ошибка получения списка друзей: {error}", + }, + # -- chat_binder -- + "chat_binder.user_info_missing": { + "en": "User information is missing.", + "ru": "Информация о пользователе отсутствует.", + }, + "chat_binder.message_text_missing": { + "en": "Message text is missing.", + "ru": "Текст сообщения отсутствует.", + }, + "chat_binder.bound_success": { + "en": "Chat bound successfully with key: {key}\n{access_status}", + "ru": "Чат успешно привязан с ключом: {key}\n{access_status}", + }, + "chat_binder.has_access": { + "en": "✅ I have access to this chat.", + "ru": "✅ У меня есть доступ к этому чату.", + }, + "chat_binder.no_access": { + "en": "❌ I don't have access to this chat.", + "ru": "❌ У меня нет доступа к этому чату.", + }, + "chat_binder.bind_error": { + "en": "Error: {error}", + "ru": "Ошибка: {error}", + }, + "chat_binder.unbound_success": { + "en": "Chat unbound successfully with key: {key}", + "ru": "Чат успешно отвязан с ключом: {key}", + }, + "chat_binder.unbind_not_found": { + "en": "No chat was bound with key: {key}", + "ru": "Нет чата, привязанного с ключом: {key}", + }, + "chat_binder.not_bound": { + "en": "This chat is not bound to you.", + "ru": "Этот чат не привязан к вам.", + }, + "chat_binder.status_single": { + "en": "This chat is bound to you with key: '{key}'\nAccess status: {access_status}", + "ru": "Этот чат привязан к вам с ключом: '{key}'\nСтатус доступа: {access_status}", + }, + "chat_binder.status_has_access": { + "en": "✅ Bot has access", + "ru": "✅ Бот имеет доступ", + }, + "chat_binder.status_no_access": { + "en": "❌ Bot doesn't have access", + "ru": "❌ Бот не имеет доступа", + }, + "chat_binder.status_multiple": { + "en": "This chat is bound to you with {count} keys:\n{details}", + "ru": "Этот чат привязан к вам с {count} ключами:\n{details}", + }, + "chat_binder.list_empty": { + "en": "You don't have any bound chats.", + "ru": "У вас нет привязанных чатов.", + }, + "chat_binder.list_header": { + "en": "Your bound chats:\n{chats_info}", + "ru": "Ваши привязанные чаты:\n{chats_info}", + }, + "chat_binder.list_error": { + "en": "Error listing chats: {error}", + "ru": "Ошибка получения списка чатов: {error}", + }, + "chat_binder.get_chat_result": { + "en": "Bound chat for key '{key}': {chat_id}", + "ru": "Привязанный чат для ключа '{key}': {chat_id}", + }, + "chat_binder.check_error": { + "en": "Error checking binding status: {error}", + "ru": "Ошибка проверки статуса привязки: {error}", + }, + # -- user_interactions -- + "user_interactions.timeout": { + "en": "\n\n⏰ No response received within the time limit.", + "ru": "\n\n⏰ Ответ не получен в установленный срок.", + }, + "user_interactions.timeout_auto_selected": { + "en": "\n\n⏰ Auto-selected: {choice}", + "ru": "\n\n⏰ Автоматически выбрано: {choice}", + }, + "user_interactions.late_response": { + "en": "Sorry, this response came too late or was for a different question. Please try again.", + "ru": "Извините, этот ответ пришёл слишком поздно или был для другого вопроса. Попробуйте снова.", + }, + "user_interactions.choice_invalid": { + "en": "This choice is no longer valid.", + "ru": "Этот выбор больше не действителен.", + }, + "user_interactions.choice_recorded": { + "en": "Your choice has already been recorded.", + "ru": "Ваш выбор уже записан.", + }, + "user_interactions.selected": { + "en": "\n\nSelected: {choice}", + "ru": "\n\nВыбрано: {choice}", + }, + "user_interactions.hint": { + "en": "\n\nTip: You can choose an option or type your own response.", + "ru": "\n\nПодсказка: Вы можете выбрать вариант или ввести свой ответ.", + }, + # -- bot_commands_menu -- + "commands_menu.public_header": { + "en": "📝 Public commands:", + "ru": "📝 Публичные команды:", + }, + "commands_menu.hidden_header": { + "en": "🕵️ Hidden commands:", + "ru": "🕵️ Скрытые команды:", + }, + "commands_menu.admin_header": { + "en": "👑 Admin commands:", + "ru": "👑 Команды администратора:", + }, + "commands_menu.no_commands": { + "en": "No commands available", + "ru": "Нет доступных команд", + }, + # -- auto_archive -- + "auto_archive.intro": { + "en": ( + "🔔 Auto-archive is enabled! Your messages will be forwarded and deleted after a short delay.\n" + "• Use {no_archive_tag} to prevent both forwarding and deletion\n" + "• Use {no_delete_tag} to forward but keep the original message\n" + "Use /autoarchive_help for more info." + ), + "ru": ( + "🔔 Автоархив включён! Ваши сообщения будут пересланы и удалены после короткой задержки.\n" + "• Используйте {no_archive_tag} чтобы предотвратить пересылку и удаление\n" + "• Используйте {no_delete_tag} чтобы переслать, но сохранить оригинал\n" + "Используйте /autoarchive_help для информации." + ), + }, + "auto_archive.no_binding": { + "en": "Auto-archive is enabled, but you don't have a bound chat for forwarding messages to.", + "ru": "Автоархив включён, но у вас нет привязанного чата для пересылки сообщений.", + }, + "auto_archive.supergroup_error": { + "en": "⚠️ The bound chat was upgraded to supergroup. Please use /bind_auto_archive to bind the new supergroup.", + "ru": "⚠️ Привязанный чат был обновлён до супергруппы. Используйте /bind_auto_archive чтобы привязать новую супергруппу.", + }, + "auto_archive.bind_success": { + "en": "Chat bound for auto-archiving", + "ru": "Чат привязан для автоархива", + }, + "auto_archive.help": { + "en": ( + "🤖 Auto-Archive Help\n\n" + "• Messages are automatically forwarded to your bound chat and deleted after a short delay\n" + "• Use {no_archive_tag} to prevent both forwarding and deletion\n" + "• Use {no_delete_tag} to forward but keep the original message\n" + "• Use /bind_auto_archive to bind a chat for auto-archiving" + ), + "ru": ( + "🤖 Справка по автоархиву\n\n" + "• Сообщения автоматически пересылаются в привязанный чат и удаляются после короткой задержки\n" + "• Используйте {no_archive_tag} чтобы предотвратить пересылку и удаление\n" + "• Используйте {no_delete_tag} чтобы переслать, но сохранить оригинал\n" + "• Используйте /bind_auto_archive чтобы привязать чат для автоархива" + ), + }, + # -- telethon_manager -- + "telethon.phone_prompt": { + "en": "Please enter your phone number (including country code, e.g., +1234567890):", + "ru": "Введите номер телефона (включая код страны, например, +1234567890):", + }, + "telethon.cancelled_no_phone": { + "en": "Setup cancelled - no phone number provided.", + "ru": "Настройка отменена — номер телефона не предоставлен.", + }, + "telethon.code_prompt": { + "en": "Please enter MODIFIED verification code as follows: YOUR CODE splitted with spaces e.g '21694' -> '2 1 6 9 4' or telegram WILL BLOCK IT", + "ru": "Введите МОДИФИЦИРОВАННЫЙ код подтверждения: КОД разделённый пробелами, например '21694' -> '2 1 6 9 4', иначе Telegram ЗАБЛОКИРУЕТ", + }, + "telethon.cancelled_no_code": { + "en": "Setup cancelled - no verification code provided.", + "ru": "Настройка отменена — код подтверждения не предоставлен.", + }, + "telethon.code_no_spaces": { + "en": "Setup cancelled - YOU DID NOT split the code with spaces like this: '2 1 6 9 4'", + "ru": "Настройка отменена — вы НЕ разделили код пробелами: '2 1 6 9 4'", + }, + "telethon.password_prompt": { + "en": "Two-factor authentication is enabled. Please enter your 2FA password:", + "ru": "Включена двухфакторная аутентификация. Введите пароль 2FA:", + }, + "telethon.cancelled_no_password": { + "en": "Setup cancelled - no password provided.", + "ru": "Настройка отменена — пароль не предоставлен.", + }, + "telethon.setup_success": { + "en": "Successfully set up Telethon client! The session is saved and ready to use.", + "ru": "Telethon клиент успешно настроен! Сеанс сохранён и готов к использованию.", + }, + "telethon.setup_error": { + "en": "Error during setup: {error}", + "ru": "Ошибка при настройке: {error}", + }, + "telethon.already_active": { + "en": "You already have an active Telethon session! Use /setup_telethon_force to create a new one.", + "ru": "У вас уже есть активный сеанс Telethon! Используйте /setup_telethon_force для создания нового.", + }, + "telethon.active_session_found": { + "en": "Active Telethon session found for {name}!", + "ru": "Найден активный сеанс Telethon для {name}!", + }, + "telethon.no_session": { + "en": "No active Telethon session found. Use /setup_telethon to create one.", + "ru": "Активный сеанс Telethon не найден. Используйте /setup_telethon для создания нового.", + }, + "telethon.not_connected": { + "en": "Client for user {user_id} not found. Please run the /setup_telethon command to authenticate.", + "ru": "Клиент для пользователя {user_id} не найден. Запустите команду /setup_telethon для аутентификации.", + }, + # -- trial_mode -- + "trial_mode.limit_reached": { + "en": "You have reached your usage limit for the {func_name} command. Reset in: {remaining_time}.", + "ru": "Вы достигли лимита команды {func_name}. Сброс через: {remaining_time}.", + }, + "trial_mode.global_limit": { + "en": "The {func_name} command has reached its global usage limit. Please try again later. Reset in: {remaining_time}.", + "ru": "Команда {func_name} достигла глобального лимита. Попробуйте позже. Сброс через: {remaining_time}.", + }, + "trial_mode.bot_limit": { + "en": "The bot has reached its global usage limit. Please try again later. Reset in: {remaining_time}.", + "ru": "Бот достиг глобального лимита использования. Попробуйте позже. Сброс через: {remaining_time}.", + }, + "trial_mode.personal_limit": { + "en": "You have reached your personal usage limit. Please try again later. Reset in: {remaining_time}.", + "ru": "Вы достигли личного лимита. Попробуйте позже. Сброс через: {remaining_time}.", + }, + # -- error_handler -- + "error_handler.something_went_wrong": { + "en": "Oops, something went wrong :(", + "ru": "Упс, что-то пошло не так :(", + }, + "error_handler.easter_egg": { + "en": "\nHere, take this instead: \n{easter_egg}", + "ru": "\nВот, возьмите вместо этого: \n{easter_egg}", + }, + # -- llm_provider -- + "llm_provider.no_access": { + "en": "You don't have access to AI features", + "ru": "У вас нет доступа к функциям AI", + }, + "llm_provider.no_access_contact_admin": { + "en": "You don't have access to AI features, please write to {admin_contact} to request access", + "ru": "У вас нет доступа к функциям AI, напишите {admin_contact} для получения доступа", + }, + "llm_provider.no_stats": { + "en": "No LLM usage statistics available.", + "ru": "Статистика использования LLM недоступна.", + }, + # -- multi_forward -- + "multi_forward.message_count": { + "en": "Received {count} messages", + "ru": "Получено {count} сообщений", + }, + "multi_forward.send_as_file_disabled": { + "en": "Send as file disabled", + "ru": "Отправка файлом отключена", + }, + "multi_forward.send_as_file_enabled": { + "en": "Send as file enabled", + "ru": "Отправка файлом включена", + }, +} diff --git a/botspot/components/new/auto_archive.py b/botspot/components/new/auto_archive.py index 86f3b4a..78073c1 100644 --- a/botspot/components/new/auto_archive.py +++ b/botspot/components/new/auto_archive.py @@ -10,11 +10,12 @@ from pydantic_settings import BaseSettings from botspot.commands_menu import Visibility, botspot_command +from botspot.components.middlewares.i18n import t from botspot.utils.deps_getters import get_database from botspot.utils.send_safe import send_safe if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 class CommandFilterMode(str, Enum): @@ -52,7 +53,7 @@ def __init__(self, settings: AutoArchiveSettings) -> None: self._warning_sent: Set[int] = set() self._collection = None - async def _get_collection(self) -> "AsyncIOMotorCollection": + async def _get_collection(self) -> "AsyncCollection": if self._collection is None: db = get_database() self._collection = db["auto_archive_intro"] @@ -143,21 +144,21 @@ async def __call__( except ChatBindingNotFoundError: # tell user to bind the chat if user_id not in self._warning_sent: - message_text = "Auto-archive is enabled, but you don't have a bound chat for forwarding messages to." - await send_safe(message.chat.id, message_text) + await send_safe(message.chat.id, t("auto_archive.no_binding")) self._warning_sent.add(user_id) await self._save_warning_sent(user_id) return await handler(event, data) # Send intro message if this is first time for this user if user_id not in self._intro_sent: - intro_message = ( - "🔔 Auto-archive is enabled! Your messages will be forwarded and deleted after a short delay.\n" - f"• Use {self.settings.no_archive_tag} to prevent both forwarding and deletion\n" - f"• Use {self.settings.no_delete_tag} to forward but keep the original message\n" - "Use /autoarchive_help for more info." + await send_safe( + message.chat.id, + t( + "auto_archive.intro", + no_archive_tag=self.settings.no_archive_tag, + no_delete_tag=self.settings.no_delete_tag, + ), ) - await send_safe(message.chat.id, intro_message) self._intro_sent.add(user_id) await self._save_intro_sent(user_id) @@ -173,11 +174,7 @@ async def __call__( if "the message can't be forwarded" in str(e): # Likely the group was upgraded to supergroup await unbind_chat(user_id, key=self.settings.chat_binding_key) - message_text = ( - "⚠️ The bound chat was upgraded to supergroup. " - "Please use /bind_auto_archive to bind the new supergroup." - ) - await send_safe(message.chat.id, message_text) + await send_safe(message.chat.id, t("auto_archive.supergroup_error")) return await handler(event, data) raise @@ -210,7 +207,7 @@ async def cmd_bind_auto_archive(message: Message): from botspot.chat_binder import bind_chat await bind_chat(message.from_user.id, message.chat.id, key=aa.settings.chat_binding_key) - await send_safe(message.chat.id, "Chat bound for auto-archiving") + await send_safe(message.chat.id, t("auto_archive.bind_success")) dp.message.register(cmd_bind_auto_archive, Command("bind_auto_archive")) @@ -221,14 +218,14 @@ async def cmd_bind_auto_archive(message: Message): visibility=Visibility.PUBLIC, ) async def cmd_help_autoarchive(message: Message): - help_text = ( - "🤖 Auto-Archive Help\n\n" - "• Messages are automatically forwarded to your bound chat and deleted after a short delay\n" - f"• Use {aa.settings.no_archive_tag} to prevent both forwarding and deletion\n" - f"• Use {aa.settings.no_delete_tag} to forward but keep the original message\n" - "• Use /bind_auto_archive to bind a chat for auto-archiving" + await send_safe( + message.chat.id, + t( + "auto_archive.help", + no_archive_tag=aa.settings.no_archive_tag, + no_delete_tag=aa.settings.no_delete_tag, + ), ) - await send_safe(message.chat.id, help_text) dp.message.register(cmd_help_autoarchive, Command("help_autoarchive")) diff --git a/botspot/components/new/chat_binder.py b/botspot/components/new/chat_binder.py index 2e256f4..25c5724 100644 --- a/botspot/components/new/chat_binder.py +++ b/botspot/components/new/chat_binder.py @@ -18,10 +18,11 @@ from pydantic import BaseModel from pydantic_settings import BaseSettings +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 logger = get_logger() @@ -58,7 +59,7 @@ class BoundChatRecord(BaseModel): class ChatBinder: def __init__( - self, settings: ChatBinderSettings, collection: Optional["AsyncIOMotorCollection"] = None + self, settings: ChatBinderSettings, collection: Optional["AsyncCollection"] = None ): """Initialize the ChatBinder. @@ -314,12 +315,12 @@ async def bind_chat_command_handler(message: Message): """Handler for the /bind_chat command.""" chat_id = message.chat.id if message.from_user is None: - await message.reply("User information is missing.") + await message.reply(t("chat_binder.user_info_missing")) return user_id = message.from_user.id if message.text is None: - await message.reply("Message text is missing.") + await message.reply(t("chat_binder.message_text_missing")) return key = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else "default" @@ -329,25 +330,23 @@ async def bind_chat_command_handler(message: Message): # Include access status in the response access_status = ( - "✅ I have access to this chat." - if record.has_access - else "❌ I don't have access to this chat." + t("chat_binder.has_access") if record.has_access else t("chat_binder.no_access") ) - await message.reply(f"Chat bound successfully with key: {key}\n{access_status}") + await message.reply(t("chat_binder.bound_success", key=key, access_status=access_status)) except ValueError as e: - await message.reply(f"Error: {str(e)}") + await message.reply(t("chat_binder.bind_error", error=str(e))) async def unbind_chat_command_handler(message: Message): """Handler for the /unbind_chat command.""" if message.from_user is None: - await message.reply("User information is missing.") + await message.reply(t("chat_binder.user_info_missing")) return user_id = message.from_user.id if message.text is None: - await message.reply("Message text is missing.") + await message.reply(t("chat_binder.message_text_missing")) return key = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else "default" @@ -355,18 +354,18 @@ async def unbind_chat_command_handler(message: Message): try: result, deleted_key = await unbind_chat(user_id, key, chat_id=message.chat.id) if result: - await message.reply(f"Chat unbound successfully with key: {deleted_key}") + await message.reply(t("chat_binder.unbound_success", key=deleted_key)) else: - await message.reply(f"No chat was bound with key: {key}") + await message.reply(t("chat_binder.unbind_not_found", key=key)) except Exception as e: - await message.reply(f"Error: {str(e)}") + await message.reply(t("chat_binder.bind_error", error=str(e))) async def bind_status_command_handler(message: Message): """Handler for the /bind_status command.""" chat_id = message.chat.id if message.from_user is None: - await message.reply("User information is missing.") + await message.reply(t("chat_binder.user_info_missing")) return user_id = message.from_user.id @@ -376,7 +375,7 @@ async def bind_status_command_handler(message: Message): bindings = await get_binding_records(user_id, chat_id) if not bindings: - await message.reply("This chat is not bound to you.") + await message.reply(t("chat_binder.not_bound")) return # Format binding information @@ -384,12 +383,13 @@ async def bind_status_command_handler(message: Message): binding = bindings[0] # Include access status information access_status = ( - "✅ Bot has access" if binding.has_access else "❌ Bot doesn't have access" + t("chat_binder.status_has_access") + if binding.has_access + else t("chat_binder.status_no_access") ) await message.reply( - f"This chat is bound to you with key: '{binding.key}'\n" - f"Access status: {access_status}" + t("chat_binder.status_single", key=binding.key, access_status=access_status) ) else: # For multiple bindings, create a simple list @@ -400,48 +400,48 @@ async def bind_status_command_handler(message: Message): details_text = "\n".join(binding_details) await message.reply( - f"This chat is bound to you with {len(bindings)} keys:\n{details_text}" + t("chat_binder.status_multiple", count=len(bindings), details=details_text) ) except Exception as e: logger.error(f"Error in bind_status_command_handler: {e}") - await message.reply(f"Error checking binding status: {str(e)}") + await message.reply(t("chat_binder.check_error", error=str(e))) async def async_list_chats_handler(message: Message): """Handler for the /list_chats command.""" if message.from_user is None: - await message.reply("User information is missing.") + await message.reply(t("chat_binder.user_info_missing")) return try: bound_chats = await list_user_bindings(message.from_user.id) if not bound_chats: - await message.reply("You don't have any bound chats.") + await message.reply(t("chat_binder.list_empty")) return chats_info = "\n".join( [f"Key: {chat.key}, Chat ID: {chat.chat_id}" for chat in bound_chats] ) - await message.reply(f"Your bound chats:\n{chats_info}") + await message.reply(t("chat_binder.list_header", chats_info=chats_info)) except Exception as e: - await message.reply(f"Error listing chats: {str(e)}") + await message.reply(t("chat_binder.list_error", error=str(e))) # todo: ask_user_choice - list all keys and ask user to choose one async def async_get_chat_handler(message: Message): """Handler for the /get_chat command.""" if message.from_user is None: - await message.reply("User information is missing.") + await message.reply(t("chat_binder.user_info_missing")) return if message.text is None: - await message.reply("Message text is missing.") + await message.reply(t("chat_binder.message_text_missing")) return key = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else "default" chat_id = await get_bound_chat(message.from_user.id, key) - await message.reply(f"Bound chat for key '{key}': {chat_id}") + await message.reply(t("chat_binder.get_chat_result", key=key, chat_id=chat_id)) def setup_dispatcher(dp): diff --git a/botspot/components/new/contact_manager.py b/botspot/components/new/contact_manager.py index a8a44ab..b862409 100644 --- a/botspot/components/new/contact_manager.py +++ b/botspot/components/new/contact_manager.py @@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 class ContactManagerSettings(BaseSettings): diff --git a/botspot/components/new/context_builder.py b/botspot/components/new/context_builder.py index 74f9c58..c0ca868 100644 --- a/botspot/components/new/context_builder.py +++ b/botspot/components/new/context_builder.py @@ -41,8 +41,8 @@ from botspot.utils.internal import get_logger if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 - from motor.motor_asyncio import AsyncIOMotorDatabase # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 + from pymongo.asynchronous.database import AsyncDatabase # noqa: F401 logger = get_logger() diff --git a/botspot/components/new/llm_provider.py b/botspot/components/new/llm_provider.py index c701e99..336adfd 100644 --- a/botspot/components/new/llm_provider.py +++ b/botspot/components/new/llm_provider.py @@ -17,6 +17,7 @@ ) from aiogram.types import Chat, User +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger from botspot.utils.unsorted import ( Attachment, @@ -380,7 +381,6 @@ async def _prepare_request( # Check if user is allowed to use LLM if not await self._check_user_allowed(user): # Get admin contact info for user message - user_message = "You don't have access to AI features" if deps.botspot_settings.admins and len(deps.botspot_settings.admins) > 0: if len(deps.botspot_settings.admins) > 1: admin_contact = ( @@ -390,7 +390,11 @@ async def _prepare_request( ) else: admin_contact = "admin @" + deps.botspot_settings.admins[0].lstrip("@") - user_message += f", please write to {admin_contact} to request access" + user_message = t( + "llm_provider.no_access_contact_admin", admin_contact=admin_contact + ) + else: + user_message = t("llm_provider.no_access") raise LLMPermissionError( message=f"User {user} is not allowed to use LLM features", user_message=user_message, @@ -763,7 +767,7 @@ async def llm_usage_command(message: Message): stats = await get_llm_usage_stats() if not stats: - await message.reply("No LLM usage statistics available.") + await message.reply(t("llm_provider.no_stats")) return # Format stats nicely diff --git a/botspot/components/new/subscription_manager.py b/botspot/components/new/subscription_manager.py index cfc6034..84785fc 100644 --- a/botspot/components/new/subscription_manager.py +++ b/botspot/components/new/subscription_manager.py @@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings if TYPE_CHECKING: - from motor.motor_asyncio import AsyncIOMotorCollection # noqa: F401 + from pymongo.asynchronous.collection import AsyncCollection # noqa: F401 class SubscriptionManagerSettings(BaseSettings): diff --git a/botspot/components/qol/bot_commands_menu.py b/botspot/components/qol/bot_commands_menu.py index 11bd8ee..0c5d20c 100644 --- a/botspot/components/qol/bot_commands_menu.py +++ b/botspot/components/qol/bot_commands_menu.py @@ -8,6 +8,7 @@ from deprecated import deprecated from pydantic_settings import BaseSettings +from botspot.components.middlewares.i18n import t from botspot.utils.internal import get_logger logger = get_logger() @@ -104,7 +105,7 @@ def _format_nested_commands(include_admin: bool, settings: BotCommandsMenuSettin # Process public commands by group if grouped_commands[Visibility.PUBLIC]: - result.append("📝 Public commands:") + result.append(t("commands_menu.public_header")) for group_name, cmds in sorted(grouped_commands[Visibility.PUBLIC].items()): result.append(f"\n{group_name.title()}:") for cmd, desc in sorted(cmds): @@ -112,7 +113,7 @@ def _format_nested_commands(include_admin: bool, settings: BotCommandsMenuSettin # Process hidden commands by group if grouped_commands[Visibility.HIDDEN]: - result.append("\n🕵️ Hidden commands:") + result.append("\n" + t("commands_menu.hidden_header")) for group_name, cmds in sorted(grouped_commands[Visibility.HIDDEN].items()): result.append(f"\n{group_name.title()}:") for cmd, desc in sorted(cmds): @@ -120,13 +121,13 @@ def _format_nested_commands(include_admin: bool, settings: BotCommandsMenuSettin # Process admin commands by group (if allowed) if include_admin and grouped_commands[Visibility.ADMIN_ONLY]: - result.append("\n👑 Admin commands:") + result.append("\n" + t("commands_menu.admin_header")) for group_name, cmds in sorted(grouped_commands[Visibility.ADMIN_ONLY].items()): result.append(f"\n{group_name.title()}:") for cmd, desc in sorted(cmds): result.append(f" /{cmd} - {desc}") - return "\n".join(result) if result else "No commands available" + return "\n".join(result) if result else t("commands_menu.no_commands") def _format_flat_commands(include_admin: bool, settings: BotCommandsMenuSettings) -> str: @@ -136,9 +137,9 @@ def _format_flat_commands(include_admin: bool, settings: BotCommandsMenuSettings # Visibility-based fallback groups visibility_groups = { - Visibility.PUBLIC: "📝 Public commands", - Visibility.HIDDEN: "🕵️ Hidden commands", - Visibility.ADMIN_ONLY: "👑 Admin commands", + Visibility.PUBLIC: t("commands_menu.public_header"), + Visibility.HIDDEN: t("commands_menu.hidden_header"), + Visibility.ADMIN_ONLY: t("commands_menu.admin_header"), } # Process each command @@ -181,7 +182,7 @@ def _format_flat_commands(include_admin: bool, settings: BotCommandsMenuSettings for cmd, desc, _ in sorted(groups[admin_group]): result.append(f" /{cmd} - {desc}") - return "\n".join(result).strip() if result else "No commands available" + return "\n".join(result).strip() if result else t("commands_menu.no_commands") async def set_aiogram_bot_commands(bot: Bot, settings: BotCommandsMenuSettings = None): diff --git a/botspot/core/bot_manager.py b/botspot/core/bot_manager.py index 259d474..4f11fff 100644 --- a/botspot/core/bot_manager.py +++ b/botspot/core/bot_manager.py @@ -15,7 +15,7 @@ telethon_manager, trial_mode, ) -from botspot.components.middlewares import error_handler, simple_user_cache +from botspot.components.middlewares import error_handler, i18n, simple_user_cache from botspot.components.new import ( auto_archive, chat_binder, @@ -92,6 +92,12 @@ def __init__( def setup_dispatcher(self, dp: Dispatcher): """Setup dispatcher with components""" + # i18n middleware must be first so language is set before any component sends text + if self.settings.i18n.enabled: + dp.update.middleware( + i18n.I18nMiddleware(default_locale=self.settings.i18n.default_locale) + ) + # Remove global bot check - each component handles its own requirements if self.settings.user_data.enabled: user_data.setup_dispatcher(dp, **self.settings.user_data.model_dump()) diff --git a/botspot/core/botspot_settings.py b/botspot/core/botspot_settings.py index be27d3d..e1df406 100644 --- a/botspot/core/botspot_settings.py +++ b/botspot/core/botspot_settings.py @@ -10,6 +10,7 @@ from botspot.components.main.telethon_manager import TelethonManagerSettings from botspot.components.main.trial_mode import TrialModeSettings from botspot.components.middlewares.error_handler import ErrorHandlerSettings +from botspot.components.middlewares.i18n import I18nSettings from botspot.components.new.auto_archive import AutoArchiveSettings from botspot.components.new.chat_binder import ChatBinderSettings from botspot.components.new.chat_fetcher import ChatFetcherSettings @@ -65,6 +66,7 @@ def friends(self) -> List[str]: trial_mode: TrialModeSettings = TrialModeSettings() user_data: UserDataSettings = UserDataSettings() single_user_mode: SingleUserModeSettings = SingleUserModeSettings() + i18n: I18nSettings = I18nSettings() send_safe: SendSafeSettings = SendSafeSettings() admin_filter: AdminFilterSettings = AdminFilterSettings() chat_binder: ChatBinderSettings = ChatBinderSettings() diff --git a/botspot/core/dependency_manager.py b/botspot/core/dependency_manager.py index cfbac27..53a806a 100644 --- a/botspot/core/dependency_manager.py +++ b/botspot/core/dependency_manager.py @@ -18,10 +18,8 @@ from botspot.components.new.llm_provider import LLMProvider from botspot.components.new.queue_manager import QueueManager from botspot.components.new.s3_storage import S3StorageProvider - from motor.motor_asyncio import ( - AsyncIOMotorClient, # noqa: F401 - AsyncIOMotorDatabase, # noqa: F401 - ) + from pymongo import AsyncMongoClient # noqa: F401 + from pymongo.asynchronous.database import AsyncDatabase # noqa: F401 class DependencyManager(metaclass=Singleton): @@ -30,8 +28,8 @@ def __init__( botspot_settings: Optional[BotspotSettings] = None, bot: Optional[Bot] = None, dispatcher: Optional[Dispatcher] = None, - mongo_client: Optional["AsyncIOMotorClient"] = None, - mongo_database: Optional["AsyncIOMotorDatabase"] = None, + mongo_client: Optional["AsyncMongoClient"] = None, + mongo_database: Optional["AsyncDatabase"] = None, **kwargs, ): self._botspot_settings = botspot_settings or BotspotSettings() @@ -81,7 +79,7 @@ def dispatcher(self, value): self._dispatcher = value @property - def mongo_client(self) -> "AsyncIOMotorClient": + def mongo_client(self) -> "AsyncMongoClient": if self._mongo_client is None: raise BotspotError("MongoDB client is not initialized") return self._mongo_client @@ -91,7 +89,7 @@ def mongo_client(self, value): self._mongo_client = value @property - def mongo_database(self) -> "AsyncIOMotorDatabase": + def mongo_database(self) -> "AsyncDatabase": if self._mongo_database is None: raise BotspotError("MongoDB database is not initialized") return self._mongo_database diff --git a/botspot/i18n.py b/botspot/i18n.py new file mode 100644 index 0000000..09364d9 --- /dev/null +++ b/botspot/i18n.py @@ -0,0 +1,10 @@ +"""Convenience re-export for botspot i18n.""" + +from botspot.components.middlewares.i18n import ( + get_lang, + register_strings, + set_lang, + t, +) + +__all__ = ["t", "set_lang", "get_lang", "register_strings"] diff --git a/dev/inbox/capture_sample_messages/example/load_message.ipynb b/dev/inbox/capture_sample_messages/example/load_message.ipynb index ffaae24..d9d8de5 100644 --- a/dev/inbox/capture_sample_messages/example/load_message.ipynb +++ b/dev/inbox/capture_sample_messages/example/load_message.ipynb @@ -22,8 +22,7 @@ "\n", "p1 = p / \"sample_voice_attached.pkl\"\n", "\n", - "m1 = pickle.load(open(p1, \"rb\"))\n", - "\n" + "m1 = pickle.load(open(p1, \"rb\"))" ] }, { diff --git a/docs/index.md b/docs/index.md index da74bcd..cc3e587 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,9 +66,10 @@ Built-in LLM support: ## Get Started -1. **[Installation & Setup](getting-started.md)** - Get your first bot running -2. **[Components Guide](components/index.md)** - Learn the component system -3. **[Examples](examples.md)** - See real-world implementations -4. **[API Reference](api/core.md)** - Detailed API documentation +Install with pip or uv: -Ready to build something awesome? Let's go! 🚀 \ No newline at end of file +```bash +uv add botspot +``` + +Check out the [examples](https://github.com/calmmage/botspot/tree/main/examples) and the [botspot-template](https://github.com/calmmage/botspot-template) to get started. \ No newline at end of file diff --git a/examples/components_examples/archive/database_demo/app.py b/examples/components_examples/archive/database_demo/app.py index ade14a1..8ff8f46 100644 --- a/examples/components_examples/archive/database_demo/app.py +++ b/examples/components_examples/archive/database_demo/app.py @@ -1,6 +1,6 @@ from typing import List, Optional -from motor.motor_asyncio import AsyncIOMotorDatabase +from pymongo.asynchronous.database import AsyncDatabase from pydantic_settings import BaseSettings from botspot.utils.deps_getters import get_database @@ -21,10 +21,10 @@ class App: def __init__(self, **kwargs): self.config = AppConfig(**kwargs) - self._db: Optional[AsyncIOMotorDatabase] = None + self._db: Optional[AsyncDatabase] = None @property - def db(self) -> AsyncIOMotorDatabase: + def db(self) -> AsyncDatabase: if self._db is None: self._db = get_database() return self._db diff --git a/examples/components_examples/auto_archive_demo/bot.py b/examples/components_examples/auto_archive_demo/bot.py index 83b6814..ca26ce6 100644 --- a/examples/components_examples/auto_archive_demo/bot.py +++ b/examples/components_examples/auto_archive_demo/bot.py @@ -1,9 +1,6 @@ from aiogram import F -from aiogram.filters import Command from aiogram.types import Message -from botspot.commands_menu import botspot_command -from botspot.components.new.chat_binder import list_user_bindings from botspot.utils.send_safe import send_safe from examples.base_bot import App, main, router diff --git a/examples/components_examples/telethon_manager_example/bot.py b/examples/components_examples/telethon_manager_example/bot.py index 46b338f..279aa52 100644 --- a/examples/components_examples/telethon_manager_example/bot.py +++ b/examples/components_examples/telethon_manager_example/bot.py @@ -2,14 +2,12 @@ Example usage of the telethon_manager component. """ -from aiogram import html -from aiogram.filters import Command, CommandStart +from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.types import Message from botspot.commands_menu import botspot_command from botspot.components.main.telethon_manager import get_telethon_manager -from botspot.components.main.trial_mode import add_global_limit, add_user_limit from examples.base_bot import App, main, router diff --git a/mkdocs.yml b/mkdocs.yml index c258e78..0f61ed4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,20 +44,6 @@ plugins: nav: - Home: index.md - - Getting Started: getting-started.md - - Components: - - Overview: components/index.md - - Data: components/data.md - - Features: components/features.md - - Main: components/main.md - - Middlewares: components/middlewares.md - - QoL: components/qol.md - - API Reference: - - Core: api/core.md - - Components: api/components.md - - Utils: api/utils.md - - Examples: examples.md - - Contributing: contributing.md markdown_extensions: - pymdownx.highlight: diff --git a/pyproject.toml b/pyproject.toml index 8465620..9292f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ extras = [ "apscheduler<4.0.0,>=3.10.4", "pymongo>=4.9", "litellm<2.0,>=1.63", - "motor<4.0.0,>=3.6.0", "telethon<2.0.0,>=1.38.1", "aioboto3<15.0.0,>=14.1.0", "mistune<4.0.0,>=3.1.3", @@ -41,6 +40,7 @@ test = [ "pyupgrade<4.0.0,>=3.20.0", "pyright<2.0.0,>=1.1.401", "ruff<1.0.0,>=0.11.12", + "lizard>=1.21.2", ] docs = [ "mkdocs>=1.6", diff --git a/tests/components/new/test_example_apps.py b/tests/components/new/test_example_apps.py index 819c9ba..6edf16c 100644 --- a/tests/components/new/test_example_apps.py +++ b/tests/components/new/test_example_apps.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from aiogram import Dispatcher # Add the necessary paths for imports sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))) diff --git a/tests/components/qol/test_bot_info.py b/tests/components/qol/test_bot_info.py index 8c832ca..29e65e5 100644 --- a/tests/components/qol/test_bot_info.py +++ b/tests/components/qol/test_bot_info.py @@ -93,7 +93,7 @@ async def mock_answer(text): # Build response response = [ - f"🤖 Bot Information", + "🤖 Bot Information", f"Botspot Version: {botspot.__version__}", f"Uptime: {uptime.days}d {uptime.seconds // 3600}h {(uptime.seconds // 60) % 60}m", "\n📊 Enabled Components:", @@ -108,7 +108,7 @@ async def mock_answer(text): if mock_settings.bot_info.show_detailed_settings: # model dump - response.append(f"\n📊 Detailed Settings:") + response.append("\n📊 Detailed Settings:") response.append(mock_settings.model_dump_json(indent=2)) # Call our mock answer function @@ -161,7 +161,7 @@ async def mock_answer(text): # Build response response = [ - f"🤖 Bot Information", + "🤖 Bot Information", f"Botspot Version: {botspot.__version__}", f"Uptime: {uptime.days}d {uptime.seconds // 3600}h {(uptime.seconds // 60) % 60}m", "\n📊 Enabled Components:", @@ -176,7 +176,7 @@ async def mock_answer(text): if mock_settings.bot_info.show_detailed_settings: # model dump - response.append(f"\n📊 Detailed Settings:") + response.append("\n📊 Detailed Settings:") response.append(mock_settings.model_dump_json(indent=2)) # Call our mock answer function @@ -263,7 +263,7 @@ async def mock_answer(text): # Build response directly with our known uptime response = [ - f"🤖 Bot Information", + "🤖 Bot Information", f"Botspot Version: {botspot.__version__}", uptime_text, "\n📊 Enabled Components:", diff --git a/tests/conftest.py b/tests/conftest.py index 9802e6b..fb63d80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,14 +3,13 @@ import pytest # Mock the MongoDB client -pytest.importorskip("motor.motor_asyncio") pytest.importorskip("pymongo") # Mock MongoDB connection @pytest.fixture(autouse=True) def mock_mongo(): - with patch("motor.motor_asyncio.AsyncIOMotorClient") as mock_client: + with patch("pymongo.AsyncMongoClient") as mock_client: mock_db = MagicMock() mock_client.return_value.__getitem__.return_value = mock_db yield mock_client diff --git a/tests/utils/test_user_ops.py b/tests/utils/test_user_ops.py index 13365ce..b6c24bd 100644 --- a/tests/utils/test_user_ops.py +++ b/tests/utils/test_user_ops.py @@ -88,7 +88,7 @@ def test_phone_number(self): mock_cache = MagicMock() mock_deps.simple_user_cache = mock_cache mock_get_deps.return_value = mock_deps - + phone = "+12345678901" result = get_user_record(phone) assert result.phone == phone @@ -104,7 +104,7 @@ def test_numeric_string(self): mock_cache.get_user.side_effect = Exception("User not found") mock_deps.simple_user_cache = mock_cache mock_get_deps.return_value = mock_deps - + user_id_str = "12345" result = get_user_record(user_id_str) assert result.user_id == 12345 @@ -120,7 +120,7 @@ def test_username_with_at(self): mock_cache.get_user_by_username.side_effect = Exception("User not found") mock_deps.simple_user_cache = mock_cache mock_get_deps.return_value = mock_deps - + username = "@test_user" result = get_user_record(username) assert result.username == "test_user" # @ should be stripped @@ -136,7 +136,7 @@ def test_username_without_at(self): mock_cache.get_user_by_username.side_effect = Exception("User not found") mock_deps.simple_user_cache = mock_cache mock_get_deps.return_value = mock_deps - + username = "test_user" result = get_user_record(username) assert result.username == username diff --git a/uv.lock b/uv.lock index e61cef3..74c2c44 100644 --- a/uv.lock +++ b/uv.lock @@ -430,13 +430,13 @@ extras = [ { name = "calmlib" }, { name = "litellm" }, { name = "mistune" }, - { name = "motor" }, { name = "pymongo" }, { name = "pyrogram" }, { name = "telethon" }, ] test = [ { name = "isort" }, + { name = "lizard" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -474,13 +474,13 @@ extras = [ { name = "calmlib", git = "https://github.com/calmmage/calmlib.git?rev=main" }, { name = "litellm", specifier = ">=1.63,<2.0" }, { name = "mistune", specifier = ">=3.1.3,<4.0.0" }, - { name = "motor", specifier = ">=3.6.0,<4.0.0" }, { name = "pymongo", specifier = ">=4.9" }, { name = "pyrogram", git = "https://github.com/KurimuzonAkuma/pyrogram.git?rev=master" }, { name = "telethon", specifier = ">=1.38.1,<2.0.0" }, ] test = [ { name = "isort", specifier = ">=6.0.0,<7.0.0" }, + { name = "lizard", specifier = ">=1.21.2" }, { name = "pyright", specifier = ">=1.1.401,<2.0.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=1.0.0,<2.0.0" }, @@ -1068,11 +1068,11 @@ wheels = [ [[package]] name = "fsspec" -version = "2026.2.0" +version = "2026.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, ] [[package]] @@ -1089,11 +1089,11 @@ wheels = [ [[package]] name = "griffelib" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383, upload-time = "2026-03-23T21:05:25.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377, upload-time = "2026-03-23T21:04:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -1250,7 +1250,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.11.0" +version = "9.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1264,9 +1264,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, ] [[package]] @@ -1463,11 +1463,11 @@ wheels = [ [[package]] name = "json5" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, ] [[package]] @@ -1766,6 +1766,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" }, ] +[[package]] +name = "lizard" +version = "1.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathspec" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/d5/7af5493043af4f10ad52b46b6271f0d3ba3d04500fdc835f27bfd606604d/lizard-1.21.2.tar.gz", hash = "sha256:bb45c354cd30bf9081bb81e2b22d26774f5974937c6a742903180d4c1bbb7602", size = 90385, upload-time = "2026-03-02T07:11:31.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/5b/622b5e2577aacbe20a002e71b3ac9c9a4428cf3c312d648c4ec6950750b8/lizard-1.21.2-py2.py3-none-any.whl", hash = "sha256:d628a63fe0ad1ccff8e8f648e8dc9621f3a85ff754106dcc32b62cd0fc877802", size = 98235, upload-time = "2026-03-02T07:11:28.523Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -2039,18 +2052,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, ] -[[package]] -name = "motor" -version = "3.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pymongo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, -] - [[package]] name = "multidict" version = "6.7.1" @@ -2809,15 +2810,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/88/815e53084c5079a59df912825a279f41dd2e0df82281770eadc732f5352c/python_discovery-1.2.1.tar.gz", hash = "sha256:180c4d114bff1c32462537eac5d6a332b768242b76b69c0259c7d14b1b680c9e", size = 58457, upload-time = "2026-03-26T22:30:44.496Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl", hash = "sha256:b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502", size = 31674, upload-time = "2026-03-26T22:30:43.396Z" }, ] [[package]]