From 415ec12b2fc0509a2b691c86eb84e1754b3f8477 Mon Sep 17 00:00:00 2001 From: mihalin Date: Sun, 11 Jul 2021 12:53:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 2 + Dockerfile | 10 ++ README.md | 5 +- aerich.ini | 4 + bot.py | 74 --------- ...-compose.yaml => docker-compose-debug.yaml | 0 docker-compose-release.yaml | 30 ++++ docker-entrypoint.sh | 5 + extendedinstance/bot.py | 65 ++++++++ instance/bot.py | 95 ++++++------ main.py | 60 ++++++++ olgram/bot/bot.py | 90 ----------- olgram/{bot => commands}/__init__.py | 0 olgram/commands/bot.py | 142 ++++++++++++++++++ olgram/{bot => commands}/bots.py | 3 + olgram/{bot => commands}/start.py | 4 + .../models/0_20210711121349_init.sql | 30 ++++ olgram/models/models.py | 12 +- olgram/settings.py | 16 ++ olgram/utils/database.py | 15 -- requirements.txt | 5 +- utils/settings.py | 1 - 22 files changed, 435 insertions(+), 233 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 aerich.ini delete mode 100644 bot.py rename docker-compose.yaml => docker-compose-debug.yaml (100%) create mode 100644 docker-compose-release.yaml create mode 100755 docker-entrypoint.sh create mode 100644 extendedinstance/bot.py create mode 100644 main.py delete mode 100644 olgram/bot/bot.py rename olgram/{bot => commands}/__init__.py (100%) create mode 100644 olgram/commands/bot.py rename olgram/{bot => commands}/bots.py (96%) rename olgram/{bot => commands}/start.py (89%) create mode 100644 olgram/migrations/models/0_20210711121349_init.sql delete mode 100644 olgram/utils/database.py delete mode 100644 utils/settings.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e04276f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.idea +venv diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3c6590 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.8-buster + +COPY . /app + +WORKDIR /app + +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 58a12ef..0531798 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,4 @@ Open-source self-hosted Livegram alternative ##### -instance поведение -Кто-то написал сообщение в любом чате - переслать в супер-чат -Кто-то ответил на сообщение в супер-чате - переслать автору сообщения -Кто-то написал /start - отправить стартовое сообщение \ No newline at end of file +(TODO: readme) \ No newline at end of file diff --git a/aerich.ini b/aerich.ini new file mode 100644 index 0000000..e5014bb --- /dev/null +++ b/aerich.ini @@ -0,0 +1,4 @@ +[aerich] +tortoise_orm = olgram.settings.TORTOISE_ORM +location = ./olgram/migrations +src_folder = . diff --git a/bot.py b/bot.py deleted file mode 100644 index b930509..0000000 --- a/bot.py +++ /dev/null @@ -1,74 +0,0 @@ -import asyncio - -import aiogram.types -from aiogram import Bot as AioBot, Dispatcher -from aiogram.contrib.fsm_storage.memory import MemoryStorage - -from olgram.settings import BotSettings - -from olgram.bot.bots import router as bots_router -from olgram.bot.start import router as start_router -from olgram.bot.bot import router as bot_router -from olgram.utils.database import init_database - -from olgram.models.models import Bot, GroupChat - -from instance.bot import BotInstance - -import typing as ty - - -async def invite_callback(identify: int, message: aiogram.types.Message): - bot = await Bot.get(id=identify) - chat, _ = await GroupChat.get_or_create(chat_id=message.chat.id, - defaults={"name": message.chat.full_name}) - if chat not in await bot.group_chats.all(): - await bot.group_chats.add(chat) - - -async def left_callback(identify: int, message: aiogram.types.Message): - bot = await Bot.get(id=identify) - - chat = await bot.group_chats.get_or_none(chat_id=message.chat.id) - if chat: - await bot.group_chats.remove(chat) - - -def run_bot(bot: BotInstance, loop: ty.Optional[asyncio.AbstractEventLoop] = None): - loop = loop or asyncio.get_event_loop() - loop.create_task(bot.start_polling()) - - -async def run_all_bots(loop: asyncio.AbstractEventLoop): - bots = await Bot.all() - for bot in bots: - run_bot(BotInstance(bot.token, - bot.super_chat_id, - bot.start_text, - invite_callback=invite_callback, - left_callback=left_callback, - identify=bot.id), loop) - - -def main(): - """ - Classic polling - """ - loop = asyncio.get_event_loop() - loop.run_until_complete(init_database()) - - bot = AioBot(BotSettings.token()) - dp = Dispatcher(bot, storage=MemoryStorage()) - - start_router.setup(dp) - bots_router.setup(dp) - bot_router.setup(dp) - - loop.run_until_complete(run_all_bots(loop)) - loop.create_task(dp.start_polling()) - - loop.run_forever() - - -if __name__ == '__main__': - main() diff --git a/docker-compose.yaml b/docker-compose-debug.yaml similarity index 100% rename from docker-compose.yaml rename to docker-compose-debug.yaml diff --git a/docker-compose-release.yaml b/docker-compose-release.yaml new file mode 100644 index 0000000..febefc3 --- /dev/null +++ b/docker-compose-release.yaml @@ -0,0 +1,30 @@ +version: '3' +services: + postgres: + image: kartoza/postgis + restart: unless-stopped + env_file: + - release.env + volumes: + - database:/var/lib/postgresql/data + redis: + image: 'bitnami/redis:latest' + restart: unless-stopped + environment: + - ALLOW_EMPTY_PASSWORD=yes + volumes: + - redis-db:/bitnami/redis/data + env_file: + - release.env + bot: + build: . + restart: unless-stopped + env_file: + - release.env + depends_on: + - postgres + - redis + +volumes: + database: + redis-db: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c3031b0 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +aerich upgrade +python main.py \ No newline at end of file diff --git a/extendedinstance/bot.py b/extendedinstance/bot.py new file mode 100644 index 0000000..1c1de0f --- /dev/null +++ b/extendedinstance/bot.py @@ -0,0 +1,65 @@ +from aiogram import types +import asyncio +import aiocache +import typing as ty +from instance.bot import BotInstance, BotProperties +from olgram.models.models import Bot, GroupChat + + +class BotInstanceDatabase(BotInstance): + _instances: ty.Dict[int, "BotInstanceDatabase"] = dict() + + def __init__(self, bot: Bot): + self._bot = bot + super().__init__() + + @classmethod + async def run_all(cls): + bots = await Bot.all() + for bot in bots: + cls._instances[bot.id] = BotInstanceDatabase(bot) + # Polling только для отладки + asyncio.get_event_loop().create_task(cls._instances[bot.id].start_polling()) + + @classmethod + async def on_delete(cls, instance: Bot): + # Polling только для отладки + cls._instances[instance.id].stop_polling() + cls._instances.pop(instance.id) + + @classmethod + async def on_create(cls, instance: Bot): + # Polling только для отладки + cls._instances[instance.id] = BotInstanceDatabase(instance) + asyncio.get_event_loop().create_task(cls._instances[instance.id].start_polling()) + + @aiocache.cached(ttl=5) + async def _properties(self) -> BotProperties: + await self._bot.refresh_from_db() + return BotProperties(self._bot.token, self._bot.start_text, int(self._bot.token.split(":")[0]), + await self._bot.super_chat_id()) + + async def _setup(self): + await super()._setup() + # Callback-и на добавление бота в чат и удаление бота из чата + self._dp.register_message_handler(self._receive_invite, content_types=[types.ContentType.NEW_CHAT_MEMBERS]) + self._dp.register_message_handler(self._receive_left, content_types=[types.ContentType.LEFT_CHAT_MEMBER]) + + async def _receive_invite(self, message: types.Message): + for member in message.new_chat_members: + if member.id == message.bot.id: + chat, _ = await GroupChat.get_or_create(chat_id=message.chat.id, + defaults={"name": message.chat.full_name}) + if chat not in await self._bot.group_chats.all(): + await self._bot.group_chats.add(chat) + await self._bot.save() + break + + async def _receive_left(self, message: types.Message): + if message.left_chat_member.id == message.bot.id: + chat = await self._bot.group_chats.filter(chat_id=message.chat.id).first() + if chat: + await self._bot.group_chats.remove(chat) + if self._bot.group_chat == chat: + self._bot.group_chat = None + await self._bot.save(update_fields=["group_chat"]) diff --git a/instance/bot.py b/instance/bot.py index 339f2df..7c36b74 100644 --- a/instance/bot.py +++ b/instance/bot.py @@ -1,36 +1,43 @@ import asyncio -import typing as ty import aiogram import aioredis +from abc import ABC, abstractmethod +from dataclasses import dataclass from aiogram import Dispatcher, types, exceptions from aiogram.contrib.fsm_storage.memory import MemoryStorage -from settings import InstanceSettings +try: + from settings import InstanceSettings +except ModuleNotFoundError: + from .settings import InstanceSettings -class BotInstance: - def __init__(self, token: str, super_chat_id: int, start_text: str, - invite_callback: ty.Optional[ty.Callable] = None, - left_callback: ty.Optional[ty.Callable] = None, - identify: ty.Optional[int] = None): - self._token = token - self._bot_id = self._token.split(":")[0] - self._super_chat_id = super_chat_id - self._start_text = start_text +@dataclass() +class BotProperties: + token: str + start_text: str + bot_id: int + super_chat_id: int + + +class BotInstance(ABC): + def __init__(self): self._redis: aioredis.Redis = None self._dp: aiogram.Dispatcher = None - self._identify = identify - self._invite_callback = invite_callback - self._left_callback = left_callback + @abstractmethod + async def _properties(self) -> BotProperties: + raise NotImplemented() def stop_polling(self): self._dp.stop_polling() - async def start_polling(self): + async def _setup(self): self._redis = await aioredis.create_redis_pool(InstanceSettings.redis_path()) - bot = aiogram.Bot(self._token) + props = await self._properties() + + bot = aiogram.Bot(props.token) self._dp = Dispatcher(bot, storage=MemoryStorage()) # Здесь перечислены все типы сообщений, которые бот должен пересылать @@ -43,29 +50,14 @@ class BotInstance: types.ContentType.STICKER, types.ContentType.VIDEO, types.ContentType.VOICE]) - # Callback-и на добавление бота в чат и удаление бота из чата - self._dp.register_message_handler(self._receive_invite, content_types=[types.ContentType.NEW_CHAT_MEMBERS]) - self._dp.register_message_handler(self._receive_left, content_types=[types.ContentType.LEFT_CHAT_MEMBER]) + async def start_polling(self): + await self._setup() await self._dp.start_polling() - def _message_unique_id(self, message_id) -> str: - return self._bot_id + "-" + str(message_id) - - async def _receive_invite(self, message: types.Message): - if not self._invite_callback: - return - - for member in message.new_chat_members: - if member.id == message.bot.id: - await self._invite_callback(self._identify, message) - - async def _receive_left(self, message: types.Message): - if not self._left_callback: - return - - if message.left_chat_member.id == message.bot.id: - await self._left_callback(self._identify, message) + @classmethod + def _message_unique_id(cls, bot_id: int, message_id: int) -> str: + return f"{bot_id}_{message_id}" async def _receive_message(self, message: types.Message): """ @@ -73,20 +65,23 @@ class BotInstance: :param message: :return: """ + props = await self._properties() if message.text and message.text.startswith("/start"): # На команду start нужно ответить, не пересылая сообщение никуда - await message.answer(self._start_text) + await message.answer(props.start_text) return - if message.chat.id != self._super_chat_id: + if message.chat.id != props.super_chat_id: # Это обычный чат: сообщение нужно переслать в супер-чат - new_message = await message.forward(self._super_chat_id) - await self._redis.set(self._message_unique_id(new_message.message_id), message.chat.id) + new_message = await message.forward(props.super_chat_id) + await self._redis.set(self._message_unique_id(props.bot_id, new_message.message_id), + message.chat.id) else: # Это супер-чат if message.reply_to_message: # Ответ из супер-чата переслать тому пользователю, - chat_id = await self._redis.get(self._message_unique_id(message.reply_to_message.message_id)) + chat_id = await self._redis.get( + self._message_unique_id(props.bot_id, message.reply_to_message.message_id)) if not chat_id: chat_id = message.reply_to_message.forward_from_chat if not chat_id: @@ -99,7 +94,17 @@ class BotInstance: await message.reply("Невозможно переслать сообщение: возможно, автор заблокировал бота") return else: - await message.forward(self._super_chat_id) + await message.forward(props.super_chat_id) + + +class FreezeBotInstance(BotInstance): + def __init__(self, token: str, start_text: str, super_chat_id: int): + super().__init__() + + self._props = BotProperties(token, start_text, int(token.split(":")[0]), super_chat_id) + + async def _properties(self) -> BotProperties: + return self._props if __name__ == '__main__': @@ -108,9 +113,9 @@ if __name__ == '__main__': бот для пересылки сообщений. Все настройки этого бота задаются в переменных окружения на сервере. Бот работает в режиме polling """ - bot = BotInstance( + bot = FreezeBotInstance( InstanceSettings.token(), - InstanceSettings.super_chat_id(), - InstanceSettings.start_text() + InstanceSettings.start_text(), + InstanceSettings.super_chat_id() ) asyncio.get_event_loop().run_until_complete(bot.start_polling()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..d2918c3 --- /dev/null +++ b/main.py @@ -0,0 +1,60 @@ +import asyncio + +from aiogram import Bot as AioBot, Dispatcher +from aiogram.contrib.fsm_storage.memory import MemoryStorage +from tortoise.signals import post_delete, post_save +from tortoise import Tortoise + +from olgram.settings import BotSettings, TORTOISE_ORM + +from olgram.commands.bots import router as bots_router +from olgram.commands.start import router as start_router +from olgram.commands.bot import router as bot_router + +from olgram.models.models import Bot +from extendedinstance.bot import BotInstanceDatabase + + +@post_save(Bot) +async def signal_post_save( + sender, + instance: Bot, + created: bool, + using_db, + update_fields, +) -> None: + if created: + await BotInstanceDatabase.on_create(instance) + + +@post_delete(Bot) +async def signal_post_delete(sender, instance: Bot, using_db) -> None: + await BotInstanceDatabase.on_delete(instance) + + +async def init_database(): + await Tortoise.init(config=TORTOISE_ORM) + + +def main(): + """ + Classic polling + """ + loop = asyncio.get_event_loop() + loop.run_until_complete(init_database()) + + bot = AioBot(BotSettings.token()) + dp = Dispatcher(bot, storage=MemoryStorage()) + + start_router.setup(dp) + bots_router.setup(dp) + bot_router.setup(dp) + + loop.run_until_complete(BotInstanceDatabase.run_all()) + loop.create_task(dp.start_polling()) + + loop.run_forever() + + +if __name__ == '__main__': + main() diff --git a/olgram/bot/bot.py b/olgram/bot/bot.py deleted file mode 100644 index c4f71e8..0000000 --- a/olgram/bot/bot.py +++ /dev/null @@ -1,90 +0,0 @@ -from aiogram import types, Bot as AioBot -from aiogram.dispatcher import FSMContext -from aiogram.utils.callback_data import CallbackData -from textwrap import dedent - -from olgram.utils.router import Router -from olgram.utils.mix import try_delete_message -from olgram.models.models import Bot, User - -router = Router() - -# Пользователь выбрал бота -select_bot = CallbackData('bot_select', 'bot_id') -# Пользователь выбрал, что хочет сделать со своим ботом -bot_operation = CallbackData('bot_operation', 'bot_id', 'operation') -# Пользователь выбрал чат -select_bot_chat = CallbackData('chat_select', 'bot_id', 'chat_id') - - -@router.callback_query_handler(select_bot.filter(), state="*") -async def select_bot_callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext): - """ - Пользователь выбрал бота для редактирования - """ - bot_id = callback_data["bot_id"] - bot = await Bot.get_or_none(id=bot_id) - if not bot or (await bot.owner).telegram_id != call.from_user.id: - await call.answer("Такого бота нет, либо он принадлежит не вам", show_alert=True) - return - - await try_delete_message(call.message) - - keyboard = types.InlineKeyboardMarkup(row_width=2) - keyboard.insert(types.InlineKeyboardButton(text="Текст", - callback_data=bot_operation.new(bot_id=bot_id, operation="text"))) - keyboard.insert(types.InlineKeyboardButton(text="Чат", - callback_data=bot_operation.new(bot_id=bot_id, operation="chat"))) - keyboard.insert(types.InlineKeyboardButton(text="Удалить бот", - callback_data=bot_operation.new(bot_id=bot_id, operation="delete"))) - keyboard.insert(types.InlineKeyboardButton(text="<<Вернуться к списку ботов", - callback_data=bot_operation.new(bot_id=bot_id, operation="back"))) - - await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" - Управление ботом @{bot.name}. - - Если у вас возникли вопросы по настройке бота, то посмотрите нашу справку /help. - """), reply_markup=keyboard) - - -@router.callback_query_handler(bot_operation.filter(operation="delete"), state="*") -async def delete_bot_callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext): - bot_id = callback_data["bot_id"] - bot = await Bot.get_or_none(id=bot_id) - if not bot or (await bot.owner).telegram_id != call.from_user.id: - await call.answer("Такого бота нет, либо он принадлежит не вам", show_alert=True) - return - - await bot.delete() - await call.answer("Бот удалён") - await try_delete_message(call.message) - - -@router.callback_query_handler(bot_operation.filter(operation="chat"), state="*") -async def chats_bot_callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext): - bot_id = callback_data["bot_id"] - bot = await Bot.get_or_none(id=bot_id) - if not bot or (await bot.owner).telegram_id != call.from_user.id: - await call.answer("Такого бота нет, либо он принадлежит не вам", show_alert=True) - return - - await try_delete_message(call.message) - - keyboard = types.InlineKeyboardMarkup(row_width=2) - - chats = await bot.group_chats.all() - - if not chats: - return await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" - Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот. - Чтобы подключить чат — просто добавьте бот @{bot.name} в чат. - """), reply_markup=keyboard) - - for chat in chats: - keyboard.insert(types.InlineKeyboardButton(text=chat.name, - callback_data=select_bot_chat.new(bot_id=bot_id, chat_id=chat.id))) - - await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" - В этом разделе вы можете привязать бота @{bot.name} к чату. - Выберите чат, куда бот будет пересылать сообщения. - """), reply_markup=keyboard) diff --git a/olgram/bot/__init__.py b/olgram/commands/__init__.py similarity index 100% rename from olgram/bot/__init__.py rename to olgram/commands/__init__.py diff --git a/olgram/commands/bot.py b/olgram/commands/bot.py new file mode 100644 index 0000000..196a1bd --- /dev/null +++ b/olgram/commands/bot.py @@ -0,0 +1,142 @@ +""" +Здесь работа с конкретным ботом +""" +from aiogram import types, Bot as AioBot +from aiogram.dispatcher import FSMContext +from aiogram.utils.callback_data import CallbackData +from textwrap import dedent + +from olgram.utils.router import Router +from olgram.utils.mix import try_delete_message +from olgram.models.models import Bot, User + +router = Router() + +# Пользователь выбрал бота +select_bot = CallbackData('bot_select', 'bot_id') +# Пользователь выбрал, что хочет сделать со своим ботом +bot_operation = CallbackData('bot_operation', 'bot_id', 'operation') +# Пользователь выбрал чат +select_bot_chat = CallbackData('chat_select', 'bot_id', 'chat_id') +# Пользователь выбрал чат - личные сообщения +select_bot_chat_personal = CallbackData('chat_select_personal', 'bot_id') + + +def check_bot_owner(handler): + """ + Этот декоратор запрещает пользователям вызывать callback's (inline кнопки) для ботов, которыми они не владеют + """ + async def wrapped(call: types.CallbackQuery, callback_data: dict, state: FSMContext): + bot_id = callback_data["bot_id"] + bot = await Bot.get_or_none(id=bot_id) + if not bot or (await bot.owner).telegram_id != call.from_user.id: + await call.answer("У вас нет прав на этого бота", show_alert=True) + return + + return await handler(bot, call, callback_data, state) + return wrapped + + +@router.callback_query_handler(select_bot.filter(), state="*") +@check_bot_owner +async def select_bot_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Пользователь выбрал бота для редактирования + """ + await try_delete_message(call.message) + + keyboard = types.InlineKeyboardMarkup(row_width=2) + keyboard.insert(types.InlineKeyboardButton(text="Текст", + callback_data=bot_operation.new(bot_id=bot.id, operation="text"))) + keyboard.insert(types.InlineKeyboardButton(text="Чат", + callback_data=bot_operation.new(bot_id=bot.id, operation="chat"))) + keyboard.insert(types.InlineKeyboardButton(text="Удалить бот", + callback_data=bot_operation.new(bot_id=bot.id, operation="delete"))) + keyboard.insert(types.InlineKeyboardButton(text="<<Вернуться к списку ботов", + callback_data=bot_operation.new(bot_id=bot.id, operation="back"))) + + await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" + Управление ботом @{bot.name}. + + Если у вас возникли вопросы по настройке бота, то посмотрите нашу справку /help. + """), reply_markup=keyboard) + + +@router.callback_query_handler(bot_operation.filter(operation="delete"), state="*") +@check_bot_owner +async def delete_bot_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Кнопка "удалить" для бота + """ + await bot.delete() + await call.answer("Бот удалён") + await try_delete_message(call.message) + + +@router.callback_query_handler(bot_operation.filter(operation="chat"), state="*") +@check_bot_owner +async def chats_bot_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Кнопка "чаты" для бота + """ + await try_delete_message(call.message) + + keyboard = types.InlineKeyboardMarkup(row_width=2) + + chats = await bot.group_chats.all() + + if not chats: + return await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" + Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот. + Чтобы подключить чат — просто добавьте бот @{bot.name} в чат. + """), reply_markup=keyboard) + + for chat in chats: + keyboard.insert(types.InlineKeyboardButton(text=chat.name, + callback_data=select_bot_chat.new(bot_id=bot.id, chat_id=chat.id))) + keyboard.insert(types.InlineKeyboardButton(text="Личные сообщения", + callback_data=select_bot_chat_personal.new(bot_id=bot.id))) + await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" + В этом разделе вы можете привязать бота @{bot.name} к чату. + Выберите чат, куда бот будет пересылать сообщения. + """), reply_markup=keyboard) + + +@router.callback_query_handler(select_bot_chat.filter(), state="*") +@check_bot_owner +async def chat_selected_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Пользователь выбрал групповой чат для бота + """ + chat_id = callback_data["chat_id"] + chat = await bot.group_chats.filter(id=chat_id).first() + if not chat: + await call.answer("Нельзя привязать бота к этому чату") + return + bot.group_chat = chat + await bot.save() + await call.answer(f"Выбран чат {chat.name}") + + +@router.callback_query_handler(select_bot_chat_personal.filter(), state="*") +@check_bot_owner +async def chat_selected_personal_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Пользователь выбрал личный чат для бота + """ + bot.group_chat = None + await bot.save() + await call.answer(f"Выбран личный чат") + + +@router.callback_query_handler(bot_operation.filter(operation="text"), state="*") +@check_bot_owner +async def text_bot_callback(bot: Bot, call: types.CallbackQuery, callback_data: dict, state: FSMContext): + """ + Кнопка "текст" для бота + """ + await AioBot.get_current().send_message(call.message.chat.id, dedent(f""" + Текущий текст бота по кнопке start: + + {bot.start_text} + """)) diff --git a/olgram/bot/bots.py b/olgram/commands/bots.py similarity index 96% rename from olgram/bot/bots.py rename to olgram/commands/bots.py index 9f2c6eb..88a1200 100644 --- a/olgram/bot/bots.py +++ b/olgram/commands/bots.py @@ -1,3 +1,6 @@ +""" +Здесь работа с ботами на первом уровне вложенности: список ботов, добавление ботов +""" from aiogram import types, Bot as AioBot from aiogram.dispatcher import FSMContext from aiogram.utils.exceptions import Unauthorized, TelegramAPIError diff --git a/olgram/bot/start.py b/olgram/commands/start.py similarity index 89% rename from olgram/bot/start.py rename to olgram/commands/start.py index 6271ab9..1ac8348 100644 --- a/olgram/bot/start.py +++ b/olgram/commands/start.py @@ -1,3 +1,7 @@ +""" +Здесь простые команды на первом уровне вложенности: /start /help +""" + from aiogram import types from aiogram.dispatcher import FSMContext from textwrap import dedent diff --git a/olgram/migrations/models/0_20210711121349_init.sql b/olgram/migrations/models/0_20210711121349_init.sql new file mode 100644 index 0000000..e78e993 --- /dev/null +++ b/olgram/migrations/models/0_20210711121349_init.sql @@ -0,0 +1,30 @@ +-- upgrade -- +CREATE TABLE IF NOT EXISTS "group_chat" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "chat_id" INT NOT NULL UNIQUE, + "name" VARCHAR(50) NOT NULL +); +CREATE INDEX IF NOT EXISTS "idx_group_chat_chat_id_5da32d" ON "group_chat" ("chat_id"); +CREATE TABLE IF NOT EXISTS "user" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "telegram_id" INT NOT NULL UNIQUE +); +CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); +CREATE TABLE IF NOT EXISTS "bot" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "token" VARCHAR(50) NOT NULL UNIQUE, + "name" VARCHAR(33) NOT NULL, + "start_text" TEXT NOT NULL, + "group_chat_id" INT REFERENCES "group_chat" ("id") ON DELETE SET NULL, + "owner_id" INT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(20) NOT NULL, + "content" JSONB NOT NULL +); +CREATE TABLE IF NOT EXISTS "bot_group_chat" ( + "bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE SET NULL, + "groupchat_id" INT NOT NULL REFERENCES "group_chat" ("id") ON DELETE SET NULL +); diff --git a/olgram/models/models.py b/olgram/models/models.py index 935710a..4461459 100644 --- a/olgram/models/models.py +++ b/olgram/models/models.py @@ -11,11 +11,19 @@ class Bot(Model): name = fields.CharField(max_length=33) start_text = fields.TextField(default=dedent(""" Здравствуйте! - Напишите ваш вопрос и мы ответим Вам в ближайшее время. + Напишите ваш вопрос и мы ответим вам в ближайшее время. """)) - super_chat_id = fields.IntField() group_chats = fields.ManyToManyField("models.GroupChat", related_name="bots", on_delete=fields.relational.SET_NULL) + group_chat = fields.ForeignKeyField("models.GroupChat", related_name="active_bots", + on_delete=fields.relational.SET_NULL, + null=True) + + async def super_chat_id(self): + group_chat = await self.group_chat + if group_chat: + return group_chat.chat_id + return (await self.owner).telegram_id class Meta: table = 'bot' diff --git a/olgram/settings.py b/olgram/settings.py index 65de455..27c5a43 100644 --- a/olgram/settings.py +++ b/olgram/settings.py @@ -47,3 +47,19 @@ class DatabaseSettings(AbstractSettings): @classmethod def database_name(cls) -> str: return cls._get_env("POSTGRES_DB") + + @classmethod + def host(cls) -> str: + return cls._get_env("POSTGRES_HOST") + + +TORTOISE_ORM = { + "connections": {"default": f'postgres://{DatabaseSettings.user()}:{DatabaseSettings.password()}' + f'@{DatabaseSettings.host()}/{DatabaseSettings.database_name()}'}, + "apps": { + "models": { + "models": ["olgram.models.models", "aerich.models"], + "default_connection": "default", + }, + }, +} \ No newline at end of file diff --git a/olgram/utils/database.py b/olgram/utils/database.py deleted file mode 100644 index 6b24a69..0000000 --- a/olgram/utils/database.py +++ /dev/null @@ -1,15 +0,0 @@ -from tortoise import Tortoise -from olgram.settings import DatabaseSettings - - -async def init_database(): - # Here we create a SQLite DB using file "db.sqlite3" - # also specify the app name of "models" - # which contain models from "app.models" - await Tortoise.init( - db_url=f'postgres://{DatabaseSettings.user()}:{DatabaseSettings.password()}' - f'@localhost:5430/{DatabaseSettings.database_name()}', - modules={'models': ['olgram.models.models']} - ) - # Generate the schema - await Tortoise.generate_schemas() diff --git a/requirements.txt b/requirements.txt index e32cd67..9ac0494 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ aiogram tortoise-orm[asyncpg] -aerich +aerich==0.5.4 python-dotenv -aioredis \ No newline at end of file +aioredis +aiocache \ No newline at end of file diff --git a/utils/settings.py b/utils/settings.py deleted file mode 100644 index 8b13789..0000000 --- a/utils/settings.py +++ /dev/null @@ -1 +0,0 @@ -