refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
@@ -0,0 +1,102 @@
import logging
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, Update, User
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import message_log_dal, user_dal
class ActionLoggerMiddleware(BaseMiddleware):
def __init__(self, settings: Settings):
super().__init__()
self.settings = settings
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
result = await handler(event, data)
session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
user_id: Optional[int] = None
telegram_username: Optional[str] = None
telegram_first_name: Optional[str] = None
content: Optional[str] = None
is_admin_event_flag: bool = False
target_user_id_for_log: Optional[int] = None
if event_user:
user_id = event_user.id
telegram_username = event_user.username
telegram_first_name = event_user.first_name
if user_id in self.settings.ADMIN_IDS:
is_admin_event_flag = True
if is_admin_event_flag and not self.settings.LOG_ADMIN_ACTIONS:
return result
raw_update_snippet = None
try:
raw_update_snippet = event.model_dump_json(exclude_none=True, indent=None)[:1000]
except AttributeError:
raw_update_snippet = str(event)[:1000]
except Exception:
raw_update_snippet = str(event)[:1000]
current_event_type = event.event_type
if event.message:
msg: Message = event.message
if msg.text:
content = msg.text
if msg.text.startswith("/"):
current_event_type = f"command:{msg.text.split()[0]}"
else:
content = f"[{msg.content_type or 'unknown_content_type'}]"
current_event_type = f"message:{msg.content_type or 'unknown'}"
elif event.callback_query:
cb: CallbackQuery = event.callback_query
content = cb.data
action_part = cb.data.split(":")[0] if cb.data and ":" in cb.data else cb.data
current_event_type = f"callback:{action_part}"
if user_id or current_event_type not in ["update"]:
log_user_id_for_db = user_id
if user_id:
user_exists = await user_dal.get_user_by_id(session, user_id)
if not user_exists:
logging.warning(
f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." # noqa: E501
)
log_user_id_for_db = None
log_payload = {
"user_id": log_user_id_for_db,
"telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"event_type": current_event_type,
"content": content[:1000] if content else "N/A",
"raw_update_preview": raw_update_snippet,
"is_admin_event": is_admin_event_flag,
"target_user_id": target_user_id_for_log,
"timestamp": datetime.now(timezone.utc),
}
try:
await message_log_dal.create_message_log_no_commit(session, log_payload)
except Exception as e_log:
logging.error(
f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", # noqa: E501
exc_info=True,
)
return result
@@ -0,0 +1,121 @@
import logging
from typing import Any, Awaitable, Callable, Dict, Optional, Union
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import (
AiogramError,
TelegramAPIError,
TelegramForbiddenError,
)
from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message, Update, User
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
from ..keyboards.inline.user_keyboards import get_user_banned_keyboard
from .i18n import JsonI18n
class BanCheckMiddleware(BaseMiddleware):
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
super().__init__()
self.settings = settings
self.i18n_main_instance = i18n_instance
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
bot_instance: Bot = data["bot"]
if not event_user:
return await handler(event, data)
if event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
try:
db_user_model = await user_dal.get_user_by_id(session, event_user.id)
except Exception as e_db:
logging.error(
f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}", exc_info=True
)
return await handler(event, data)
if db_user_model and db_user_model.is_banned:
logging.info(
f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." # noqa: E501
)
i18n_data_from_event = data.get("i18n_data", {})
current_lang = i18n_data_from_event.get(
"current_language", self.settings.DEFAULT_LANGUAGE
)
i18n_to_use: Optional[JsonI18n] = i18n_data_from_event.get(
"i18n_instance", self.i18n_main_instance
)
ban_message_text = "You are banned. Please contact support."
keyboard: Optional[InlineKeyboardMarkup] = None
if i18n_to_use:
_ = lambda k, **kw: i18n_to_use.gettext(current_lang, k, **kw)
ban_message_text = _("user_is_banned")
keyboard = get_user_banned_keyboard(
self.settings.SUPPORT_LINK, current_lang, i18n_to_use
)
elif self.settings.SUPPORT_LINK:
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
builder.button(text="Support", url=self.settings.SUPPORT_LINK)
keyboard = builder.as_markup()
actual_event_object: Optional[Union[Message, CallbackQuery]] = None
if event.message:
actual_event_object = event.message
elif event.callback_query:
actual_event_object = event.callback_query
try:
if isinstance(actual_event_object, Message):
await actual_event_object.answer(ban_message_text, reply_markup=keyboard)
elif isinstance(actual_event_object, CallbackQuery):
await actual_event_object.answer(ban_message_text, show_alert=True)
if actual_event_object.message:
try:
await actual_event_object.message.edit_text(
ban_message_text, reply_markup=keyboard
)
except (TelegramAPIError, AiogramError):
await bot_instance.send_message(
actual_event_object.from_user.id,
ban_message_text,
reply_markup=keyboard,
)
else:
await bot_instance.send_message(
actual_event_object.from_user.id,
ban_message_text,
reply_markup=keyboard,
)
else:
await bot_instance.send_message(
event_user.id, ban_message_text, reply_markup=keyboard
)
logging.info(f"Ban notification sent to user {event_user.id}.")
except TelegramForbiddenError:
logging.warning(f"BanCheck: Bot is blocked by user {event_user.id}.")
except Exception as e_send:
logging.error(
f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", # noqa: E501
exc_info=True,
)
return
return await handler(event, data)
@@ -0,0 +1,140 @@
import logging
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import (
CallbackQuery,
Message,
Update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from db.dal import user_dal
class ChannelSubscriptionMiddleware(BaseMiddleware):
"""
Blocks access to handlers for users who have not yet passed the required channel subscription check.
The /start command is allowed through so that the handler can re-run the verification.
""" # noqa: E501
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
super().__init__()
self.settings = settings
self.i18n_main_instance = i18n_instance
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
if not required_channel_id:
return await handler(event, data)
event_user = data.get("event_from_user")
if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
callback_query = event.callback_query
if (
callback_query
and callback_query.data
and callback_query.data == "channel_subscription:verify"
):
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message
if message_object and message_object.text and message_object.text.startswith("/start"):
return await handler(event, data)
session: AsyncSession = data["session"]
try:
db_user = await user_dal.get_user_by_id(session, event_user.id)
except Exception as db_error:
logging.error(
"ChannelSubscriptionMiddleware: failed to fetch user %s: %s",
event_user.id,
db_error,
exc_info=True,
)
return await handler(event, data)
if not db_user:
return await handler(event, data)
if (
db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for == required_channel_id
):
return await handler(event, data)
i18n_payload: Dict[str, Any] = data.get("i18n_data", {})
current_lang: str = i18n_payload.get("current_language", self.settings.DEFAULT_LANGUAGE)
i18n_instance: Optional[JsonI18n] = i18n_payload.get(
"i18n_instance", self.i18n_main_instance
)
def translate(key: str) -> str:
if i18n_instance:
return i18n_instance.gettext(current_lang, key)
return key
keyboard = (
get_channel_subscription_keyboard(
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
if i18n_instance
else None
)
prompt_text = translate("channel_subscription_required")
if event.callback_query:
await self._handle_callback(event.callback_query, prompt_text, keyboard, data)
return
if message_object:
await message_object.answer(prompt_text, reply_markup=keyboard)
else:
bot_instance = data["bot"]
await bot_instance.send_message(
chat_id=event_user.id,
text=prompt_text,
reply_markup=keyboard,
)
return
async def _handle_callback(
self,
callback: CallbackQuery,
prompt_text: str,
keyboard,
data: Dict[str, Any],
) -> None:
try:
await callback.answer(prompt_text, show_alert=True)
except Exception:
pass
if callback.message:
try:
await callback.message.answer(prompt_text, reply_markup=keyboard)
except Exception as send_error:
logging.error(
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", # noqa: E501
callback.message.chat.id,
send_error,
exc_info=True,
)
else:
bot_instance = data["bot"]
await bot_instance.send_message(
chat_id=callback.from_user.id,
text=prompt_text,
reply_markup=keyboard,
)
+34
View File
@@ -0,0 +1,34 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Update
from sqlalchemy.orm import sessionmaker
class DBSessionMiddleware(BaseMiddleware):
def __init__(self, async_session_factory: sessionmaker):
super().__init__()
self.async_session_factory = async_session_factory
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if self.async_session_factory is None:
logging.critical("DBSessionMiddleware: async_session_factory is None!")
raise RuntimeError("async_session_factory not provided to DBSessionMiddleware")
async with self.async_session_factory() as session:
data["session"] = session
try:
result = await handler(event, data)
await session.commit()
return result
except Exception:
await session.rollback()
logging.error("DBSessionMiddleware: Exception caused rollback.", exc_info=True)
raise
+161
View File
@@ -0,0 +1,161 @@
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import Update, User
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
class JsonI18n:
def __init__(self, path: str, default: str = "en", domain: str = "bot"):
self.domain = domain
self.path = path
self.default_lang = default
self.locales_data: Dict[str, Dict[str, str]] = {}
self._load_locales()
logging.info(
f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501
)
def _load_locales(self):
if not os.path.isdir(self.path):
logging.error(f"Locales path not found or not a directory: {self.path}")
return
for item in os.listdir(self.path):
if item.endswith(".json"):
lang_code = item.split(".")[0]
file_path = os.path.join(self.path, item)
try:
with open(file_path, "r", encoding="utf-8") as f:
self.locales_data[lang_code] = json.load(f)
except json.JSONDecodeError as e_json_load:
logging.error(
f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501
)
except Exception as e_load:
logging.error(
f"Error loading locale {lang_code} from {file_path}: {e_load}",
exc_info=True,
)
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
# Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data:
effective_lang_code = lang_code
elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang
elif "en" in self.locales_data:
effective_lang_code = "en"
else:
effective_lang_code = lang_code or self.default_lang
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
# Try explicit fallback to English if available
fallback_data = self.locales_data.get("en")
if fallback_data is not None:
text = fallback_data.get(key)
if text is not None:
try:
return text.format(**kwargs) if kwargs else text
except Exception:
return text
logging.warning(
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." # noqa: E501
)
return key.format(**kwargs) if kwargs else key
text = lang_data.get(key)
if text is None:
if effective_lang_code != self.default_lang:
default_lang_data = self.locales_data.get(self.default_lang, {})
text = default_lang_data.get(key)
if text is None:
logging.warning(
f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." # noqa: E501
)
return key.format(**kwargs) if kwargs else key
try:
return text.format(**kwargs) if kwargs else text
except KeyError as e_format:
logging.warning(
f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" # noqa: E501
)
return text
except Exception as e_general_format:
logging.error(
f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", # noqa: E501
exc_info=True,
)
return text
_i18n_instance_singleton: Optional[JsonI18n] = None
def get_i18n_instance(path: str = "locales", default: str = "en", domain: str = "bot") -> JsonI18n:
global _i18n_instance_singleton
if _i18n_instance_singleton is None:
if not os.path.exists(path) or not os.path.isdir(path):
logging.error(
f"CRITICAL: Locales directory '{path}' not found. i18n will not work correctly."
)
_i18n_instance_singleton = JsonI18n(path=path, default=default, domain=domain)
else:
_i18n_instance_singleton = JsonI18n(path=path, default=default, domain=domain)
return _i18n_instance_singleton
class I18nMiddleware(BaseMiddleware):
def __init__(self, i18n: JsonI18n, settings: Settings):
super().__init__()
self.i18n = i18n
self.settings = settings
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
session: AsyncSession = data["session"]
event_user: Optional[User] = data.get("event_from_user")
current_language = self.i18n.default_lang
if event_user:
try:
user_db_model = await user_dal.get_user_by_id(session, event_user.id)
if (
user_db_model
and user_db_model.language_code
and user_db_model.language_code in self.i18n.locales_data
):
current_language = user_db_model.language_code
elif event_user.language_code:
lang_prefix = event_user.language_code.split("-")[0].lower()
if lang_prefix in self.i18n.locales_data:
current_language = lang_prefix
elif event_user.language_code.lower() in self.i18n.locales_data:
current_language = event_user.language_code.lower()
except Exception as e_db_lang:
logging.error(
f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", # noqa: E501
exc_info=True,
)
if event_user.language_code:
lang_prefix = event_user.language_code.split("-")[0].lower()
if lang_prefix in self.i18n.locales_data:
current_language = lang_prefix
elif event_user.language_code.lower() in self.i18n.locales_data:
current_language = event_user.language_code.lower()
data["i18n_data"] = {"i18n_instance": self.i18n, "current_language": current_language}
return await handler(event, data)
+83
View File
@@ -0,0 +1,83 @@
import logging
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import Update
from aiogram.types import User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
from db.dal import user_dal
class ProfileSyncMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
session: AsyncSession = data.get("session")
tg_user: Optional[TgUser] = data.get("event_from_user")
if session and tg_user:
try:
db_user = await user_dal.get_user_by_telegram_id(session, tg_user.id)
if not db_user:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user:
update_payload: Dict[str, Any] = {}
sanitized_username = sanitize_username(tg_user.username)
sanitized_first_name = sanitize_display_name(tg_user.first_name)
sanitized_last_name = sanitize_display_name(tg_user.last_name)
if db_user.telegram_id != tg_user.id:
update_payload["telegram_id"] = tg_user.id
if db_user.username != sanitized_username:
update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name:
update_payload["first_name"] = sanitized_first_name
if db_user.last_name != sanitized_last_name:
update_payload["last_name"] = sanitized_last_name
if update_payload:
await user_dal.update_user(session, db_user.user_id, update_payload)
logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
)
# Also update description on panel if linked
try:
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join(
[
db_user.email or "",
username_for_display(tg_user.username, with_at=False)
if sanitized_username is not None
else "",
sanitized_first_name or "",
sanitized_last_name or "",
]
).strip()
panel_payload = {
"description": description_text,
"telegramId": tg_user.id,
}
if db_user.email:
panel_payload["email"] = db_user.email
await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
panel_payload,
)
except Exception as e_upd_desc:
logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
)
except Exception as e:
logging.error(
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501
exc_info=True,
)
return await handler(event, data)