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: