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]]