diff --git a/src/trudex/application/bot/admin_dialogs/main_menu.py b/src/trudex/application/bot/admin_dialogs/main_menu.py index 7120cc3..5eb37d4 100644 --- a/src/trudex/application/bot/admin_dialogs/main_menu.py +++ b/src/trudex/application/bot/admin_dialogs/main_menu.py @@ -1,13 +1,15 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Back, Button, Column, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.admin_dialogs.states import AdminMenuSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message @inject @@ -61,7 +63,6 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -83,6 +84,46 @@ async def on_user_input(message: Message, _widget: MessageInput, manager: Dialog await manager.switch_to(AdminMenuSG.user_detail) +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(AdminMenuSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.switch_to(AdminMenuSG.main) + + async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: await _callback.answer("Управление тестами") @@ -91,8 +132,8 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(AdminMenuSG.users_list) -async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Рассылка") +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(AdminMenuSG.broadcast_input) admin_menu_dialog = Dialog( @@ -138,4 +179,18 @@ admin_menu_dialog = Dialog( state=AdminMenuSG.user_detail, getter=get_user_detail_data, ), + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=AdminMenuSG.main), + state=AdminMenuSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=AdminMenuSG.broadcast_confirm, + ), ) diff --git a/src/trudex/application/bot/admin_dialogs/states.py b/src/trudex/application/bot/admin_dialogs/states.py index 22127fe..266e10b 100644 --- a/src/trudex/application/bot/admin_dialogs/states.py +++ b/src/trudex/application/bot/admin_dialogs/states.py @@ -6,3 +6,5 @@ class AdminMenuSG(StatesGroup): users_list = State() users_input = State() user_detail = State() + broadcast_input = State() + broadcast_confirm = State() diff --git a/src/trudex/application/bot/creator_dialogs/main_menu.py b/src/trudex/application/bot/creator_dialogs/main_menu.py index 0972908..752904d 100644 --- a/src/trudex/application/bot/creator_dialogs/main_menu.py +++ b/src/trudex/application/bot/creator_dialogs/main_menu.py @@ -4,10 +4,12 @@ from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Column, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.text import Const, Format from dishka import FromDishka +from dishka.integrations.aiogram import CONTAINER_NAME from dishka.integrations.aiogram_dialog import inject from trudex.application.bot.creator_dialogs.states import CreatorMenuSG from trudex.infrastructure.database.dao.user import UserDAO +from trudex.infrastructure.utils.broadcast import broadcast_message @inject @@ -81,7 +83,6 @@ async def on_input_mode(_callback: CallbackQuery, _button: Button, manager: Dial async def on_user_input(message: Message, _widget: MessageInput, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -108,7 +109,6 @@ async def on_make_admin_clicked(_callback: CallbackQuery, _button: Button, manag async def on_confirm_yes(_callback: CallbackQuery, _button: Button, manager: DialogManager): - from dishka.integrations.aiogram import CONTAINER_NAME container = manager.middleware_data[CONTAINER_NAME] user_dao = await container.get(UserDAO) @@ -127,6 +127,46 @@ async def on_confirm_no(_callback: CallbackQuery, _button: Button, manager: Dial await manager.switch_to(CreatorMenuSG.user_detail) +async def on_broadcast_input(message: Message, _widget: MessageInput, manager: DialogManager): + manager.dialog_data["broadcast_message_id"] = message.message_id + manager.dialog_data["broadcast_chat_id"] = message.chat.id + await manager.switch_to(CreatorMenuSG.broadcast_confirm) + + +@inject +async def on_broadcast_confirm(_callback: CallbackQuery, _button: Button, manager: DialogManager, user_dao: FromDishka[UserDAO]): + message_id = manager.dialog_data.get("broadcast_message_id") + chat_id = manager.dialog_data.get("broadcast_chat_id") + + if not message_id or not chat_id or not _callback.message: + await _callback.answer("Ошибка: сообщение не найдено") + return + + await _callback.message.answer("⏳ Рассылка началась...") + + bot = _callback.bot + if not bot: + await _callback.answer("Ошибка: бот не найден") + return + + stats = await broadcast_message(bot, message_id, chat_id, user_dao) + + stats_text = ( + f"✅ Рассылка завершена\n\n" + f"Всего пользователей: {stats.total}\n" + f"Успешно отправлено: {stats.success}\n" + f"Не удалось отправить: {stats.failed}" + ) + + await _callback.message.answer(stats_text) + await manager.done() + + +async def on_broadcast_cancel(_callback: CallbackQuery, _button: Button, manager: DialogManager): + await _callback.answer("Рассылка отменена") + await manager.switch_to(CreatorMenuSG.main) + + async def on_tests_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: await _callback.answer("Тесты") @@ -135,8 +175,8 @@ async def on_users_clicked(_callback: CallbackQuery, _button: Button, manager: D await manager.switch_to(CreatorMenuSG.users_list) -async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, _manager: DialogManager) -> None: - await _callback.answer("Рассылка") +async def on_broadcast_clicked(_callback: CallbackQuery, _button: Button, manager: DialogManager) -> None: + await manager.switch_to(CreatorMenuSG.broadcast_input) creator_menu_dialog = Dialog( @@ -195,4 +235,18 @@ creator_menu_dialog = Dialog( state=CreatorMenuSG.make_admin_confirm, getter=get_confirm_data, ), + Window( + Const("📢 Рассылка\n\nОтправьте сообщение, которое хотите разослать всем пользователям:"), + MessageInput(on_broadcast_input), + SwitchTo(Const("◀️ Отмена"), id="cancel_broadcast", state=CreatorMenuSG.main), + state=CreatorMenuSG.broadcast_input, + ), + Window( + Const("⚠️ Подтверждение рассылки\n\nВы уверены, что хотите отправить это сообщение всем пользователям?"), + Row( + Button(Const("✅ Да"), id="broadcast_confirm", on_click=on_broadcast_confirm), + Button(Const("❌ Нет"), id="broadcast_cancel", on_click=on_broadcast_cancel), + ), + state=CreatorMenuSG.broadcast_confirm, + ), ) diff --git a/src/trudex/application/bot/creator_dialogs/states.py b/src/trudex/application/bot/creator_dialogs/states.py index b8c1d05..b2ee9e3 100644 --- a/src/trudex/application/bot/creator_dialogs/states.py +++ b/src/trudex/application/bot/creator_dialogs/states.py @@ -7,3 +7,5 @@ class CreatorMenuSG(StatesGroup): users_input = State() user_detail = State() make_admin_confirm = State() + broadcast_input = State() + broadcast_confirm = State() diff --git a/src/trudex/infrastructure/utils/broadcast.py b/src/trudex/infrastructure/utils/broadcast.py new file mode 100644 index 0000000..06612aa --- /dev/null +++ b/src/trudex/infrastructure/utils/broadcast.py @@ -0,0 +1,35 @@ +import asyncio +from dataclasses import dataclass + +from aiogram import Bot +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError + +from trudex.infrastructure.database.dao.user import UserDAO + + +@dataclass +class BroadcastStats: + success: int + failed: int + total: int + + +async def broadcast_message(bot: Bot, message_id: int, chat_id: int, user_dao: UserDAO) -> BroadcastStats: + users = await user_dao.get_all() + success = 0 + failed = 0 + + for user in users: + try: + await bot.copy_message(chat_id=user.id, from_chat_id=chat_id, message_id=message_id) + success += 1 + except TelegramForbiddenError: + failed += 1 + except TelegramBadRequest: + failed += 1 + except Exception: + failed += 1 + + await asyncio.sleep(0.1) + + return BroadcastStats(success=success, failed=failed, total=len(users))