├── .gitignore ├── Dockerfile ├── Makefile ├── bot.py ├── data ├── __init__.py └── config_example.py ├── filters ├── __init__.py ├── is_admin.py └── is_chat_admin.py ├── handlers ├── __init__.py ├── admin_commands │ ├── __init__.py │ ├── forget.py │ └── remember.py ├── chat_events │ ├── __init__.py │ ├── new_members.py │ └── pinned_message.py ├── errors │ ├── __init__.py │ ├── bot_blocked.py │ └── not_modified.py ├── super_admin_commands │ ├── __init__.py │ └── leave_chat.py └── user │ ├── __init__.py │ ├── help.py │ └── start.py ├── keyboards ├── __init__.py ├── default │ ├── __init__.py │ └── consts.py ├── inline │ ├── __init__.py │ ├── callbacks.py │ ├── consts.py │ └── user.py └── keyboard_utils │ ├── __init__.py │ └── schema_generator.py ├── middlewares └── __init__.py ├── models └── __init__.py ├── requirements.txt ├── states ├── __init__.py └── user │ └── __init__.py ├── utils ├── __init__.py ├── db_api │ ├── __init__.py │ ├── chats.py │ └── storages │ │ ├── __init__.py │ │ ├── basestorage │ │ ├── __init__.py │ │ └── storage.py │ │ └── mysql │ │ ├── __init__.py │ │ └── storage.py └── misc │ ├── __init__.py │ ├── logging.py │ └── throttling.py └── web_handlers ├── __init__.py ├── health.py └── tg_updates.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea/ 132 | 133 | /data/config.py 134 | !/data/config_example.py 135 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-slim as compile-image 2 | 3 | WORKDIR /discussremover/ 4 | COPY requirements.txt /discussremover/ 5 | RUN pip install --user -r requirements.txt 6 | COPY . /discussremover/ 7 | 8 | FROM python:3.7-alpine as run-image 9 | 10 | COPY --from=compile-image /root/.local /root/.local 11 | COPY --from=compile-image /discussremover/ /discussremover/ 12 | 13 | ENV PATH=/root/.local/bin:$PATH 14 | CMD ["python3", "/discussremover/bot.py"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE_NAME=discussremover 2 | DOCKER_REGISTRY=cr.yandex/crp50vbrjt40fspljvo7 3 | 4 | update: 5 | @echo '<<>>' 6 | docker build -t $(IMAGE_NAME) . 7 | docker tag $(IMAGE_NAME):latest $(DOCKER_REGISTRY)/$(IMAGE_NAME):$(version) 8 | docker push $(DOCKER_REGISTRY)/$(IMAGE_NAME):$(version) 9 | 10 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import aiojobs 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.types import ParseMode 6 | from aiohttp import web 7 | from loguru import logger 8 | 9 | from data import config 10 | 11 | 12 | # noinspection PyUnusedLocal 13 | async def on_startup(app: web.Application): 14 | import middlewares 15 | import filters 16 | import handlers 17 | 18 | middlewares.setup(dp) 19 | filters.setup(dp) 20 | 21 | handlers.errors.setup(dp) 22 | handlers.super_admin_commands.setup(dp) 23 | handlers.admin_commands.setup(dp) 24 | handlers.user.setup(dp) 25 | handlers.chat_events.setup(dp) 26 | 27 | logger.info(f'Configure Webhook URL to: {config.WEBHOOK_URL}') 28 | await dp.bot.set_webhook(config.WEBHOOK_URL) 29 | 30 | 31 | async def on_shutdown(app: web.Application): 32 | app_bot: Bot = app['bot'] 33 | await app_bot.close() 34 | 35 | 36 | async def init() -> web.Application: 37 | from utils.misc import logging 38 | import web_handlers 39 | logging.setup() 40 | scheduler = await aiojobs.create_scheduler() 41 | app = web.Application() 42 | subapps: List[str, web.Application] = [ 43 | ('/health', web_handlers.health_app), 44 | ('/tg/webhooks', web_handlers.tg_updates_app), 45 | ] 46 | for prefix, subapp in subapps: 47 | subapp['bot'] = bot 48 | subapp['dp'] = dp 49 | subapp['scheduler'] = scheduler 50 | app.add_subapp(prefix, subapp) 51 | app.on_startup.append(on_startup) 52 | app.on_shutdown.append(on_shutdown) 53 | return app 54 | 55 | 56 | if __name__ == '__main__': 57 | bot = Bot(config.BOT_TOKEN, parse_mode=ParseMode.HTML, validate_token=True) 58 | dp = Dispatcher(bot) 59 | 60 | web.run_app(init(), port=5153, host='localhost') 61 | -------------------------------------------------------------------------------- /data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forden/DiscussRemoverBot/7a5bbca316839a5065e8fcbed4f010d57d4f9617/data/__init__.py -------------------------------------------------------------------------------- /data/config_example.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BOT_TOKEN = '' 4 | BASE_URL = '' # Webhook domain 5 | WEBHOOK_PATH = f'' 6 | WEBHOOK_URL = f'{BASE_URL}{WEBHOOK_PATH}' 7 | 8 | LOGS_BASE_PATH = str(Path(__file__).parent.parent / 'logs') 9 | 10 | admins = [] 11 | 12 | ip = { 13 | 'db': '' 14 | } 15 | 16 | mysql_info = { 17 | 'host': ip['db'], 18 | 'user': '', 19 | 'password': '', 20 | 'db': '', 21 | 'maxsize': 5, 22 | 'port': 3306 23 | } 24 | -------------------------------------------------------------------------------- /filters/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .is_admin import SuperAdminFilter 4 | from .is_chat_admin import AdminFilter 5 | 6 | 7 | def setup(dp: Dispatcher): 8 | dp.filters_factory.bind(SuperAdminFilter) 9 | dp.filters_factory.bind(AdminFilter) 10 | -------------------------------------------------------------------------------- /filters/is_admin.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | from aiogram.dispatcher.filters import BoundFilter 3 | 4 | from data import config 5 | 6 | 7 | class SuperAdminFilter(BoundFilter): 8 | key = 'is_super_admin' 9 | 10 | def __init__(self, is_super_admin): 11 | self.is_super_admin = is_super_admin 12 | 13 | async def check(self, message: types.Message, *args): 14 | return message.from_user.id in config.admins 15 | -------------------------------------------------------------------------------- /filters/is_chat_admin.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, types 2 | from aiogram.dispatcher.filters import BoundFilter 3 | 4 | from data import config 5 | 6 | 7 | class AdminFilter(BoundFilter): 8 | key = 'is_admin' 9 | 10 | def __init__(self, is_admin): 11 | self.is_admin = is_admin 12 | 13 | async def check(self, upd: types.Message, *args): 14 | if upd.chat.type in ['supergroup', 'group']: 15 | if upd.from_user.id in config.admins: 16 | return True 17 | member = await Bot.get_current().get_chat_member(upd.chat.id, upd.from_user.id) 18 | return member.is_chat_admin() 19 | else: 20 | return False 21 | -------------------------------------------------------------------------------- /handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from . import admin_commands, chat_events, errors, super_admin_commands, user 2 | -------------------------------------------------------------------------------- /handlers/admin_commands/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .forget import forget 4 | from .remember import remember 5 | 6 | 7 | def setup(dp: Dispatcher): 8 | dp.register_message_handler(remember, commands=['remember'], is_admin=True) 9 | dp.register_message_handler(forget, commands=['forget'], is_admin=True) 10 | -------------------------------------------------------------------------------- /handlers/admin_commands/forget.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from utils.db_api import Chats 4 | 5 | 6 | async def forget(msg: types.Message): 7 | await Chats.remove_pinned(msg.chat) 8 | await msg.reply('Отлично, теперь я не буду мешать Telegram закреплять сообщения с канала') 9 | -------------------------------------------------------------------------------- /handlers/admin_commands/remember.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from utils.db_api import Chats 4 | 5 | 6 | async def remember(msg: types.Message): 7 | if msg.reply_to_message: 8 | await Chats.set_pinned(msg.chat, msg.reply_to_message) 9 | m = [ 10 | 'Отлично, я запомнил это сообщение и буду закреплять его каждый раз, ' 11 | 'когда сервисный аккаунт Telegram репостнет запись с канала' 12 | ] 13 | else: 14 | m = ['Эту команду можно использовать только в ответ на какое-то сообщение, иначе ничего не сработает'] 15 | await msg.reply('\n'.join(m)) 16 | -------------------------------------------------------------------------------- /handlers/chat_events/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher, types 2 | 3 | from .new_members import new_members 4 | from .pinned_message import new_pinned_message 5 | 6 | MEDIA_TYPES = [ 7 | types.ContentType.PHOTO, types.ContentType.DOCUMENT, types.ContentType.AUDIO, 8 | types.ContentType.STICKER, types.ContentType.VIDEO, types.ContentType.VIDEO_NOTE, 9 | types.ContentType.VOICE, types.ContentType.LOCATION, types.ContentType.CONTACT, 10 | types.ContentType.ANIMATION, types.ContentType.TEXT, types.ContentType.DICE 11 | ] 12 | 13 | 14 | def setup(dp: Dispatcher): 15 | dp.register_message_handler(new_members, content_types=types.ContentTypes.NEW_CHAT_MEMBERS) 16 | dp.register_message_handler(new_pinned_message, user_id=[777000], content_types=MEDIA_TYPES) 17 | -------------------------------------------------------------------------------- /handlers/chat_events/new_members.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, types 2 | from aiogram.utils import markdown as md 3 | 4 | from utils.db_api import Chats 5 | 6 | 7 | async def new_members(msg: types.Message): 8 | botinfo = await Bot.get_current().get_me() 9 | for i in msg.new_chat_members: 10 | if i.id == botinfo.id: 11 | if await Chats.is_new(msg.chat): 12 | await Chats.register(msg.chat) 13 | txt = [ 14 | f'Привет, {md.quote_html(msg.chat.title)}! ' 15 | 'Я помогу вам удерживать одно сообщение закрепленным, исправляя недостаток Telegram\n', 16 | 'Чтобы я запомнил нужное сообщение - просто ответь на него командой /remember.', 17 | 'Чтобы я забыл сообщение и позволял закреплять любые сообщения - используй команду /forget.' 18 | ] 19 | await msg.answer('\n'.join(txt)) 20 | -------------------------------------------------------------------------------- /handlers/chat_events/pinned_message.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | from aiogram.utils import exceptions, markdown as md 3 | from loguru import logger 4 | 5 | from utils.db_api import Chats 6 | 7 | 8 | async def new_pinned_message(msg: types.Message): 9 | try: 10 | to_be_pinned = await Chats.get_pinned(msg.chat) 11 | if to_be_pinned is None: 12 | return True 13 | await msg.chat.pin_message(to_be_pinned, disable_notification=True) 14 | except exceptions.NotEnoughRightsToPinMessage: 15 | await msg.answer('У меня недостаточно прав чтобы закреплять/откреплять сообщения') 16 | except exceptions.TelegramAPIError as e: 17 | await msg.answer(f'Произошла ошибка: {md.quote_html(str(e))}') 18 | logger.error(f'Telegram API error: [{e}] [{e.__class__}]. Caused by [{msg}]') 19 | except Exception as e: 20 | logger.error(f'Unexpected error: [{e}] [{e.__class__}]. Caused by [{msg}]') 21 | -------------------------------------------------------------------------------- /handlers/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.utils import exceptions 3 | 4 | from .bot_blocked import blocked_by_user 5 | from .not_modified import message_not_modified, message_to_delete_not_found 6 | 7 | 8 | def setup(dp: Dispatcher): 9 | dp.register_errors_handler(message_not_modified, exception=exceptions.MessageNotModified) 10 | dp.register_errors_handler(message_to_delete_not_found, exception=exceptions.MessageToDeleteNotFound) 11 | dp.register_errors_handler(blocked_by_user, exception=exceptions.BotBlocked) 12 | -------------------------------------------------------------------------------- /handlers/errors/bot_blocked.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | from aiogram.utils import exceptions 3 | from loguru import logger 4 | 5 | 6 | async def blocked_by_user(upd: types.Update, err: exceptions.BotBlocked): 7 | logger.error(f'Bot blocked by user: {upd} [{err}]') 8 | -------------------------------------------------------------------------------- /handlers/errors/not_modified.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | from aiogram.utils import exceptions 3 | 4 | 5 | # noinspection PyUnusedLocal 6 | async def message_not_modified(update: types.Update, error: exceptions.MessageNotModified): 7 | return True 8 | 9 | 10 | # noinspection PyUnusedLocal 11 | async def message_to_delete_not_found(update: types.Update, error: exceptions.MessageToDeleteNotFound): 12 | return True 13 | -------------------------------------------------------------------------------- /handlers/super_admin_commands/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .leave_chat import leave_chat 4 | 5 | 6 | def setup(dp: Dispatcher): 7 | dp.register_message_handler(leave_chat, commands=['leave'], is_super_admin=True) 8 | -------------------------------------------------------------------------------- /handlers/super_admin_commands/leave_chat.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | 4 | async def leave_chat(msg: types.Message): 5 | if msg.chat.type != 'private': 6 | await msg.chat.leave() 7 | else: 8 | await msg.reply('Это личные сообщения, бот не может выйти отсюда') 9 | -------------------------------------------------------------------------------- /handlers/user/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher, types 2 | from aiogram.dispatcher.filters import CommandHelp, CommandStart 3 | 4 | from .help import bot_help 5 | from .start import bot_start 6 | 7 | 8 | def setup(dp: Dispatcher): 9 | dp.register_message_handler(bot_start, types.ChatType.is_private, CommandStart()) 10 | dp.register_message_handler(bot_help, CommandHelp()) 11 | -------------------------------------------------------------------------------- /handlers/user/help.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, types 2 | 3 | import keyboards 4 | 5 | 6 | async def bot_help(msg: types.Message): 7 | txt = [ 8 | 'Чтобы увидеть это сообщение - используй команду /help', 9 | 'Чтобы добавить меня в чат - используй кнопку ниже', 10 | 'Чтобы я запомнил нужное сообщение - ответь на него командой /remember в группе', 11 | 'Чтобы я забыл сообщение и позволял закреплять любые сообщения - используй команду /forget в группе' 12 | ] 13 | kb = keyboards.inline.Users.main_menu(await Bot.get_current().get_me()) 14 | await msg.answer('\n'.join(txt), reply_markup=kb, reply=not msg.chat.type == 'private') 15 | -------------------------------------------------------------------------------- /handlers/user/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, types 2 | from aiogram.utils import markdown as md 3 | 4 | import keyboards 5 | 6 | 7 | async def bot_start(msg: types.Message): 8 | txt = [ 9 | f'Привет, {md.quote_html(msg.from_user.full_name)}! ' 10 | 'Я помогу тебе удерживать конкретнее сообщение закрепленным, исправляя недостаток Telegram.\n', 11 | 'Просто добавь меня в нужный чат и выдай права на закрепление сообщений. Об остальном я сразу расскажу.', 12 | 'Если ты хочешь увидеть справку - используй команду /help' 13 | ] 14 | kb = keyboards.inline.Users.main_menu(await Bot.get_current().get_me()) 15 | await msg.answer('\n'.join(txt), reply_markup=kb) 16 | -------------------------------------------------------------------------------- /keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | from . import default, inline 2 | -------------------------------------------------------------------------------- /keyboards/default/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forden/DiscussRemoverBot/7a5bbca316839a5065e8fcbed4f010d57d4f9617/keyboards/default/__init__.py -------------------------------------------------------------------------------- /keyboards/default/consts.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union 2 | 3 | from aiogram.types import KeyboardButton, KeyboardButtonPollType, ReplyKeyboardMarkup 4 | 5 | from ..keyboard_utils import schema_generator 6 | 7 | 8 | class DefaultConstructor: 9 | aliases = { 10 | 'contact': 'request_contact', 11 | 'location': 'request_location', 12 | 'poll': 'request_poll' 13 | } 14 | available_properities = ['text', 'request_contact', 'request_location', 'request_poll'] 15 | properties_amount = 2 16 | 17 | @staticmethod 18 | def _create_kb( 19 | actions: List[Union[str, Dict[str, Union[str, bool, KeyboardButtonPollType]]]], 20 | schema: List[int] 21 | ) -> ReplyKeyboardMarkup: 22 | kb = ReplyKeyboardMarkup() 23 | kb.row_width = max(schema) 24 | btns = [] 25 | # noinspection DuplicatedCode 26 | for a in actions: 27 | if isinstance(a, str): 28 | a = {'text': a} 29 | data: Dict[str, Union[str, bool, KeyboardButtonPollType]] = {} 30 | for k, v in DefaultConstructor.aliases.items(): 31 | if k in a: 32 | a[v] = a[k] 33 | del a[k] 34 | for k in a: 35 | if k in DefaultConstructor.available_properities: 36 | if len(data) < DefaultConstructor.properties_amount: 37 | data[k] = a[k] 38 | else: 39 | break 40 | if len(data) != DefaultConstructor.properties_amount: 41 | raise ValueError('Недостаточно данных для создания кнопки') 42 | btns.append(KeyboardButton(**data)) 43 | kb.keyboard = schema_generator.create_keyboard_layout(btns, schema) 44 | return kb 45 | -------------------------------------------------------------------------------- /keyboards/inline/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import Users 2 | -------------------------------------------------------------------------------- /keyboards/inline/callbacks.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /keyboards/inline/consts.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Tuple, Union 2 | 3 | from aiogram.types import CallbackGame, InlineKeyboardButton, InlineKeyboardMarkup, LoginUrl 4 | from aiogram.utils.callback_data import CallbackData 5 | 6 | from ..keyboard_utils import schema_generator 7 | 8 | 9 | class InlineConstructor: 10 | aliases = { 11 | 'cb': 'callback_data' 12 | } 13 | available_properities = [ 14 | 'text', 'callback_data', 'url', 'login_url', 'switch_inline_query', 'switch_inline_query_current_chat', 15 | 'callback_game', 'pay' 16 | ] 17 | properties_amount = 2 18 | 19 | @staticmethod 20 | def _create_kb( 21 | actions: List[Dict[str, Union[str, bool, Tuple[Dict[str, str], CallbackData], LoginUrl, CallbackGame]]], 22 | schema: List[int] 23 | ) -> InlineKeyboardMarkup: 24 | kb = InlineKeyboardMarkup() 25 | kb.row_width = max(schema) 26 | btns = [] 27 | # noinspection DuplicatedCode 28 | for a in actions: 29 | data: Dict[str, Union[str, bool, Tuple[Dict[str, str], CallbackData], LoginUrl, CallbackGame]] = {} 30 | for k, v in InlineConstructor.aliases.items(): 31 | if k in a: 32 | a[v] = a[k] 33 | del a[k] 34 | for k in a: 35 | if k in InlineConstructor.available_properities: 36 | if len(data) < InlineConstructor.properties_amount: 37 | data[k] = a[k] 38 | else: 39 | break 40 | if 'callback_data' in data: 41 | data['callback_data'] = data['callback_data'][1].new(**data['callback_data'][0]) 42 | if 'pay' in data: 43 | if len(btns) != 0 and data['pay']: 44 | raise ValueError('Платежная кнопка должна идти первой в клавиатуре') 45 | data['pay'] = a['pay'] 46 | if len(data) != InlineConstructor.properties_amount: 47 | raise ValueError('Недостаточно данных для создания кнопки') 48 | btns.append(InlineKeyboardButton(**data)) 49 | kb.inline_keyboard = schema_generator.create_keyboard_layout(btns, schema) 50 | return kb 51 | -------------------------------------------------------------------------------- /keyboards/inline/user.py: -------------------------------------------------------------------------------- 1 | from aiogram import types 2 | 3 | from .consts import InlineConstructor 4 | 5 | 6 | class Users(InlineConstructor): 7 | @staticmethod 8 | def main_menu(bot: types.User): 9 | schema = [1] 10 | actions = [ 11 | {'text': 'Добавить бота в чат', 'url': f'https://telegram.me/{bot.username}?startgroup=true'} 12 | ] 13 | return Users._create_kb(actions, schema) 14 | -------------------------------------------------------------------------------- /keyboards/keyboard_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forden/DiscussRemoverBot/7a5bbca316839a5065e8fcbed4f010d57d4f9617/keyboards/keyboard_utils/__init__.py -------------------------------------------------------------------------------- /keyboards/keyboard_utils/schema_generator.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup 4 | 5 | 6 | def create_keyboard_layout( 7 | buttons: List[Union[InlineKeyboardButton, KeyboardButton]], 8 | count: List[int] 9 | ) -> Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]: 10 | if sum(count) != len(buttons): 11 | raise ValueError('Количество кнопок не совпадает со схемой') 12 | tmplist = [] 13 | for a in count: 14 | tmplist.append([]) 15 | for _ in range(a): 16 | tmplist[-1].append(buttons.pop(0)) 17 | return tmplist 18 | -------------------------------------------------------------------------------- /middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | 4 | # noinspection PyUnusedLocal 5 | def setup(dp: Dispatcher): 6 | pass 7 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forden/DiscussRemoverBot/7a5bbca316839a5065e8fcbed4f010d57d4f9617/models/__init__.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==2.9.2 2 | aiomysql==0.0.20 3 | loguru==0.5.1 4 | aiohttp_healthcheck==1.3.1 5 | aiohttp==3.6.2 6 | aiojobs==0.2.2 7 | -------------------------------------------------------------------------------- /states/__init__.py: -------------------------------------------------------------------------------- 1 | from . import user 2 | -------------------------------------------------------------------------------- /states/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Forden/DiscussRemoverBot/7a5bbca316839a5065e8fcbed4f010d57d4f9617/states/user/__init__.py -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | from . import db_api, misc 2 | -------------------------------------------------------------------------------- /utils/db_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .chats import Chats 2 | -------------------------------------------------------------------------------- /utils/db_api/chats.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from aiogram import types 4 | 5 | from .storages import MysqlConnection 6 | 7 | 8 | class Chats(MysqlConnection): 9 | @staticmethod 10 | async def is_new(chat: types.Chat) -> bool: 11 | sql = 'SELECT * FROM `admins` WHERE `chat_id` = %s' 12 | params = (chat.id,) 13 | r = await Chats._make_request(sql, params, fetch=True) 14 | return not bool(r) 15 | 16 | @staticmethod 17 | async def register(chat: types.Chat): 18 | sql = 'INSERT INTO `admins` (`chat_id`, `admins`) VALUES (%s, %s)' 19 | params = (chat.id, '') 20 | await Chats._make_request(sql, params) 21 | 22 | @staticmethod 23 | async def set_pinned(chat: types.Chat, msg: types.Message): 24 | if (await Chats.get_pinned(chat)) is not None: 25 | sql = 'UPDATE `pinned` SET `message_id` = %s WHERE `chat_id`= %s' 26 | params = (msg.message_id, chat.id) 27 | else: 28 | sql = 'INSERT INTO `pinned` (`chat_id`, `message_id`) VALUES (%s, %s)' 29 | params = (chat.id, msg.message_id) 30 | await Chats._make_request(sql, params) 31 | 32 | @staticmethod 33 | async def remove_pinned(chat: types.Chat): 34 | sql = 'DELETE FROM `pinned` WHERE `chat_id` = %s' 35 | params = (chat.id,) 36 | await Chats._make_request(sql, params) 37 | 38 | @staticmethod 39 | async def get_pinned(chat: types.Chat) -> Union[int, None]: 40 | sql = 'SELECT `message_id` FROM `pinned` WHERE `chat_id` = %s' 41 | params = (chat.id,) 42 | res = await Chats._make_request(sql, params, fetch=True) 43 | return int(res['message_id']) if res else None 44 | -------------------------------------------------------------------------------- /utils/db_api/storages/__init__.py: -------------------------------------------------------------------------------- 1 | from .mysql import MysqlConnection 2 | -------------------------------------------------------------------------------- /utils/db_api/storages/basestorage/__init__.py: -------------------------------------------------------------------------------- 1 | from . import storage 2 | -------------------------------------------------------------------------------- /utils/db_api/storages/basestorage/storage.py: -------------------------------------------------------------------------------- 1 | from typing import List, Type, TypeVar, Union 2 | 3 | T = TypeVar("T") 4 | 5 | 6 | class RawConnection: 7 | @staticmethod 8 | def __make_request( 9 | sql: str, 10 | params: Union[tuple, List[tuple]] = None, 11 | fetch: bool = False, 12 | mult: bool = False 13 | ): 14 | """ 15 | You have to override this method for all synchronous databases (e.g., Sqlite). 16 | :param sql: 17 | :param params: 18 | :param fetch: 19 | :param mult: 20 | :return: 21 | """ 22 | raise NotImplementedError 23 | 24 | @staticmethod 25 | def _make_request( 26 | sql: str, 27 | params: Union[tuple, List[tuple]] = None, 28 | fetch: bool = False, 29 | mult: bool = False, 30 | model_type: Type[T] = None 31 | ): 32 | """ 33 | You have to override this method for all synchronous databases (e.g., Sqlite). 34 | :param sql: 35 | :param params: 36 | :param fetch: 37 | :param mult: 38 | :param model_type: 39 | :return: 40 | """ 41 | raise NotImplementedError 42 | -------------------------------------------------------------------------------- /utils/db_api/storages/mysql/__init__.py: -------------------------------------------------------------------------------- 1 | from .storage import MysqlConnection 2 | -------------------------------------------------------------------------------- /utils/db_api/storages/mysql/storage.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, List, Optional, Type, TypeVar, Union 3 | 4 | import aiomysql 5 | from loguru import logger 6 | 7 | from data import config 8 | from ..basestorage.storage import RawConnection 9 | 10 | T = TypeVar("T") 11 | 12 | 13 | # noinspection DuplicatedCode 14 | class MysqlConnection(RawConnection): 15 | connection_pool = None 16 | 17 | @staticmethod 18 | async def __make_request( 19 | sql: str, 20 | params: Union[tuple, List[tuple]] = None, 21 | fetch: bool = False, 22 | mult: bool = False, 23 | retries_count: int = 5 24 | ) -> Optional[Union[List[Dict[str, Any]], Dict[str, Any]]]: 25 | if MysqlConnection.connection_pool is None: 26 | MysqlConnection.connection_pool = await aiomysql.create_pool(**config.mysql_info) 27 | async with MysqlConnection.connection_pool.acquire() as conn: 28 | conn: aiomysql.Connection = conn 29 | async with conn.cursor(aiomysql.DictCursor) as cur: 30 | cur: aiomysql.DictCursor = cur 31 | for i in range(retries_count): 32 | try: 33 | if isinstance(params, list): 34 | await cur.executemany(sql, params) 35 | else: 36 | await cur.execute(sql, params) 37 | except (aiomysql.OperationalError, aiomysql.InternalError) as e: 38 | logger.error(f'Found error [{e}] [{sql}] [{params}] retrying [{i}/{retries_count}]') 39 | if 'Deadlock found' in str(e): 40 | await asyncio.sleep(1) 41 | else: 42 | break 43 | if fetch: 44 | if mult: 45 | r = await cur.fetchall() 46 | else: 47 | r = await cur.fetchone() 48 | return r 49 | else: 50 | await conn.commit() 51 | 52 | @staticmethod 53 | def _convert_to_model(data: Optional[dict], model: Type[T]) -> Optional[T]: 54 | if data is not None: 55 | return model(**data) 56 | else: 57 | return None 58 | 59 | @staticmethod 60 | async def _make_request( 61 | sql: str, 62 | params: Union[tuple, List[tuple]] = None, 63 | fetch: bool = False, 64 | mult: bool = False, 65 | model_type: Type[T] = None 66 | ) -> Optional[Union[List[T], T]]: 67 | raw = await MysqlConnection.__make_request(sql, params, fetch, mult) 68 | if raw is None: 69 | if mult: 70 | return [] 71 | else: 72 | return None 73 | else: 74 | if mult: 75 | if model_type is not None: 76 | return [MysqlConnection._convert_to_model(i, model_type) for i in raw] 77 | else: 78 | return [i for i in raw] 79 | else: 80 | if model_type is not None: 81 | return MysqlConnection._convert_to_model(raw, model_type) 82 | else: 83 | return raw 84 | -------------------------------------------------------------------------------- /utils/misc/__init__.py: -------------------------------------------------------------------------------- 1 | from .throttling import rate_limit 2 | -------------------------------------------------------------------------------- /utils/misc/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from loguru import logger 5 | 6 | from data import config 7 | 8 | 9 | class InterceptHandler(logging.Handler): 10 | LEVELS_MAP = { 11 | logging.CRITICAL: "CRITICAL", 12 | logging.ERROR: "ERROR", 13 | logging.WARNING: "WARNING", 14 | logging.INFO: "INFO", 15 | logging.DEBUG: "DEBUG", 16 | } 17 | 18 | def _get_level(self, record): 19 | return self.LEVELS_MAP.get(record.levelno, record.levelno) 20 | 21 | def emit(self, record): 22 | logger_opt = logger.opt(depth=6, exception=record.exc_info) 23 | logger_opt.log(self._get_level(record), record.getMessage()) 24 | 25 | 26 | # noinspection PyArgumentList 27 | def setup(): 28 | logger.add(sys.stderr, format="{time} {level} {message}", filter="my_module", level="INFO") 29 | logger.add(config.LOGS_BASE_PATH + "/file_{time}.log") 30 | logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO) 31 | -------------------------------------------------------------------------------- /utils/misc/throttling.py: -------------------------------------------------------------------------------- 1 | def rate_limit(limit: int, key=None): 2 | """ 3 | Decorator for configuring rate limit and key in different functions. 4 | 5 | :param limit: 6 | :param key: 7 | :return: 8 | """ 9 | 10 | def decorator(func): 11 | setattr(func, 'throttling_rate_limit', limit) 12 | if key: 13 | setattr(func, 'throttling_key', key) 14 | return func 15 | 16 | return decorator 17 | -------------------------------------------------------------------------------- /web_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .health import health_app 2 | from .tg_updates import tg_updates_app 3 | -------------------------------------------------------------------------------- /web_handlers/health.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | from aiogram import Bot 4 | from aiohttp import web 5 | from aiohttp_healthcheck import HealthCheck 6 | from loguru import logger 7 | 8 | 9 | async def health_check() -> Tuple[bool, str]: 10 | return True, 'Server alive' 11 | 12 | 13 | async def check_webhook() -> Tuple[bool, str]: 14 | from data import config 15 | bot: Bot = health_app['bot'] 16 | 17 | webhook = await bot.get_webhook_info() 18 | if webhook.url and webhook.url == config.WEBHOOK_URL: 19 | return True, f'Webhook configured. Pending updates count {webhook.pending_update_count}' 20 | else: 21 | logger.error('Configured wrong webhook URL {webhook}', webhook=webhook.url) 22 | return False, 'Configured invalid webhook URL' 23 | 24 | 25 | health_app = web.Application() 26 | health = HealthCheck() 27 | health.add_check(health_check) 28 | health.add_check(check_webhook) 29 | health_app.add_routes([web.get('/check', health)]) 30 | -------------------------------------------------------------------------------- /web_handlers/tg_updates.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher, types 2 | from aiohttp import web 3 | 4 | tg_updates_app = web.Application() 5 | 6 | 7 | async def proceed_update(req: web.Request): 8 | upds = [types.Update(**(await req.json()))] 9 | Bot.set_current(req.app['bot']) 10 | Dispatcher.set_current(req.app['dp']) 11 | await req.app['dp'].process_updates(upds) 12 | 13 | 14 | async def execute(req: web.Request) -> web.Response: 15 | await req.app['scheduler'].spawn(proceed_update(req)) 16 | return web.Response() 17 | 18 | 19 | tg_updates_app.add_routes([web.post('/discussbot/{token}', execute)]) 20 | --------------------------------------------------------------------------------