diff --git a/docs/source/options.rst b/docs/source/options.rst index a5df46a..5b186db 100644 --- a/docs/source/options.rst +++ b/docs/source/options.rst @@ -67,3 +67,17 @@ Olgram пересылает сообщения так, чтобы сообщен По-умолчанию поток сообщений от одного пользователя прерывается каждые 24 часа. Без этой опции поток сообщений не прерывается никогда. + + +.. _mailing: + +Рассылка +--------------- + +После включения этой опции ваш бот будет запоминать всех пользователей, которые пишут в ваш бот. +Вы сможете запустить рассылку по этим пользователям. + +.. note:: + + Включение этой опции меняет текст политики конфиденциальности вашего feedback бота (команда /security_policy) + и может отпугнуть некоторых пользователей. Не включайте эту опцию без необходимости. diff --git a/olgram/commands/bot_actions.py b/olgram/commands/bot_actions.py index cb880d7..a4e043e 100644 --- a/olgram/commands/bot_actions.py +++ b/olgram/commands/bot_actions.py @@ -128,3 +128,8 @@ async def olgram_text(bot: Bot, call: types.CallbackQuery): async def antiflood(bot: Bot, call: types.CallbackQuery): bot.enable_antiflood = not bot.enable_antiflood await bot.save(update_fields=["enable_antiflood"]) + + +async def mailing(bot: Bot, call: types.CallbackQuery): + bot.enable_mailing = not bot.enable_mailing + await bot.save(update_fields=["enable_mailing"]) diff --git a/olgram/commands/menu.py b/olgram/commands/menu.py index edcb30c..9e8e087 100644 --- a/olgram/commands/menu.py +++ b/olgram/commands/menu.py @@ -178,6 +178,11 @@ async def send_bot_settings_menu(bot: Bot, call: types.CallbackQuery): operation="always_second_message", chat=empty)) ) + keyboard.insert( + types.InlineKeyboardButton(text=_("Рассылка"), + callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="mailing", + chat=empty)) + ) keyboard.insert( types.InlineKeyboardButton(text=_("Прерывать поток"), callback_data=menu_callback.new(level=3, bot_id=bot.id, @@ -203,14 +208,16 @@ async def send_bot_settings_menu(bot: Bot, call: types.CallbackQuery): antiflood_turn = _("включен") if bot.enable_antiflood else _("выключен") enable_always_second_message = _("включён") if bot.enable_always_second_message else _("выключен") thread_interrupt = _("да") if bot.enable_thread_interrupt else _("нет") + mailing_turn = _("включена") if bot.enable_mailing else _("выключена") text = dedent(_(""" Потоки сообщений: {0} Данные пользователя: {1} Антифлуд: {2} - Автоответчик всегда: {3} - Прерывать поток: {4} - - """)).format(thread_turn, info_turn, antiflood_turn, enable_always_second_message, thread_interrupt) + Автоответчик всегда: {3} + Прерывать поток: {4} + Рассылка: {5} + """)).format(thread_turn, info_turn, antiflood_turn, enable_always_second_message, thread_interrupt, + mailing_turn) if is_promo: olgram_turn = _("включена") if bot.enable_olgram_text else _("выключена") @@ -543,6 +550,9 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon if operation == "always_second_message": await bot_actions.always_second_message(bot, call) return await send_bot_settings_menu(bot, call) + if operation == "mailing": + await bot_actions.mailing(bot, call) + return await send_bot_settings_menu(bot, call) if operation == "thread_interrupt": await bot_actions.thread_interrupt(bot, call) return await send_bot_settings_menu(bot, call) diff --git a/olgram/migrations/models/20_20240301215418_update.sql b/olgram/migrations/models/20_20240301215418_update.sql new file mode 100644 index 0000000..76e29a4 --- /dev/null +++ b/olgram/migrations/models/20_20240301215418_update.sql @@ -0,0 +1,6 @@ +-- upgrade -- +ALTER TABLE "bot" ADD "enable_mailing" BOOL NOT NULL DEFAULT False; +ALTER TABLE "bot" ADD "last_mailing_at" TIMESTAMPTZ; +-- downgrade -- +ALTER TABLE "bot" DROP COLUMN "enable_mailing"; +ALTER TABLE "bot" DROP COLUMN "last_mailing_at"; diff --git a/olgram/models/models.py b/olgram/models/models.py index a182579..dec7808 100644 --- a/olgram/models/models.py +++ b/olgram/models/models.py @@ -48,6 +48,8 @@ class Bot(Model): enable_antiflood = fields.BooleanField(default=False) enable_always_second_message = fields.BooleanField(default=False) enable_thread_interrupt = fields.BooleanField(default=True) + enable_mailing = fields.BooleanField(default=False) + last_mailing_at = fields.DatetimeField(null=True, default=None) def decrypted_token(self): cryptor = DatabaseSettings.cryptor() @@ -106,6 +108,17 @@ class User(Model): table = 'user' +class MailingUser(Model): + id = fields.BigIntField(pk=True) + telegram_id = fields.BigIntField(index=True) + + bot = fields.ForeignKeyField("models.Bot", related_name="mailing_users", on_delete=fields.relational.CASCADE) + + class Meta: + table = 'mailinguser' + unique_together = (("bot", "telegram_id"), ) + + class GroupChat(Model): id = fields.IntField(pk=True) chat_id = fields.BigIntField(index=True, unique=True) diff --git a/olgram/utils/mix.py b/olgram/utils/mix.py index a5aa382..2a39adb 100644 --- a/olgram/utils/mix.py +++ b/olgram/utils/mix.py @@ -1,5 +1,6 @@ from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup from aiogram.utils.exceptions import TelegramAPIError +from aiogram import types, Bot as AioBot 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") diff --git a/server/custom.py b/server/custom.py index 3de539f..06df638 100644 --- a/server/custom.py +++ b/server/custom.py @@ -1,3 +1,5 @@ +import asyncio + from aiogram import Bot as AioBot, Dispatcher from aiogram.dispatcher.webhook import WebhookRequestHandler from aiogram.dispatcher.webhook import SendMessage @@ -11,7 +13,7 @@ from tortoise.expressions import F import logging import typing as ty from olgram.settings import ServerSettings -from olgram.models.models import Bot, GroupChat, BannedUser, BotStartMessage, BotSecondMessage +from olgram.models.models import Bot, GroupChat, BannedUser, BotStartMessage, BotSecondMessage, MailingUser from locales.locale import _, translators from server.inlines import inline_handler @@ -55,15 +57,20 @@ def _on_security_policy(message: types.Message, bot): text = _("Политика конфиденциальности\n\n" "Этот бот не хранит ваши сообщения, имя пользователя и @username. При отправке сообщения (кроме команд " "/start и /security_policy) ваш идентификатор пользователя записывается в кеш на некоторое время и потом " - "удаляется из кеша. Этот идентификатор используется только для общения с оператором; боты Olgram " - "не делают массовых рассылок.\n\n") + "удаляется из кеша. Этот идентификатор используется для общения с оператором.\n\n") if bot.enable_additional_info: text += _("При отправке сообщения (кроме команд /start и /security_policy) оператор видит ваши имя " "пользователя, @username и идентификатор пользователя в силу настроек, которые оператор указал при " - "создании бота.") + "создании бота.\n\n") else: text += _("В зависимости от ваших настроек конфиденциальности Telegram, оператор может видеть ваш username, " - "имя пользователя и другую информацию.") + "имя пользователя и другую информацию.\n\n") + + if bot.enable_mailing: + text += _("В этом боте включена массовая рассылка в силу настроек, которые оператор указал при создании бота. " + "Ваш идентификатор пользователя может быть записан в базу данных на долгое время") + else: + text += _("В этом боте нет массовой рассылки сообщений") return SendMessage(chat_id=message.chat.id, text=text, @@ -130,11 +137,19 @@ async def send_to_superchat(is_super_group: bool, message: types.Message, super_ await send_user_message(message, super_chat_id, bot) +async def _increase_count(_bot): + _bot.incoming_messages_count = F("incoming_messages_count") + 1 + await _bot.save(update_fields=["incoming_messages_count"]) + + async def handle_user_message(message: types.Message, super_chat_id: int, bot): """Обычный пользователь прислал сообщение в бот, нужно переслать его операторам""" _ = _get_translator(message) is_super_group = super_chat_id < 0 + if bot.enable_mailing: + asyncio.create_task(MailingUser.get_or_create(telegram_id=message.chat.id, bot=bot)) + # Проверить, не забанен ли пользователь banned = await bot.banned_users.filter(telegram_id=message.chat.id) if banned: @@ -157,8 +172,7 @@ async def handle_user_message(message: types.Message, super_chat_id: int, bot): _logger.error(f"(exception on forwarding) {err}") return - bot.incoming_messages_count = F("incoming_messages_count") + 1 - await bot.save(update_fields=["incoming_messages_count"]) + asyncio.create_task(_increase_count(bot)) # И отправить пользователю специальный текст, если он указан и если давно не отправляли if bot.second_text: