Skip to content

Commit

Permalink
Merge pull request #6 from soltanoff/aiogram_refactoring
Browse files Browse the repository at this point in the history
Bot controller refactoring: middlewares, command filters
  • Loading branch information
soltanoff authored Dec 26, 2023
2 parents 2e0f0cb + 42d24b0 commit 1a5c174
Show file tree
Hide file tree
Showing 25 changed files with 925 additions and 433 deletions.
14 changes: 9 additions & 5 deletions .ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED 0

ENV buildDeps=' \
build-essential \
musl-dev \
gcc \
'

RUN apt-get update \
&& pip install --upgrade --no-cache-dir pip \
&& pip install --upgrade --no-cache-dir wheel \
&& pip install --upgrade --no-cache-dir setuptools \
&& pip install --upgrade --no-cache-dir poetry
&& apt-get install -y $buildDeps --no-install-recommends \
&& pip install --upgrade --no-cache-dir pip wheel setuptools poetry

WORKDIR /app

Expand All @@ -16,6 +20,6 @@ COPY pyproject.toml /app/
RUN poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction

COPY .. /app
COPY app /app

CMD [ "python", "main.py" ]
34 changes: 3 additions & 31 deletions .github/workflows/linters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
python -m pip install --upgrade pip wheel setuptools poetry
poetry config virtualenvs.create false
poetry install --no-root --no-interaction
flake8:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [ build ]
Expand All @@ -38,21 +38,7 @@ jobs:
id: cache-virtualenv
- name: Analysing the code with flake8
run: |
python -m flake8 .
pylint:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [ build ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: '3.12'
- uses: syphar/restore-virtualenv@v1
id: cache-virtualenv
- name: Analysing the code with pylint
run: |
python -m pylint $(git ls-files '*.py')
make lint
safety:
runs-on: ubuntu-latest
timeout-minutes: 5
Expand All @@ -66,18 +52,4 @@ jobs:
id: cache-virtualenv
- name: Analysing the dependencies with safety
run: |
python -m safety check
bandit:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [ build ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: '3.12'
- uses: syphar/restore-virtualenv@v1
id: cache-virtualenv
- name: Analysing the common security issues in Python code
run: |
python -m bandit -c pyproject.toml -r .
make safety
27 changes: 27 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.PHONY: static

docker_compose_path = "docker-compose.yml"

DC = docker-compose -f $(docker_compose_path)


format: # format your code according to project linter tools
poetry run black .
poetry run isort .

lint:
poetry run black --check app
poetry run isort --check app
poetry run flake8 --inline-quotes '"'
@# For some reason, mypy and pylint fails to resolve PYTHONPATH, set manually.
PYTHONPATH=./app poetry run pylint app
#PYTHONPATH=./app poetry run mypy --namespace-packages --show-error-codes app --check-untyped-defs --ignore-missing-imports --show-traceback

safety:
poetry run safety check

app-up: # Up the project using docker-compose
$(DC) up -d --build

down: # Down the project using docker-compose
$(DC) down
Empty file added app/__init__.py
Empty file.
1 change: 1 addition & 0 deletions app/bot_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .bot_controller import BotController # noqa: F401
44 changes: 44 additions & 0 deletions app/bot_controller/bot_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import logging
from asyncio import CancelledError

from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiohttp.web_runner import GracefulExit

from bot_controller import middlewares, services


class BotController:
MIDDLEWARES = [
middlewares.DbTransactionMiddleware,
middlewares.UserMiddleware,
middlewares.AutoReplyMiddleware,
]
ROUTERS = [
services.subscriptions_router,
]

def __init__(self, telegram_api_key: str):
self._bot = Bot(token=telegram_api_key)
self._dispatcher = Dispatcher()

self._register_middlewares()
self._register_routers()

async def start(self):
try:
await self._dispatcher.start_polling(self._bot)
except Exception as error:
logging.exception("Unexpected error: %r", error, exc_info=error)
except (GracefulExit, KeyboardInterrupt, CancelledError):
logging.info("Bot graceful shutdown...")

async def send_message(self, user_external_id: int, answer: str, parse_mode=ParseMode.HTML):
await self._bot.send_message(user_external_id, answer, parse_mode=parse_mode)

def _register_middlewares(self):
for middleware in self.MIDDLEWARES:
self._dispatcher.update.outer_middleware.register(middleware())

def _register_routers(self):
self._dispatcher.include_routers(*self.ROUTERS)
22 changes: 22 additions & 0 deletions app/bot_controller/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from functools import wraps
from typing import Callable, Optional

from aiogram import types


def skip_empty_command(command: str) -> Callable:
command_len = len(command) + 1
error_answer = "Empty message? Please, check /help"

def decorator(handler: Callable) -> Callable:
@wraps(handler)
async def wrapper(message: types.Message, *args, **kwargs) -> Optional[str]:
request_message: str = message.text[command_len:].strip()
if not request_message:
return error_answer

return await handler(message, *args, **kwargs)

return wrapper

return decorator
47 changes: 47 additions & 0 deletions app/bot_controller/middlewares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from typing import Any, Awaitable, Callable, Dict

from aiogram import BaseMiddleware, types

import db_helper
from bot_controller.services import logs
from models import async_session


class DbTransactionMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[types.TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: types.TelegramObject,
data: Dict[str, Any],
) -> Any:
async with async_session() as session:
data["session"] = session
return await handler(event, data)


class UserMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[types.TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: types.TelegramObject,
data: Dict[str, Any],
) -> Any:
session = data["session"]
data["user"] = await db_helper.get_or_create_user(session, event.message)
return await handler(event, data)


class AutoReplyMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[types.TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: types.TelegramObject,
data: Dict[str, Any],
) -> Any:
message = event.message
logs.log_bot_incomming_message(message)
result = await handler(event, data)
logs.log_bot_outgoing_message(message, result)

if result is not None:
await message.reply(text=result, disable_web_page_preview=False)
37 changes: 37 additions & 0 deletions app/bot_controller/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Callable, List, Optional

import aiogram
from aiogram.filters import Command

from bot_controller.decorators import skip_empty_command


class Router(aiogram.Router):
def __init__(self, *, name: Optional[str] = None) -> None:
super().__init__(name=name)
self.command_list: List[str] = []

def register(
self,
command: Optional[str] = None,
description: Optional[str] = None,
skip_empty_messages: bool = False,
) -> Callable:
def decorator(command_handler: Callable) -> Callable:
if command is None:
self.message()(command_handler)
return command_handler

command_filter = Command(command)
handler = command_handler
if skip_empty_messages:
handler = skip_empty_command(command=command)(command_handler)

self.message(command_filter)(handler)

if description:
self.command_list.append(f"/{command} - {description}")

return handler

return decorator
1 change: 1 addition & 0 deletions app/bot_controller/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .subscriptions import router as subscriptions_router # noqa: F401
23 changes: 23 additions & 0 deletions app/bot_controller/services/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import logging

from aiogram import types


def log_bot_incomming_message(message: types.Message):
logging.info(
"User[%s|%s:@%s]: %r",
message.chat.id,
message.from_user.id,
message.from_user.username,
message.text,
)


def log_bot_outgoing_message(message: types.Message, answer: str):
logging.info(
"<<< User[%s|%s:@%s]: %r",
message.chat.id,
message.from_user.id,
message.from_user.username,
answer,
)
89 changes: 89 additions & 0 deletions app/bot_controller/services/subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import logging
import re

from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession

import db_helper
import settings
from bot_controller.router import Router
from db_helper import STMT_USER_SUBSCRIPTION
from models import User, UserRepository


router = Router(name=__name__)


@router.register(
command="start",
description="base command for user registration",
)
@router.register(
command="help",
description="view all commands",
)
async def send_welcome(*_) -> str:
return "\n".join(router.command_list)


@router.register(
command="my_subscriptions",
description="view all subscriptions",
)
async def my_subscriptions(message: types.Message, session: AsyncSession, user: User) -> None:
answer = ""
repositories = await session.scalars(STMT_USER_SUBSCRIPTION.where(UserRepository.user_id == user.id))
for repository in repositories:
answer += f'\n{repository.latest_tag if repository.latest_tag else "<fetch in progress>"} - {repository.url}'

answer = f'Subscriptions: {answer if answer else "empty"}'
await message.reply(text=answer, disable_web_page_preview=True)


@router.register(
command="subscribe",
description="[github repo urls] subscribe to the new GitHub repository",
skip_empty_messages=True,
)
async def subscribe(message: types.Message, session: AsyncSession, user: User) -> str:
for repository_url in message.text.split():
if not re.fullmatch(settings.GITHUB_PATTERN, repository_url):
logging.warning("Repository skipped by check: %s", repository_url)
continue

await db_helper.make_subscription(session, user, repository_url)

await session.commit()
return "Successfully subscribed!"


@router.register(
command="unsubscribe",
description="[github repo urls] unsubscribe from the GitHub repository",
skip_empty_messages=True,
)
async def unsubscribe(message: types.Message, session: AsyncSession, user: User) -> str:
for repository_url in message.text.split():
if not re.fullmatch(settings.GITHUB_PATTERN, repository_url):
logging.warning("Repository skipped by check: %s", repository_url)
continue

await db_helper.make_unsubscription(session, user, repository_url)

await session.commit()
return "Successfully unsubscribed!"


@router.register(
command="remove_all_subscriptions",
description="remove all exists subscriptions",
)
async def remove_all_subscriptions(_: types.Message, session: AsyncSession, user: User) -> str:
await db_helper.remove_all_subscriptions(session, user)
await session.commit()
return "Successfully unsubscribed!"


@router.register()
async def no_hello(*_) -> str:
return "Say /help"
Loading

0 comments on commit 1a5c174

Please sign in to comment.