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