1
0
mirror of https://github.com/civsocit/olgram.git synced 2025-12-16 23:46:17 +00:00

3 Commits

Author SHA1 Message Date
walker
58f551c77d mailing, more mailing 2022-11-07 02:03:00 +04:00
walker
696bc5368b mailing second iteration 2022-11-06 06:09:15 +04:00
walker
340246b937 mailing first iteration 2022-11-06 04:32:32 +04:00
8 changed files with 224 additions and 13 deletions

View File

@@ -49,3 +49,16 @@ Olgram пересылает сообщения так, чтобы сообщен
При включении этой опции пользователю запрещается отправлять больше одного сообщения в минуту. Используйте её, если При включении этой опции пользователю запрещается отправлять больше одного сообщения в минуту. Используйте её, если
не успеваете обрабатывать входящие сообщения. не успеваете обрабатывать входящие сообщения.
.. _mailing:
Рассылка
---------------
После включения этой опции ваш бот будет запоминать всех пользователей, которые пишут в ваш бот.
Вы сможете запустить рассылку по этим пользователям.
.. note::
Включение этой опции меняет текст политики конфиденциальности вашего feedback бота (команда /security_policy)
и может отпугнуть некоторых пользователей. Не включайте эту опцию без необходимости.

View File

@@ -1,10 +1,13 @@
""" """
Здесь работа с конкретным ботом Здесь работа с конкретным ботом
""" """
from asyncio import sleep
from datetime import datetime
from aiogram import types from aiogram import types
from aiogram.utils.exceptions import TelegramAPIError, Unauthorized from aiogram.utils import exceptions
from aiogram import Bot as AioBot from aiogram import Bot as AioBot
from olgram.models.models import Bot from olgram.models.models import Bot
from olgram.utils.mix import send_stored_message
from server.server import unregister_token from server.server import unregister_token
from locales.locale import _ from locales.locale import _
@@ -15,14 +18,14 @@ async def delete_bot(bot: Bot, call: types.CallbackQuery):
""" """
try: try:
await unregister_token(bot.decrypted_token()) await unregister_token(bot.decrypted_token())
except Unauthorized: except exceptions.Unauthorized:
# Вероятно пользователь сбросил токен или удалил бот, это уже не наши проблемы # Вероятно пользователь сбросил токен или удалил бот, это уже не наши проблемы
pass pass
await bot.delete() await bot.delete()
await call.answer(_("Бот удалён")) await call.answer(_("Бот удалён"))
try: try:
await call.message.delete() await call.message.delete()
except TelegramAPIError: except exceptions.TelegramAPIError:
pass pass
@@ -72,7 +75,7 @@ async def select_chat(bot: Bot, call: types.CallbackQuery, chat: str):
try: try:
await chat.delete() await chat.delete()
await a_bot.leave_chat(chat.chat_id) await a_bot.leave_chat(chat.chat_id)
except TelegramAPIError: except exceptions.TelegramAPIError:
pass pass
await call.answer(_("Бот вышел из чатов")) await call.answer(_("Бот вышел из чатов"))
await a_bot.session.close() await a_bot.session.close()
@@ -106,3 +109,35 @@ async def olgram_text(bot: Bot, call: types.CallbackQuery):
async def antiflood(bot: Bot, call: types.CallbackQuery): async def antiflood(bot: Bot, call: types.CallbackQuery):
bot.enable_antiflood = not bot.enable_antiflood bot.enable_antiflood = not bot.enable_antiflood
await bot.save(update_fields=["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"])
async def go_mailing(bot: Bot, context) -> int:
users = await bot.mailing_users
a_bot = AioBot(bot.decrypted_token())
count = 0
print(f"start mailing {context}")
for user in users:
bot.last_mailing_at = datetime.now()
await bot.save(update_fields=["last_mailing_at"])
try:
await sleep(0.05)
try:
await send_stored_message(context, a_bot, user.telegram_id)
except exceptions.RetryAfter as err:
await sleep(err.timeout)
await send_stored_message(context, a_bot, user.telegram_id)
count += 1
except (exceptions.ChatNotFound, exceptions.BotBlocked, exceptions.UserDeactivated):
await user.delete()
except exceptions.TelegramAPIError:
pass
return count

