diff --git a/src/config.py b/src/config.py index dd17f53..2d14d5c 100644 --- a/src/config.py +++ b/src/config.py @@ -11,3 +11,5 @@ settings = Settings() logger = logging.getLogger("saintbot") + +RATE_LIMIT_DELAY = 0.05 diff --git a/src/db/db.py b/src/db/db.py index a89f9a7..426bc4c 100644 --- a/src/db/db.py +++ b/src/db/db.py @@ -437,8 +437,11 @@ async def get_stats(): started_rooms = started_rooms_raw[0] if started_rooms_raw else 0 return total_users, participants, rooms_total, started_rooms - return True +def get_all_users(): + raw_users = cur.execute("SELECT DISTINCT tg_id FROM users").fetchall() + for user_id in raw_users: + yield user_id[0] if __name__ == "__main__": asyncio.run(start_db()) diff --git a/src/handlers/admin_command.py b/src/handlers/admin_command.py index fdb913b..e1a9d8c 100644 --- a/src/handlers/admin_command.py +++ b/src/handlers/admin_command.py @@ -8,6 +8,7 @@ from src.config import settings, logger from src.db import db +from src.utilities.notification import broadcast router = Router(name=__name__) @@ -60,3 +61,9 @@ async def status(msg: Message): f"Комнат всего: {rooms_total}\n" f"Комнат с запущенным ивентом: {started_rooms}" ) + +@router.message(Command("test")) +async def test(msg: Message): + if not _is_admin(msg): + return + await broadcast(msg.bot,db.get_all_users(),"test",delay=0.1) diff --git a/src/handlers/room_admin.py b/src/handlers/room_admin.py index f8bd47d..7d24720 100644 --- a/src/handlers/room_admin.py +++ b/src/handlers/room_admin.py @@ -1,25 +1,18 @@ -import asyncio -import base64 - from aiogram import F, Router -from aiogram.exceptions import TelegramRetryAfter -from aiogram.types import Message, CallbackQuery -from aiogram.filters import Command from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery -from src.config import logger +from src.config import RATE_LIMIT_DELAY from src.db import db from src.handlers.common import EFFECT_IDS from src.keyboards import common_kb, room_admin_kb from src.states.states import CallbackFactory, RemoveCallbackFactory -from src.texts import messages, text +from src.texts import messages from src.texts.callback_actions import CallbackAction +from src.utilities import notification from src.utilities import utils -RATE_LIMIT_DELAY = 0.05 - - async def get_room_name(room_iden): return f"{room_iden[:-4]}:{room_iden[-4:]}" @@ -29,7 +22,7 @@ async def get_room_name(room_iden): @router.callback_query(CallbackFactory.filter(F.action == CallbackAction.DELETE_ROOM)) async def delete_room( - call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext ): isMemberOrAdmin = await db.check_room_and_member( call.from_user.id, callback_data.room_iden @@ -53,7 +46,7 @@ async def delete_room( CallbackFactory.filter(F.action == CallbackAction.CONFIRM_DELETE) ) async def delete_room( - call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext ): isMemberOrAdmin = await db.check_room_and_member( call.from_user.id, callback_data.room_iden @@ -75,7 +68,7 @@ async def delete_room( @router.callback_query(CallbackFactory.filter(F.action == CallbackAction.REMOVE_MEMBER)) async def remove_member( - call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext ): members, *_ = await db.get_members_list(callback_data.room_iden) @@ -87,7 +80,7 @@ async def remove_member( RemoveCallbackFactory.filter(F.action == CallbackAction.REMOVE_MEMBER) ) async def removing_member( - call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext ): isMemberOrAdmin = await db.check_room_and_member( callback_data.user_id, callback_data.room_iden @@ -117,7 +110,7 @@ async def removing_member( @router.callback_query(CallbackFactory.filter(F.action == CallbackAction.START_EVENT)) async def start_event( - call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext ): isMemberOrAdmin = await db.check_room_and_member( call.from_user.id, callback_data.room_iden @@ -158,36 +151,54 @@ async def start_event( messages.event_started(room_name), reply_markup=await room_admin_kb.room_admin_kb(callback_data.room_iden), ) + await notification.broadcast(call.bot, members, + text=messages.event_started_notify(room_name), + reply_markup=await common_kb.ok_kb("None", asAdmin=False), + delay=RATE_LIMIT_DELAY, + message_effect_id=EFFECT_IDS["🎉"] + ) + await call.answer("Уведомление о начале события отправлено") + + +@router.callback_query(CallbackFactory.filter(F.action == CallbackAction.REMIND_ABOUT_EVENT)) +async def remind_about_event( + call: CallbackQuery, callback_data: CallbackFactory, state: FSMContext +): + isMemberOrAdmin = await db.check_room_and_member( + call.from_user.id, callback_data.room_iden + ) + room_name = await get_room_name(callback_data.room_iden) + + if isMemberOrAdmin == "ROOM NOT EXISTS": + await call.message.edit_text( + messages.room_not_exists(room_name), + reply_markup=await common_kb.ok_kb("None", asAdmin=False), + ) + return + + if isMemberOrAdmin == "MEMBER NOT EXISTS": + await call.message.edit_text( + messages.not_a_member(room_name), + reply_markup=await common_kb.ok_kb("None", asAdmin=False), + ) + return + + status = await db.isStarted(callback_data.room_iden) + if not status: + await call.message.edit_text( + messages.event_not_started(room_name), + reply_markup=await room_admin_kb.room_admin_kb(callback_data.room_iden), + ) + return + + members, admin, isAdminMember = await db.get_members_list(callback_data.room_iden) + if isAdminMember: + members.append(admin) - for user_id in members: - try: - await call.bot.send_message( - chat_id=user_id, - text=messages.event_started_notify(room_name), - reply_markup=await common_kb.ok_kb("None", asAdmin=False), - message_effect_id=EFFECT_IDS["🎉"], - ) - except TelegramRetryAfter as e: - await asyncio.sleep(e.retry_after) - try: - await call.bot.send_message( - chat_id=user_id, - text=messages.event_started_notify(room_name), - reply_markup=await common_kb.ok_kb("None", asAdmin=False), - message_effect_id=EFFECT_IDS["🎉"], - ) - except Exception as retry_error: - logger.warning( - "Failed to notify user %s in room %s after retry: %s", - user_id, - callback_data.room_iden, - retry_error, - ) - except Exception as e: - logger.warning( - "Failed to notify user %s in room %s: %s", - user_id, - callback_data.room_iden, - e, - ) - await asyncio.sleep(RATE_LIMIT_DELAY) + members = [member[0] for member in members] + await notification.broadcast(call.bot, members, + text=messages.remind_notify(room_name), + reply_markup=await common_kb.ok_kb("None", asAdmin=False), + delay=RATE_LIMIT_DELAY, + ) + await call.answer("Напоминание отправлено") diff --git a/src/keyboards/room_admin_kb.py b/src/keyboards/room_admin_kb.py index e5e643e..a98dce7 100644 --- a/src/keyboards/room_admin_kb.py +++ b/src/keyboards/room_admin_kb.py @@ -25,6 +25,16 @@ async def room_admin_kb(room_iden): ).pack(), ) ], + [ + InlineKeyboardButton( + text="🔔Напомнить всем о мероприятии", + callback_data=states.CallbackFactory( + action=CallbackAction.REMIND_ABOUT_EVENT, + room_iden=room_iden, + asAdmin=True, + ).pack(), + ) + ], [ InlineKeyboardButton( text="✏️Изменить настройки комнаты", diff --git a/src/texts/callback_actions.py b/src/texts/callback_actions.py index f3248cb..7f8c995 100644 --- a/src/texts/callback_actions.py +++ b/src/texts/callback_actions.py @@ -34,3 +34,5 @@ class CallbackAction: SET_ROOM_TYPE_CENTRAL = "set_room_type_central" SET_ROOM_TYPE_THROW = "set_room_type_throw" SHOW_ROOM_SETTINGS = "show_room_settings" + + REMIND_ABOUT_EVENT = "remind_about_event" diff --git a/src/texts/messages.py b/src/texts/messages.py index ab28e5e..f999ae8 100644 --- a/src/texts/messages.py +++ b/src/texts/messages.py @@ -141,6 +141,13 @@ def event_started(room_name: str) -> str: def event_started_notify(room_name: str) -> str: return f"Событие в комнате {room_name} началось\nПроверте кому вы дарите" +def remind_notify(room_name: str) -> str: + return ( + f"🔔 Напоминание: событие в комнате {room_name} уже идёт.\n" + "Зайдите в комнату и нажмите «🎁Кому я дарю», чтобы проверить получателя, " + "и подготовьте подарок." + ) + def invitation_text(room_name: str) -> str: return ( diff --git a/src/utilities/notification.py b/src/utilities/notification.py new file mode 100644 index 0000000..934d51b --- /dev/null +++ b/src/utilities/notification.py @@ -0,0 +1,43 @@ +import asyncio +from functools import wraps +from typing import AsyncGenerator + +from aiogram.exceptions import TelegramRetryAfter + +from src.config import logger + + +def safe_send(max_retries: int = 1): + def deco(fn): + @wraps(fn) + async def wrapper(*args, **kwargs): + attempt = 0 + while True: + try: + return await fn(*args, **kwargs) + except TelegramRetryAfter as e: + attempt += 1 + if attempt > max_retries: + logger.warning("RetryAfter maxed: %s", e) + return None + await asyncio.sleep(e.retry_after) + except Exception as e: + logger.warning("Send failed: %s", e) + return None + + return wrapper + + return deco + +@safe_send(max_retries=1) +async def notify_user(bot, user_id: int, text: str, reply_markup=None, message_effect_id=None): + return await bot.send_message( + chat_id=user_id, + text=text, + reply_markup=reply_markup, + message_effect_id=message_effect_id, + ) +async def broadcast(bot, user_ids: list[int], text: str, reply_markup=None, message_effect_id=None, delay: float = 0.05): + for uid in user_ids: + await notify_user(bot, uid, text, reply_markup=reply_markup, message_effect_id=message_effect_id) + await asyncio.sleep(delay) \ No newline at end of file