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
+402
View File
@@ -0,0 +1,402 @@
import asyncio
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
get_back_to_admin_panel_keyboard,
get_broadcast_confirmation_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.states.admin_states import AdminStates
from bot.utils import (
MessageContent,
get_message_content,
send_message_by_type,
send_message_via_queue,
)
from bot.utils.message_queue import get_queue_manager
from config.settings import Settings
from db.dal import message_log_dal, user_dal
router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in broadcast_message_prompt_handler")
await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _("admin_broadcast_enter_message")
if callback.message:
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
except Exception as e:
logging.warning(f"Could not edit message for broadcast prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message)
async def process_broadcast_message_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
logging.error("i18n missing in process_broadcast_message_handler")
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Определяем тип содержимого и сохраняем данные в state
entities = message.entities or message.caption_entities or []
content = get_message_content(message)
# Если нет ни текста, ни медиа — ошибка
if not content.text and not content.file_id:
await message.answer(_("admin_broadcast_error_no_message"))
return
# Сохраняем данные для рассылки
await state.update_data(
broadcast_text=content.text,
broadcast_entities=entities,
broadcast_content_type=content.content_type,
broadcast_file_id=content.file_id,
broadcast_target="all",
)
# Отправляем превью-копию того, что будет разослано
try:
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
else:
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
except TelegramBadRequest as e:
await message.answer(
_(
"admin_broadcast_invalid_html",
error=str(e),
)
)
return
# Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
await message.answer(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"),
)
await state.set_state(AdminStates.confirming_broadcast)
@router.callback_query(
F.data.startswith("broadcast_target:"),
AdminStates.confirming_broadcast,
)
async def change_broadcast_target_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error updating selection.", show_alert=True)
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
await callback.answer("Unknown target.", show_alert=True)
return
await state.update_data(broadcast_target=new_target)
await state.get_data()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
try:
await callback.message.edit_text(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target=new_target),
)
except Exception:
pass
await callback.answer()
@router.callback_query(F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear()
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(
F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast,
)
async def confirm_broadcast_callback_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
bot: Bot,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1]
user_fsm_data = await state.get_data()
if action == "send":
# Создаем объект контента из сохраненных данных
content = MessageContent(
content_type=user_fsm_data.get("broadcast_content_type", "text"),
file_id=user_fsm_data.get("broadcast_file_id"),
text=user_fsm_data.get("broadcast_text"),
)
entities = user_fsm_data.get("broadcast_entities", [])
if not content.text and content.content_type == "text":
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear()
await callback.answer(_("admin_broadcast_error_no_message_alert"), show_alert=True)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
await callback.answer()
target = user_fsm_data.get("broadcast_target", "all")
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent_count = 0
failed_count = 0
admin_user = callback.from_user
logging.info(
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501
)
# Get message queue manager
queue_manager = get_queue_manager()
if not queue_manager:
await callback.message.edit_text(
"❌ Ошибка: система очередей не инициализирована", reply_markup=None
)
return
# Queue all messages for sending
for uid in user_ids:
try:
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
)
else:
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
)
sent_count += 1
# Log successful queuing
await message_log_dal.create_message_log(
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_queued",
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501
"is_admin_event": True,
"target_user_id": uid,
},
)
except Exception as e:
failed_count += 1
logging.warning(f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}")
await message_log_dal.create_message_log(
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content": f"For user {uid}: {type(e).__name__} {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
try:
await session.commit()
except Exception as e_commit:
await session.rollback()
logging.error(f"Error committing broadcast logs: {e_commit}")
# Prepare queue stats presentation
queue_stats = queue_manager.get_queue_stats()
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
initial_user_failed = queue_stats.get("user_failed_messages", 0)
initial_group_failed = queue_stats.get("group_failed_messages", 0)
def build_queue_status(stats: dict) -> str:
dynamic_failed = max(
0, stats.get("user_failed_messages", 0) - initial_user_failed
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
total_failed = failed_count + dynamic_failed
return _(
"broadcast_queue_result",
sent_count=sent_count,
failed_count=total_failed,
user_queue_size=stats["user_queue_size"],
group_queue_size=stats["group_queue_size"],
)
result_message = build_queue_status(queue_stats)
status_message = await callback.message.answer(
result_message,
reply_markup=back_keyboard,
)
async def auto_update_queue_status() -> None:
"""Refresh queue stats message twice per second via message edit."""
last_text = result_message
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
max_iterations = 240
for _ in range(max_iterations):
await asyncio.sleep(0.5)
stats = queue_manager.get_queue_stats()
new_text = build_queue_status(stats)
queues_drained = (
stats["user_queue_size"] == 0
and stats["group_queue_size"] == 0
and not stats.get("user_queue_processing")
and not stats.get("group_queue_processing")
)
if new_text != last_text:
try:
await status_message.edit_text(
new_text,
reply_markup=back_keyboard,
)
last_text = new_text
except TelegramBadRequest as e:
if "message is not modified" in str(e):
last_text = new_text
else:
logging.debug("Broadcast queue auto-update stopped: %s", e)
break
except Exception as e:
logging.debug("Broadcast queue auto-update unexpected error: %s", e)
break
if queues_drained:
# Final refresh already attempted; exit loop.
break
else:
logging.debug("Broadcast queue auto-update reached time limit.")
asyncio.create_task(auto_update_queue_status())
elif action == "cancel":
await callback.message.edit_text(
_("admin_broadcast_cancelled"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.clear()