View File

@@ -37,4 +37,4 @@ async def info(message: types.Message, state: FSMContext):
_("Входящих сообщений у всех ботов: {0}\n").format(income_messages) + _("Входящих сообщений у всех ботов: {0}\n").format(income_messages) +
_("Исходящих сообщений у всех ботов: {0}\n").format(outgoing_messages) + _("Исходящих сообщений у всех ботов: {0}\n").format(outgoing_messages) +
_("Промо-кодов выдано: {0}\n").format(promo_count) + _("Промо-кодов выдано: {0}\n").format(promo_count) +
_("Рекламную плашку выключили: {0}\n".format(olgram_text_disabled))) _("Рекламную плашку выключили: {0}\n").format(olgram_text_disabled))

View File

@@ -4,14 +4,14 @@ from aiogram import types, Bot as AioBot
from olgram.models.models import Bot, User, DefaultAnswer from olgram.models.models import Bot, User, DefaultAnswer
from aiogram.dispatcher import FSMContext from aiogram.dispatcher import FSMContext
from aiogram.utils.callback_data import CallbackData from aiogram.utils.callback_data import CallbackData
from datetime import datetime, timedelta
from textwrap import dedent 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 olgram.commands import bot_actions
from locales.locale import _ from locales.locale import _
import typing as ty import typing as ty
menu_callback = CallbackData('menu', 'level', 'bot_id', 'operation', 'chat') menu_callback = CallbackData('menu', 'level', 'bot_id', 'operation', 'chat')
empty = "0" 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", callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="settings",
chat=empty)) 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(_(""" await edit_or_create(call, dedent(_("""
Управление ботом @{0}. Управление ботом @{0}.
@@ -172,6 +178,11 @@ async def send_bot_settings_menu(bot: Bot, call: types.CallbackQuery):
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="antiflood", callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="antiflood",
chat=empty)) chat=empty))
) )
keyboard.insert(
types.InlineKeyboardButton(text=_("Рассылка"),
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="mailing",
chat=empty))
)
is_promo = await bot.is_promo() is_promo = await bot.is_promo()
if is_promo: if is_promo:
keyboard.insert( keyboard.insert(
@@ -189,11 +200,13 @@ async def send_bot_settings_menu(bot: Bot, call: types.CallbackQuery):
thread_turn = _("включены") if bot.enable_threads else _("выключены") thread_turn = _("включены") if bot.enable_threads else _("выключены")
info_turn = _("включены") if bot.enable_additional_info else _("выключены") info_turn = _("включены") if bot.enable_additional_info else _("выключены")
antiflood_turn = _("включен") if bot.enable_antiflood else _("выключен") antiflood_turn = _("включен") if bot.enable_antiflood else _("выключен")
mailing_turn = _("включена") if bot.enable_mailing else _("выключена")
text = dedent(_(""" text = dedent(_("""
<a href="https://olgram.readthedocs.io/ru/latest/options.html#threads">Потоки сообщений</a>: <b>{0}</b> <a href="https://olgram.readthedocs.io/ru/latest/options.html#threads">Потоки сообщений</a>: <b>{0}</b>
<a href="https://olgram.readthedocs.io/ru/latest/options.html#user-info">Данные пользователя</a>: <b>{1}</b> <a href="https://olgram.readthedocs.io/ru/latest/options.html#user-info">Данные пользователя</a>: <b>{1}</b>
<a href="https://olgram.readthedocs.io/ru/latest/options.html#antiflood">Антифлуд</a>: <b>{2}</b> <a href="https://olgram.readthedocs.io/ru/latest/options.html#antiflood">Антифлуд</a>: <b>{2}</b>
""")).format(thread_turn, info_turn, antiflood_turn) <a href="https://olgram.readthedocs.io/ru/latest/options.html#mailing">Рассылка</a>: <b>{3}</b>
""")).format(thread_turn, info_turn, antiflood_turn, mailing_turn)
if is_promo: if is_promo:
olgram_turn = _("включена") if bot.enable_olgram_text else _("выключена") olgram_turn = _("включена") if bot.enable_olgram_text else _("выключена")
@@ -238,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") 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, async def send_bot_statistic_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None,
chat_id: ty.Optional[int] = None): chat_id: ty.Optional[int] = None):
if call: if call:
@@ -352,6 +389,49 @@ async def start_text_received(message: types.Message, state: FSMContext):
await send_bot_text_menu(bot, chat_id=message.chat.id) 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=1)
keyboard.insert(
types.InlineKeyboardButton(text=_("Да, начать рассылку"),
callback_data=menu_callback.new(level=3, 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 @dp.message_handler(state="wait_second_text", content_types="text", regexp="^[^/].+") # Not command
async def second_text_received(message: types.Message, state: FSMContext): async def second_text_received(message: types.Message, state: FSMContext):
async with state.proxy() as proxy: async with state.proxy() as proxy:
@@ -423,6 +503,15 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
return await send_bot_statistic_menu(bot, call) return await send_bot_statistic_menu(bot, call)
if operation == "settings": if operation == "settings":
return await send_bot_settings_menu(bot, call) return await send_bot_settings_menu(bot, call)
if operation == "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 not await bot.mailing_users:
return await call.answer(_("Нет пользователей для рассылки"))
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 == "text": if operation == "text":
await state.set_state("wait_start_text") await state.set_state("wait_start_text")
async with state.proxy() as proxy: async with state.proxy() as proxy:
@@ -440,6 +529,9 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
if operation == "antiflood": if operation == "antiflood":
await bot_actions.antiflood(bot, call) await bot_actions.antiflood(bot, call)
return await send_bot_settings_menu(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 == "additional_info": if operation == "additional_info":
await bot_actions.additional_info(bot, call) await bot_actions.additional_info(bot, call)
return await send_bot_settings_menu(bot, call) return await send_bot_settings_menu(bot, call)
@@ -454,6 +546,20 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
async with state.proxy() as proxy: async with state.proxy() as proxy:
proxy["bot_id"] = bot.id proxy["bot_id"] = bot.id
return await send_bot_second_text_menu(bot, call) return await send_bot_second_text_menu(bot, call)
if operation == "go_go_mailing":
if (await state.get_state()) == "wait_mailing_text":
async with state.proxy() as proxy:
mailing_data = dict(proxy)
await state.reset_state()
if bot.last_mailing_at and bot.last_mailing_at >= datetime.now() - timedelta(minutes=5):
return await call.answer(_("Рассылка была совсем недавно, подождите немного"), show_alert=True)
if not await bot.mailing_users:
return await call.answer(_("Нет пользователей для рассылки"))
await call.answer(_("Рассылка запущена"))
count = await bot_actions.go_mailing(bot, mailing_data)
await call.message.answer(_("Рассылка завершена, отправлено {0} сообщений").format(count))
if operation == "reset_second_text": if operation == "reset_second_text":
await bot_actions.reset_bot_second_text(bot, call) await bot_actions.reset_bot_second_text(bot, call)
return await send_bot_second_text_menu(bot, call) return await send_bot_second_text_menu(bot, call)

View File

@@ -0,0 +1,14 @@
-- upgrade --
ALTER TABLE "bot" ADD "last_mailing_at" TIMESTAMPTZ;
ALTER TABLE "bot" ADD "enable_mailing" BOOL NOT NULL DEFAULT False;
CREATE TABLE IF NOT EXISTS "mailinguser" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"telegram_id" BIGINT NOT NULL,
"bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_mailinguser_bot_id_906a76" UNIQUE ("bot_id", "telegram_id")
);
CREATE INDEX IF NOT EXISTS "idx_mailinguser_telegra_55de60" ON "mailinguser" ("telegram_id");;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "last_mailing_at";
ALTER TABLE "bot" DROP COLUMN "enable_mailing";
DROP TABLE IF EXISTS "mailinguser";

View File

@@ -46,6 +46,8 @@ class Bot(Model):
enable_additional_info = fields.BooleanField(default=False) enable_additional_info = fields.BooleanField(default=False)
enable_olgram_text = fields.BooleanField(default=True) enable_olgram_text = fields.BooleanField(default=True)
enable_antiflood = fields.BooleanField(default=False) enable_antiflood = fields.BooleanField(default=False)
enable_mailing = fields.BooleanField(default=False)
last_mailing_at = fields.DatetimeField(null=True, default=None)
def decrypted_token(self): def decrypted_token(self):
cryptor = DatabaseSettings.cryptor() cryptor = DatabaseSettings.cryptor()
@@ -70,6 +72,17 @@ class Bot(Model):
table = 'bot' table = 'bot'
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 User(Model): class User(Model):
id = fields.IntField(pk=True) id = fields.IntField(pk=True)
telegram_id = fields.BigIntField(index=True, unique=True) telegram_id = fields.BigIntField(index=True, unique=True)

View File

@@ -1,4 +1,5 @@
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup
from aiogram import types, Bot as AioBot
from aiogram.utils.exceptions import TelegramAPIError from aiogram.utils.exceptions import TelegramAPIError
from typing import Optional from typing import Optional
@@ -30,3 +31,23 @@ def wrap(data: str, max_len: int) -> str:
def button_text_limit(data: str) -> str: def button_text_limit(data: str) -> str:
return wrap(data, 30) 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")

View File

@@ -11,7 +11,7 @@ from tortoise.expressions import F
import logging import logging
import typing as ty import typing as ty
from olgram.settings import ServerSettings from olgram.settings import ServerSettings
from olgram.models.models import Bot, GroupChat, BannedUser from olgram.models.models import Bot, GroupChat, BannedUser, MailingUser
from locales.locale import _, translators from locales.locale import _, translators
from server.inlines import inline_handler from server.inlines import inline_handler
@@ -55,15 +55,20 @@ def _on_security_policy(message: types.Message, bot):
text = _("<b>Политика конфиденциальности</b>\n\n" text = _("<b>Политика конфиденциальности</b>\n\n"
"Этот бот не хранит ваши сообщения, имя пользователя и @username. При отправке сообщения (кроме команд " "Этот бот не хранит ваши сообщения, имя пользователя и @username. При отправке сообщения (кроме команд "
"/start и /security_policy) ваш идентификатор пользователя записывается в кеш на некоторое время и потом " "/start и /security_policy) ваш идентификатор пользователя записывается в кеш на некоторое время и потом "
"удаляется из кеша. Этот идентификатор используется только для общения с оператором; боты Olgram " "удаляется из кеша. Этот идентификатор используется для общения с оператором.\n\n")
"не делают массовых рассылок.\n\n")
if bot.enable_additional_info: if bot.enable_additional_info:
text += _("При отправке сообщения (кроме команд /start и /security_policy) оператор <b>видит</b> ваши имя " text += _("При отправке сообщения (кроме команд /start и /security_policy) оператор <b>видит</b> ваши имя "
"пользователя, @username и идентификатор пользователя в силу настроек, которые оператор указал при " "пользователя, @username и идентификатор пользователя в силу настроек, которые оператор указал при "
"создании бота.") "создании бота.\n\n")
else: else:
text += _("В зависимости от ваших настроек конфиденциальности Telegram, оператор может видеть ваш username, " text += _("В зависимости от ваших настроек конфиденциальности Telegram, оператор может видеть ваш username, "
"имя пользователя и другую информацию.") "имя пользователя и другую информацию.\n\n")
if bot.enable_mailing:
text += _("В этом боте включена массовая рассылка в силу настроек, которые оператор указал при создании бота. "
"Ваш идентификатор пользователя может быть записан в базу данных на долгое время")
else:
text += _("В этом боте нет массовой рассылки сообщений")
return SendMessage(chat_id=message.chat.id, return SendMessage(chat_id=message.chat.id,
text=text, text=text,
@@ -128,6 +133,10 @@ async def handle_user_message(message: types.Message, super_chat_id: int, bot):
_ = _get_translator(message) _ = _get_translator(message)
is_super_group = super_chat_id < 0 is_super_group = super_chat_id < 0
# Записать пользователя для рассылки, если она включена
if bot.enable_mailing:
_, __ = await MailingUser.get_or_create(telegram_id=message.chat.id, bot=bot)
# Проверить, не забанен ли пользователь # Проверить, не забанен ли пользователь
banned = await bot.banned_users.filter(telegram_id=message.chat.id) banned = await bot.banned_users.filter(telegram_id=message.chat.id)
if banned: if banned: