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.utils.exceptions import TelegramAPIError, Unauthorized
from aiogram.utils import exceptions
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 _
@@ -15,14 +18,14 @@ async def delete_bot(bot: Bot, call: types.CallbackQuery):
"""
try:
await unregister_token(bot.decrypted_token())
except Unauthorized:
except exceptions.Unauthorized:
# Вероятно пользователь сбросил токен или удалил бот, это уже не наши проблемы
pass
await bot.delete()
await call.answer(_("Бот удалён"))
try:
await call.message.delete()
except TelegramAPIError:
except exceptions.TelegramAPIError:
pass
@@ -72,7 +75,7 @@ async def select_chat(bot: Bot, call: types.CallbackQuery, chat: str):
try:
await chat.delete()
await a_bot.leave_chat(chat.chat_id)
except TelegramAPIError:
except exceptions.TelegramAPIError:
pass
await call.answer(_("Бот вышел из чатов"))
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):
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"])
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(outgoing_messages) +
_("Промо-кодов выдано: {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 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}.
@@ -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",
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()
if is_promo:
keyboard.insert(
@@ -189,11 +200,13 @@ async def send_bot_settings_menu(bot: Bot, call: types.CallbackQuery):
thread_turn = _("включены") if bot.enable_threads else _("выключены")
info_turn = _("включены") if bot.enable_additional_info else _("выключены")
antiflood_turn = _("включен") if bot.enable_antiflood else _("выключен")
mailing_turn = _("включена") if bot.enable_mailing else _("выключена")
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#user-info">Данные пользователя</a>: <b>{1}</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:
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")
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:
@@ -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)
@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
async def second_text_received(message: types.Message, state: FSMContext):
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)
if operation == "settings":
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":
await state.set_state("wait_start_text")
async with state.proxy() as proxy:
@@ -440,6 +529,9 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
if operation == "antiflood":
await bot_actions.antiflood(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":
await bot_actions.additional_info(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:
proxy["bot_id"] = bot.id
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":
await bot_actions.reset_bot_second_text(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_olgram_text = fields.BooleanField(default=True)
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):
cryptor = DatabaseSettings.cryptor()
@@ -70,6 +72,17 @@ class Bot(Model):
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):
id = fields.IntField(pk=True)
telegram_id = fields.BigIntField(index=True, unique=True)

View File

@@ -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")

View File

@@ -11,7 +11,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
from olgram.models.models import Bot, GroupChat, BannedUser, MailingUser
from locales.locale import _, translators
from server.inlines import inline_handler
@@ -55,15 +55,20 @@ def _on_security_policy(message: types.Message, bot):
text = _("<b>Политика конфиденциальности</b>\n\n"
"Этот бот не хранит ваши сообщения, имя пользователя и @username. При отправке сообщения (кроме команд "
"/start и /security_policy) ваш идентификатор пользователя записывается в кеш на некоторое время и потом "
"удаляется из кеша. Этот идентификатор используется только для общения с оператором; боты Olgram "
"не делают массовых рассылок.\n\n")
"удаляется из кеша. Этот идентификатор используется для общения с оператором.\n\n")
if bot.enable_additional_info:
text += _("При отправке сообщения (кроме команд /start и /security_policy) оператор <b>видит</b> ваши имя "
"пользователя, @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,
@@ -128,6 +133,10 @@ 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:
_, __ = await MailingUser.get_or_create(telegram_id=message.chat.id, bot=bot)
# Проверить, не забанен ли пользователь
banned = await bot.banned_users.filter(telegram_id=message.chat.id)
if banned: