diff --git a/olgram/commands/bot_actions.py b/olgram/commands/bot_actions.py index 40f8f10..ed3d114 100644 --- a/olgram/commands/bot_actions.py +++ b/olgram/commands/bot_actions.py @@ -1,10 +1,12 @@ """ Здесь работа с конкретным ботом """ +from asyncio import sleep from aiogram import types -from aiogram.utils.exceptions import TelegramAPIError, Unauthorized +from aiogram.utils.exceptions import TelegramAPIError, Unauthorized, BotKicked, BotBlocked from aiogram import Bot as AioBot from olgram.models.models import Bot +from olgram.utils.mix import send_stored_message from server.server import unregister_token from locales.locale import _ @@ -111,3 +113,22 @@ async def antiflood(bot: Bot, call: types.CallbackQuery): async def mailing(bot: Bot, call: types.CallbackQuery): bot.enable_mailing = not bot.enable_mailing await bot.save(update_fields=["enable_mailing"]) + + +async def go_mailing(bot: Bot, context) -> int: + users = await bot.mailing_users + a_bot = AioBot(bot.decrypted_token()) + + sended = 0 + + for user in users: + try: + await sleep(0.2) + await send_stored_message(context, a_bot, user.telegram_id) + sended += 1 + except TelegramAPIError: + # TODO: + # delete user + # check error source, check bot, break if bot deleted + continue + return sended diff --git a/olgram/commands/menu.py b/olgram/commands/menu.py index 458542e..72200f5 100644 --- a/olgram/commands/menu.py +++ b/olgram/commands/menu.py @@ -4,14 +4,14 @@ from aiogram import types, Bot as AioBot from olgram.models.models import Bot, User, DefaultAnswer from aiogram.dispatcher import FSMContext from aiogram.utils.callback_data import CallbackData +from datetime import datetime, timedelta from textwrap import dedent -from olgram.utils.mix import edit_or_create, button_text_limit, wrap +from olgram.utils.mix import edit_or_create, button_text_limit, wrap, send_stored_message from olgram.commands import bot_actions from locales.locale import _ import typing as ty - menu_callback = CallbackData('menu', 'level', 'bot_id', 'operation', 'chat') empty = "0" @@ -127,6 +127,12 @@ async def send_bot_menu(bot: Bot, call: types.CallbackQuery): callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="settings", chat=empty)) ) + if bot.enable_mailing: + keyboard.insert( + types.InlineKeyboardButton(text=_("Рассылка"), + callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="go_mailing", + chat=empty)) + ) await edit_or_create(call, dedent(_(""" Управление ботом @{0}. @@ -245,6 +251,30 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML") +async def send_bot_mailing_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None, + chat_id: ty.Optional[int] = None): + if call: + await call.answer() + keyboard = types.InlineKeyboardMarkup(row_width=1) + keyboard.insert( + types.InlineKeyboardButton(text=_("<< Отменить рассылку"), + callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty)) + ) + + text = dedent(_(""" + Напишите сообщение, которое нужно разослать всем подписчикам вашего бота @{0}. + У сообщения будет до {1} получателей. + Учтите, что + 1. Рассылается только одно сообщение за раз (в т.ч. только одна картинка) + 2. Когда рассылка запущена, её нельзя отменить + """)) + text = text.format(bot.name, len(await bot.mailing_users)) + if call: + await edit_or_create(call, text, keyboard, parse_mode="HTML") + else: + await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML") + + async def send_bot_statistic_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None, chat_id: ty.Optional[int] = None): if call: @@ -359,6 +389,54 @@ async def start_text_received(message: types.Message, state: FSMContext): await send_bot_text_menu(bot, chat_id=message.chat.id) +@dp.message_handler(state="wait_mailing_text", + content_types=[types.ContentType.TEXT, + types.ContentType.LOCATION, + types.ContentType.DOCUMENT, + types.ContentType.PHOTO, + types.ContentType.AUDIO, + types.ContentType.VIDEO]) # TODO: not command +async def mailing_text_received(message: types.Message, state: FSMContext): + async with state.proxy() as proxy: + bot_id = proxy["bot_id"] + proxy["mailing_content_type"] = message.content_type + + if message.content_type == types.ContentType.TEXT: + proxy["mailing_text"] = message.html_text + elif message.content_type == types.ContentType.LOCATION: + proxy["mailing_location"] = message.location + elif message.content_type == types.ContentType.PHOTO: + proxy["mailing_photo"] = message.photo[0].file_id + proxy["mailing_caption"] = message.caption + elif message.content_type == types.ContentType.DOCUMENT: + proxy["mailing_document"] = message.document.file_id + proxy["mailing_caption"] = message.caption + elif message.content_type == types.ContentType.AUDIO: + proxy["mailing_audio"] = message.audio.file_id + proxy["mailing_caption"] = message.caption + elif message.content_type == types.ContentType.VIDEO: + proxy["mailing_video"] = message.video.file_id + proxy["mailing_video"] = message.caption + + _message_id = await send_stored_message(proxy, AioBot.get_current(), message.chat.id) + + keyboard = types.InlineKeyboardMarkup(row_width=2) + keyboard.insert( + types.InlineKeyboardButton(text=_("<< Нет, отменить"), # TODO: don't move menu back + callback_data=menu_callback.new(level=1, bot_id=bot_id, operation=empty, + chat=empty)) + ) + keyboard.insert( + types.InlineKeyboardButton(text=_("Да, начать рассылку"), + callback_data=menu_callback.new(level=2, bot_id=bot_id, operation="go_go_mailing", + chat=empty)) + ) + + await AioBot.get_current().send_message(message.chat.id, reply_to_message_id=_message_id.message_id, + text="Вы уверены, что хотите разослать это сообщение всем пользователям?", + reply_markup=keyboard) + + @dp.message_handler(state="wait_second_text", content_types="text", regexp="^[^/].+") # Not command async def second_text_received(message: types.Message, state: FSMContext): async with state.proxy() as proxy: @@ -430,6 +508,20 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon return await send_bot_statistic_menu(bot, call) if operation == "settings": return await send_bot_settings_menu(bot, call) + if operation in ("go_mailing", "go_go_mailing"): + if bot.last_mailing_at and bot.last_mailing_at >= datetime.now() - timedelta(minutes=5): + return await call.answer(_("Рассылка была совсем недавно, подождите немного"), show_alert=True) + if operation == "go_mailing": + await state.set_state("wait_mailing_text") + async with state.proxy() as proxy: + proxy["bot_id"] = bot.id + return await send_bot_mailing_menu(bot, call) + if operation == "go_go_mailing": + async with state.proxy() as proxy: + mailing_data = dict(proxy) + await state.reset_state() # TODO: double-click protection + await call.answer(_("Рассылка запущена")) + await bot_actions.go_mailing(bot, mailing_data) if operation == "text": await state.set_state("wait_start_text") async with state.proxy() as proxy: diff --git a/olgram/utils/mix.py b/olgram/utils/mix.py index a5aa382..c304ec2 100644 --- a/olgram/utils/mix.py +++ b/olgram/utils/mix.py @@ -1,4 +1,5 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup +from aiogram import types, Bot as AioBot from aiogram.utils.exceptions import TelegramAPIError from typing import Optional @@ -30,3 +31,23 @@ def wrap(data: str, max_len: int) -> str: def button_text_limit(data: str) -> str: return wrap(data, 30) + + +async def send_stored_message(storage: dict, bot: AioBot, chat_id: int): + content_type = storage["mailing_content_type"] + if content_type == types.ContentType.TEXT: + return await bot.send_message(chat_id, storage["mailing_text"], parse_mode="HTML") + if content_type == types.ContentType.LOCATION: + return await bot.send_location(chat_id, storage["mailing_location"][0], storage["mailing_location"][1]) + if content_type == types.ContentType.AUDIO: + return await bot.send_audio(chat_id, audio=storage["mailing_audio"], caption=storage.get("mailing_caption")) + if content_type == types.ContentType.DOCUMENT: + return await bot.send_document(chat_id, document=storage["mailing_document"], + caption=storage.get("mailing_caption")) + if content_type == types.ContentType.PHOTO: + return await bot.send_photo(chat_id, photo=storage["mailing_photo"], + caption=storage.get("mailing_caption")) + if content_type == types.ContentType.VIDEO: + return await bot.send_video(chat_id, video=storage["mailing_video"], + caption=storage.get("mailing_caption")) + raise NotImplementedError("Mailing, unknown content type") \ No newline at end of file