Skip to content

Commit

Permalink
Merge pull request #1 from soltanoff/global_version_update
Browse files Browse the repository at this point in the history
Global version update: Python 3.12 and aiogram 3.3.0
  • Loading branch information
soltanoff authored Jan 5, 2024
2 parents 61d05d9 + 7b4994a commit 45da201
Show file tree
Hide file tree
Showing 26 changed files with 2,429 additions and 97 deletions.
2 changes: 0 additions & 2 deletions .bandit

This file was deleted.

25 changes: 17 additions & 8 deletions .ci/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
FROM python:3.10-slim
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
&& apt-get install -y $buildDeps --no-install-recommends \
&& pip install --upgrade --no-cache-dir pip wheel setuptools poetry

WORKDIR /app

COPY .. /src
WORKDIR /src
# will be cached if no changes in this files
COPY poetry.lock /app/
COPY pyproject.toml /app/

COPY ../.env.default ./.env
RUN poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction

RUN pip install -r requirements.txt
COPY app /app

CMD [ "python", "main.py" ]
1 change: 0 additions & 1 deletion .env.default
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
LOG_LEVEL=INFO
TELEGRAM_API_KEY=
7 changes: 0 additions & 7 deletions .flake8

This file was deleted.

55 changes: 55 additions & 0 deletions .github/workflows/linters.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: linters

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v3
with:
python-version: '3.12'
- uses: syphar/restore-virtualenv@v1
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install dependencies
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade pip wheel setuptools poetry
poetry config virtualenvs.create false
poetry install --no-root --no-interaction
lint:
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 flake8
run: |
make lint
safety:
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 dependencies with safety
run: |
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
34 changes: 15 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@

[![\[Telegram\] aiogram live](https://img.shields.io/badge/telegram-aiogram-blue.svg?style=flat-square)](https://t.me/aiogram_live)
[![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram)
[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-6.5-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api)
[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-7.0-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api)
[![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT)

This is just a project template for writing telegram bots. The project has linter, logger, docker, dot-env configured.

## Features

- SQLite (user info storage)
- custom aiogram middlewares ([middlewares.py](app/bot_controller/middlewares.py))
- custom aiogram router ([router.py](app/bot_controller/router.py))

## Command list

- `/help` - view all commands
- `/start` - base command for user registration
- `/hello` - just hello command
- `/user_info` - just hello command

## How to run

### Without Docker:
Expand All @@ -24,21 +37,4 @@ This is just a project template for writing telegram bots. The project has linte

## Development tools

### Bandit tool

[Bandit](https://github.com/PyCQA/bandit) is a tool designed to find common security issues in Python code. To do this
Bandit processes each file, builds an AST from it, and runs appropriate plugins against the AST nodes. Once Bandit has
finished scanning all the files it generates a report.

```shell
bandit -r .
```

### flake8

[flake8](https://github.com/PyCQA/flake8) is a python tool that glues together pycodestyle, pyflakes, mccabe, and
third-party plugins to check the style and quality of some python code.

```shell
flake8 .
```
More helpful commands in [Makefile](Makefile).
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 .handlers import router as subscriptions_router # noqa: F401
41 changes: 41 additions & 0 deletions app/bot_controller/services/handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession

from bot_controller.router import Router
from models import User


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="hello",
description="just hello command",
)
async def hello(*_) -> str:
return "Well, hello!"


@router.register(
command="user_info",
description="user info",
)
async def user_info(message: types.Message, session: AsyncSession, user: User) -> str: # noqa; pylint: disable=unused-argument
return f"User ID: {user.external_id}\nChat ID: {message.chat.id}\nRegistration date: {user.created_at}"


@router.register()
async def echo(message: types.Message, *_) -> str:
return message.text
Loading

0 comments on commit 45da201

Please sign in to comment.