diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 90e12e5..e557e79 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -1,8 +1,5 @@ name: Deploy -on: - push: - branches: - - stable +on: push env: IMAGE_NAME: bot USERNAME: mihalin diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c8de0c1 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,45 @@ +version: '3' +services: + postgres: + image: postgres + restart: unless-stopped + env_file: + - .env + volumes: + - database:/var/lib/postgresql/data + networks: + - default + redis: + image: 'bitnami/redis:latest' + restart: unless-stopped + environment: + - ALLOW_EMPTY_PASSWORD=yes + volumes: + - redis-db:/bitnami/redis/data + env_file: + - .env + networks: + - default + olgram: + image: ghcr.io/civsocit/olgram/bot:stable + restart: unless-stopped + networks: + - default + env_file: + - .env + volumes: + - olgram-cert:/cert + ports: + - "${WEBHOOK_PORT}:80" + depends_on: + - postgres + - redis + +volumes: + database: + redis-db: + olgram-cert: + +networks: + default: + driver: bridge diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index df35be1..001f618 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,12 @@ #!/bin/sh set -e +if [[ -z "${CUSTOM_CERT}" ]]; then + if [ ! -f /cert/private.key ]; then + openssl req -newkey rsa:2048 -sha256 -nodes -keyout /cert/private.key -x509 -days 1000 -out /cert/public.pem -subj "/C=US/ST=Berlin/L=Berlin/O=my_org/CN=my_cn" + fi +fi + sleep 10 aerich upgrade -python main.py \ No newline at end of file +python main.py diff --git a/example.env b/example.env new file mode 100644 index 0000000..d2435b8 --- /dev/null +++ b/example.env @@ -0,0 +1,12 @@ +BOT_TOKEN=YOUR_BOT_TOKEN_HERE # example: 123456789:AAAA-abc123_AbcdEFghijKLMnopqrstu12 + +POSTGRES_USER=olgram +POSTGRES_PASSWORD=SOME_RANDOM_PASSWORD_HERE # example: x2y0n27ihiez93kmzj82 +POSTGRES_DB=olgram +POSTGRES_HOST=postgres + +WEBHOOK_HOST=YOUR_HOST_HERE # example: 11.143.142.140 or my_domain.com +WEBHOOK_PORT=8443 # allowed: 80, 443, 8080, 8443 +CUSTOM_CERT=true # use that if you don't set up your own domain and let's encrypt certificate + +REDIS_PATH=redis://redis diff --git a/extendedinstance/bot.py b/extendedinstance/bot.py deleted file mode 100644 index 6d8607a..0000000 --- a/extendedinstance/bot.py +++ /dev/null @@ -1,53 +0,0 @@ -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()) - - @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/Dockerfile b/instance/Dockerfile deleted file mode 100644 index 09b05d2..0000000 --- a/instance/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.8-buster - -COPY . /app - -WORKDIR /app - -RUN pip install --upgrade pip && \ - pip install -r requirements.txt - -CMD ["python", "bot.py"] diff --git a/instance/bot.py b/instance/bot.py deleted file mode 100644 index 2d90fa8..0000000 --- a/instance/bot.py +++ /dev/null @@ -1,123 +0,0 @@ -import asyncio -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 - -try: - from settings import InstanceSettings -except ModuleNotFoundError: - from .settings import InstanceSettings - - -@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 - - @abstractmethod - async def _properties(self) -> BotProperties: - raise NotImplementedError() - - def stop_polling(self): - print("stop polling") - self._dp.stop_polling() - - async def _setup(self): - self._redis = await aioredis.create_redis_pool(InstanceSettings.redis_path()) - - props = await self._properties() - - bot = aiogram.Bot(props.token) - self._dp = Dispatcher(bot, storage=MemoryStorage()) - - # Здесь перечислены все типы сообщений, которые бот должен пересылать - self._dp.register_message_handler(self._receive_message, content_types=[types.ContentType.TEXT, - types.ContentType.CONTACT, - types.ContentType.ANIMATION, - types.ContentType.AUDIO, - types.ContentType.DOCUMENT, - types.ContentType.PHOTO, - types.ContentType.STICKER, - types.ContentType.VIDEO, - types.ContentType.VOICE]) - - async def start_polling(self): - print("start polling") - await self._setup() - await self._dp.start_polling() - - @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): - """ - Получено обычное сообщение, вероятно, для пересыла в другой чат - :param message: - :return: - """ - props = await self._properties() - if message.text and message.text.startswith("/start"): - # На команду start нужно ответить, не пересылая сообщение никуда - await message.answer(props.start_text) - return - - if message.chat.id != props.super_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(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: - await message.reply("Невозможно переслать сообщение: автор не найден") - return - chat_id = int(chat_id) - try: - await message.copy_to(chat_id) - except exceptions.MessageError: - await message.reply("Невозможно переслать сообщение: возможно, автор заблокировал бота") - return - else: - 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__': - """ - Режим single-instance. В этом режиме не работает olgram. На сервере запускается только один feedback (instance) - бот для пересылки сообщений. Все настройки этого бота задаются в переменных окружения на сервере. Бот работает - в режиме polling - """ - bot = FreezeBotInstance( - InstanceSettings.token(), - InstanceSettings.start_text(), - InstanceSettings.super_chat_id() - ) - asyncio.get_event_loop().run_until_complete(bot.start_polling()) diff --git a/instance/docker-compose.yaml b/instance/docker-compose.yaml deleted file mode 100644 index 7579c75..0000000 --- a/instance/docker-compose.yaml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3' -services: - redis: - restart: unless-stopped - image: 'bitnami/redis:latest' - environment: - - ALLOW_EMPTY_PASSWORD=yes - volumes: - - redis-db:/bitnami/redis/data - env_file: - - .env - instance: - build: . - restart: unless-stopped - depends_on: - - redis - env_file: - - .env - environment: - - INSTANCE_REDIS_PATH=redis://redis - -volumes: - redis-db: diff --git a/instance/requirements.txt b/instance/requirements.txt deleted file mode 100644 index 5bd6a47..0000000 --- a/instance/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -aiogram -python-dotenv -aioredis \ No newline at end of file diff --git a/instance/settings.py b/instance/settings.py deleted file mode 100644 index 8b40611..0000000 --- a/instance/settings.py +++ /dev/null @@ -1,46 +0,0 @@ -from dotenv import load_dotenv -import os - -load_dotenv() - - -class InstanceSettings: - @classmethod - def _get_env(cls, parameter: str) -> str: - parameter = os.getenv(parameter, None) - if not parameter: - raise ValueError(f"{parameter} not defined in ENV") - return parameter - - @classmethod - def token(cls) -> str: - """ - Token instance бота - :return: - """ - return cls._get_env("INSTANCE_TOKEN") - - @classmethod - def super_chat_id(cls) -> int: - """ - ID чата, в который бот пересылает сообщения - Это может быть личный чат (ID > 0) или общий чат (ID < 0) - :return: - """ - return int(cls._get_env("INSTANCE_SUPER_CHAT_ID")) - - @classmethod - def start_text(cls) -> str: - """ - Этот текст будет отправляться пользователю по команде /start - :return: - """ - return cls._get_env("INSTANCE_START_TEXT") - - @classmethod - def redis_path(cls) -> str: - """ - Путь до БД redis - :return: - """ - return cls._get_env("INSTANCE_REDIS_PATH") diff --git a/olgram/settings.py b/olgram/settings.py index a78a6e0..ca0fba5 100644 --- a/olgram/settings.py +++ b/olgram/settings.py @@ -8,9 +8,9 @@ load_dotenv() class AbstractSettings(ABC): @classmethod - def _get_env(cls, parameter: str) -> str: + def _get_env(cls, parameter: str, allow_none: bool = False) -> str: parameter = os.getenv(parameter, None) - if not parameter: + if not parameter and not allow_none: raise ValueError(f"{parameter} not defined in ENV") return parameter @@ -54,6 +54,19 @@ class ServerSettings(AbstractSettings): """ return cls._get_env("REDIS_PATH") + @classmethod + def use_custom_cert(cls) -> bool: + use = cls._get_env("CUSTOM_CERT", allow_none=True) + return use and "true" in use.lower() + + @classmethod + def priv_path(cls) -> str: + return "/cert/private.key" + + @classmethod + def public_path(cls) -> str: + return "/cert/public.pem" + class BotSettings(AbstractSettings): @classmethod diff --git a/requirements.txt b/requirements.txt index c60149c..118b835 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -aiogram +aiogram~=2.13 tortoise-orm[asyncpg] aerich==0.5.4 -python-dotenv +python-dotenv~=0.17.1 aioredis==1.3.1 -aiocache \ No newline at end of file +aiocache +aiohttp \ No newline at end of file diff --git a/server/server.py b/server/server.py index 07ddb2c..a44e7c2 100644 --- a/server/server.py +++ b/server/server.py @@ -2,6 +2,7 @@ from aiogram import Bot as AioBot from olgram.models.models import Bot from aiohttp import web from asyncio import get_event_loop +import ssl from olgram.settings import ServerSettings from .custom import CustomRequestHandler @@ -28,7 +29,10 @@ async def register_token(bot: Bot) -> bool: await unregister_token(bot.token) a_bot = AioBot(bot.token) - res = await a_bot.set_webhook(url_for_bot(bot)) + certificate = None + if ServerSettings.use_custom_cert(): + certificate = ServerSettings.public_path() + res = await a_bot.set_webhook(url_for_bot(bot), certificate=certificate) await a_bot.session.close() del a_bot return res @@ -52,8 +56,13 @@ def main(): app = web.Application() app.router.add_route('*', r"/{name}", CustomRequestHandler, name='webhook_handler') + context = None + if ServerSettings.use_custom_cert(): + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + context.load_cert_chain(ServerSettings.public_path(), ServerSettings.priv_path()) + runner = web.AppRunner(app) loop.run_until_complete(runner.setup()) logger.info("Server initialization done") - site = web.TCPSite(runner, host=ServerSettings.app_host(), port=ServerSettings.app_port()) + site = web.TCPSite(runner, host=ServerSettings.app_host(), port=ServerSettings.app_port(), ssl_context=context) return site