add queue tests

This commit is contained in:
Dmitry Afanasyev 2023-09-22 02:39:17 +03:00 committed by GitHub
parent 010a228380
commit 1ecf95631d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 212 additions and 86 deletions

View File

@ -3,7 +3,7 @@ name: lint
on: on:
push: push:
branches-ignore: branches-ignore:
- test - develop
tags-ignore: tags-ignore:
- "*" - "*"
pull_request: pull_request:

View File

@ -3,13 +3,16 @@ name: test
on: on:
push: push:
branches-ignore: branches-ignore:
- test - develop
tags-ignore: tags-ignore:
- "*" - "*"
pull_request: pull_request:
branches: branches:
- 'release/**' - 'release/**'
env:
STAGE: runtests
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -1,20 +1,20 @@
# MosGotTrans bot # Chat gpt bot
Бот для получения расписания конкретных автобусов для конкретных остановок Бот для запросов в chatgpt
Использует **Selenium** для парсинга сайта "яндекс карты" Использует **Selenium** и API chatgpt для запросов
## Install & Update ## Install & Update
install service install service
sudo cp scripts/mosgortrans.service /etc/systemd/system sudo cp scripts/chat-gpt.service /etc/systemd/system
```bash ```bash
cd ~/PycharmProjects/mosgortrans cd ~/PycharmProjects/chat_gpt_bot
sudo systemctl stop healthcheck_bot.service sudo systemctl stop chat_gpt_bot.service
git pull balshgit master git pull balshgit main
udo rsync -a --delete --progress ~/mosgortrans/* /opt/mosgortrans/ --exclude .git sudo rsync -a --delete --progress ~/PycharmProjects/chat_gpt_bot/* /opt/chat_gpt_bot/ --exclude .git
sudo systemctl start healthcheck_bot.service sudo systemctl start chat_gpt_bot.service
``` ```
## Local start ## Local start
@ -22,6 +22,10 @@ sudo systemctl start healthcheck_bot.service
python main.py python main.py
``` ```
```shell
poetry run uvicorn --host 0.0.0.0 --factory app.main:create_app --port 8000 --reload --reload-dir=app --reload-dir=settings
```
- set `START_WITH_WEBHOOK` to blank - set `START_WITH_WEBHOOK` to blank
## Delete or set webhook manually ## Delete or set webhook manually
@ -33,26 +37,24 @@ methods:
- set - set
## Local development clean:
```bash
killall geckodriver
killall firefox
killall python
```
## Tests ## Tests
```bash ```bash
cd tests poetry run pytest
SELENOIDTEST=1 docker-compose run test-bot python -m pytest tests/bot/test_bot_selenoid.py::test_selenoid_text -vv
``` ```
## Docs
Docs can be found at
- {domain}/{url_prefix}/{api_prefix}/docs
- {domain}/{url_prefix}/{api_prefix}/redoc
on local start can be found at http://localhost/gpt/api/docs
## Help article ## Help article
[Пишем асинхронного Телеграм-бота](https://habr.com/ru/company/kts/blog/598575/) [Пишем асинхронного Телеграм-бота](https://habr.com/ru/company/kts/blog/598575/)
[fast_api_aiogram](https://programtalk.com/vs4/python/daya0576/he-weather-bot/telegram_bot/dependencies.py/)
## TODO ## TODO

View File

@ -19,10 +19,11 @@ async def healthcheck() -> ORJSONResponse:
@router.post( @router.post(
f"/{settings.bot_webhook_url}", f"/{settings.TELEGRAM_API_TOKEN}",
name="system:process_bot_updates", name="system:process_bot_updates",
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
summary="process bot updates", summary="process bot updates",
include_in_schema=False,
) )
async def process_bot_updates(request: Request) -> ORJSONResponse: async def process_bot_updates(request: Request) -> ORJSONResponse:
await request.app.state.queue.put_updates_on_queue(request) await request.app.state.queue.put_updates_on_queue(request)

View File

@ -1,6 +1,5 @@
import asyncio import asyncio
import os import os
import time
from asyncio import Queue, sleep from asyncio import Queue, sleep
from dataclasses import dataclass from dataclasses import dataclass
from functools import cached_property from functools import cached_property
@ -10,18 +9,17 @@ from fastapi import Request, Response
from telegram import Update from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes from telegram.ext import Application, CommandHandler, ContextTypes
from app.constants import API_PREFIX from settings.config import AppSettings
from settings.config import Settings
class BotApplication: class BotApplication:
def __init__(self, settings: Settings, start_with_webhook: bool = False) -> None: def __init__(self, settings: AppSettings) -> None:
self.application: Application = ( # type: ignore self.application: Application = ( # type: ignore
Application.builder().token(token=settings.TELEGRAM_API_TOKEN).build() Application.builder().token(token=settings.TELEGRAM_API_TOKEN).build()
) )
self.add_handlers() self.add_handlers()
self.settings = settings self.settings = settings
self.start_with_webhook = start_with_webhook self.start_with_webhook = settings.START_WITH_WEBHOOK
async def set_webhook(self) -> None: async def set_webhook(self) -> None:
await self.application.initialize() await self.application.initialize()
@ -39,11 +37,7 @@ class BotApplication:
await update.message.reply_text( await update.message.reply_text(
"Help!", "Help!",
disable_notification=True, disable_notification=True,
api_kwargs={ api_kwargs={"text": "Hello World"},
"text": "Hello World",
"date": int(time.time()) + 30,
"schedule_date": int(time.time()) + 30,
},
) )
return None return None
@ -60,25 +54,20 @@ class BotApplication:
@cached_property @cached_property
def webhook_url(self) -> str: def webhook_url(self) -> str:
return os.path.join( return os.path.join(self.settings.DOMAIN.strip("/"), self.settings.bot_webhook_url.strip("/"))
self.settings.WEBHOOK_HOST.strip("/"),
API_PREFIX.strip("/"),
self.settings.URL_PREFIX.strip("/"),
self.settings.TELEGRAM_API_TOKEN.strip("/"),
)
@dataclass @dataclass
class BotQueue: class BotQueue:
bot_app: Application # type: ignore[type-arg] bot_app: BotApplication
queue: Queue = asyncio.Queue() # type: ignore[type-arg] queue: Queue = asyncio.Queue() # type: ignore[type-arg]
async def put_updates_on_queue(self, request: Request) -> Response: async def put_updates_on_queue(self, request: Request) -> Response:
""" """
Listen {URL_PREFIX}/{TELEGRAM_WEB_TOKEN} path and proxy post request to bot Listen /{URL_PREFIX}/{API_PREFIX}/{TELEGRAM_WEB_TOKEN} path and proxy post request to bot
""" """
data = await request.json() data = await request.json()
tg_update = Update.de_json(data=data, bot=self.bot_app.bot) tg_update = Update.de_json(data=data, bot=self.bot_app.application.bot)
self.queue.put_nowait(tg_update) self.queue.put_nowait(tg_update)
return Response(status_code=HTTPStatus.ACCEPTED) return Response(status_code=HTTPStatus.ACCEPTED)
@ -86,6 +75,5 @@ class BotQueue:
async def get_updates_from_queue(self) -> None: async def get_updates_from_queue(self) -> None:
while True: while True:
update = await self.queue.get() update = await self.queue.get()
print(update) await self.bot_app.application.process_update(update)
await self.bot_app.process_update(update)
await sleep(0) await sleep(0)

View File

@ -8,7 +8,7 @@ from loguru import logger
from app.core.bot import BotApplication, BotQueue from app.core.bot import BotApplication, BotQueue
from app.routers import api_router from app.routers import api_router
from settings.config import Settings, get_settings from settings.config import AppSettings, get_settings
logger.remove() logger.remove()
logger.add( logger.add(
@ -20,18 +20,18 @@ logger.add(
class Application: class Application:
def __init__(self, settings: Settings, bot_app: BotApplication) -> None: def __init__(self, settings: AppSettings, bot_app: BotApplication) -> None:
self.app = FastAPI( self.app = FastAPI(
title="Health check bot", title="Chat gpt bot",
description="Bot which check all services are working", description="Bot for proxy to chat gpt in telegram",
version="0.0.3", version="0.0.3",
docs_url=f"{settings.URL_PREFIX}/docs", docs_url="/" + "/".join([settings.api_prefix.strip("/"), "docs"]),
redoc_url=f"{settings.URL_PREFIX}/redocs", redoc_url="/" + "/".join([settings.api_prefix.strip("/"), "redocs"]),
openapi_url=f"{settings.URL_PREFIX}/api/openapi.json", openapi_url="/" + "/".join([settings.api_prefix.strip("/"), "openapi.json"]),
default_response_class=UJSONResponse, default_response_class=UJSONResponse,
) )
self.app.state.settings = settings self.app.state.settings = settings
self.app.state.queue = BotQueue(bot_app=bot_app.application) self.app.state.queue = BotQueue(bot_app=bot_app)
self.bot_app = bot_app self.bot_app = bot_app
self.app.include_router(api_router) self.app.include_router(api_router)
@ -58,9 +58,9 @@ class Application:
await asyncio.gather(self.bot_app.delete_webhook(), self.bot_app.shutdown()) await asyncio.gather(self.bot_app.delete_webhook(), self.bot_app.shutdown())
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: AppSettings | None = None) -> FastAPI:
settings = settings or get_settings() settings = settings or get_settings()
bot_app = BotApplication(settings=settings, start_with_webhook=settings.START_WITH_WEBHOOK) bot_app = BotApplication(settings=settings)
return Application(settings=settings, bot_app=bot_app).fastapi_app return Application(settings=settings, bot_app=bot_app).fastapi_app

View File

@ -2,9 +2,14 @@ from fastapi import APIRouter
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse
from app.api.system.controllers import router as system_router from app.api.system.controllers import router as system_router
from app.constants import API_PREFIX from settings.config import get_settings
api_router = APIRouter(prefix=API_PREFIX, default_response_class=ORJSONResponse) settings = get_settings()
api_router = APIRouter(
prefix=settings.api_prefix,
default_response_class=ORJSONResponse,
)
api_router.include_router(system_router, tags=["system"]) api_router.include_router(system_router, tags=["system"])

View File

@ -1,11 +1,11 @@
[Unit] [Unit]
Description=Healthcheck bot Description=Chat-gpt bot
Wants=network-online.target Wants=network-online.target
After=network-online.target After=network-online.target
[Service] [Service]
Restart=always Restart=always
WorkingDirectory=/opt/healthcheck_bot WorkingDirectory=/opt/chat_gpt_bot
ExecStart=/usr/local/bin/docker-compose -f /opt/healthcheck_bot/docker-compose.yml up ExecStart=bash -c "docker compose -f /opt/chat_gpt_bot/docker-compose.yml up"
ExecStop=/usr/local/bin/docker-compose -f /opt/healthcheck_bot/docker-compose.yml down ExecStop=bash -c "docker compose -f /opt/chat_gpt_bot/docker-compose.yml down"
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -3,8 +3,19 @@ STAGE="runtests"
APP_HOST="0.0.0.0" APP_HOST="0.0.0.0"
APP_PORT="8000" APP_PORT="8000"
POSTGRES_HOST="postgres" USER="web"
POSTGRES_PORT="5432"
POSTGRES_DB="relevancer" TELEGRAM_API_TOKEN="123456789:AABBCCDDEEFFaabbccddeeff-1234567890"
POSTGRES_USER="user"
POSTGRES_PASSWORD="postgrespwd" # webhook settings
DOMAIN="http://localhost"
URL_PREFIX=
# set to true to start with webhook. Else bot will start on polling method
START_WITH_WEBHOOK="true"
# quantity of workers for uvicorn
WORKERS_COUNT=1
# Enable uvicorn reloading
RELOAD="true"
DEBUG="true"

View File

@ -1 +1,21 @@
STAGE="runtests" STAGE="runtests"
APP_HOST="0.0.0.0"
APP_PORT="8000"
USER="web"
TELEGRAM_API_TOKEN="123456789:AABBCCDDEEFFaabbccddeeff-1234567890"
# webhook settings
DOMAIN="http://localhost"
URL_PREFIX=
# set to true to start with webhook. Else bot will start on polling method
START_WITH_WEBHOOK="true"
# quantity of workers for uvicorn
WORKERS_COUNT=1
# Enable uvicorn reloading
RELOAD="true"
DEBUG="true"

View File

@ -1,8 +0,0 @@
APP_HOST="0.0.0.0"
APP_PORT="8000"
POSTGRES_HOST="postgres"
POSTGRES_PORT="5432"
POSTGRES_DB="relevancer"
POSTGRES_USER="user"
POSTGRES_PASSWORD="postgrespwd"

View File

@ -8,7 +8,7 @@ USER="web"
TELEGRAM_API_TOKEN="123456789:AABBCCDDEEFFaabbccddeeff-1234567890" TELEGRAM_API_TOKEN="123456789:AABBCCDDEEFFaabbccddeeff-1234567890"
# webhook settings # webhook settings
WEBHOOK_HOST="https://mydomain.com" DOMAIN="https://mydomain.com"
URL_PREFIX="/gpt" URL_PREFIX="/gpt"
# set to true to start with webhook. Else bot will start on polling method # set to true to start with webhook. Else bot will start on polling method

View File

@ -5,6 +5,8 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from app.constants import API_PREFIX
BASE_DIR = Path(__file__).parent.parent BASE_DIR = Path(__file__).parent.parent
SHARED_DIR = BASE_DIR.resolve().joinpath("shared") SHARED_DIR = BASE_DIR.resolve().joinpath("shared")
SHARED_DIR.mkdir(exist_ok=True) SHARED_DIR.mkdir(exist_ok=True)
@ -23,10 +25,10 @@ if environ.get("STAGE") == "runtests":
load_dotenv(env_path, override=True) load_dotenv(env_path, override=True)
class Settings(BaseSettings): class AppSettings(BaseSettings):
"""Application settings.""" """Application settings."""
PROJECT_NAME: str = "healthcheck bot" PROJECT_NAME: str = "chat gpt bot"
APP_HOST: str = "0.0.0.0" APP_HOST: str = "0.0.0.0"
APP_PORT: int = 8000 APP_PORT: int = 8000
STAGE: str = "dev" STAGE: str = "dev"
@ -35,7 +37,7 @@ class Settings(BaseSettings):
TELEGRAM_API_TOKEN: str = "123456789:AABBCCDDEEFFaabbccddeeff-1234567890" TELEGRAM_API_TOKEN: str = "123456789:AABBCCDDEEFFaabbccddeeff-1234567890"
# webhook settings # webhook settings
START_WITH_WEBHOOK: bool = False START_WITH_WEBHOOK: bool = False
WEBHOOK_HOST: str = "https://mydomain.com" DOMAIN: str = "https://localhost"
URL_PREFIX: str = "" URL_PREFIX: str = ""
# quantity of workers for uvicorn # quantity of workers for uvicorn
@ -43,13 +45,19 @@ class Settings(BaseSettings):
# Enable uvicorn reloading # Enable uvicorn reloading
RELOAD: bool = False RELOAD: bool = False
@cached_property
def api_prefix(self) -> str:
if self.URL_PREFIX:
return "/" + "/".join([self.URL_PREFIX.strip("/"), API_PREFIX.strip("/")])
return API_PREFIX
@cached_property @cached_property
def bot_webhook_url(self) -> str: def bot_webhook_url(self) -> str:
return "/" + self.TELEGRAM_API_TOKEN return "/".join([self.api_prefix, self.TELEGRAM_API_TOKEN])
class Config: class Config:
case_sensitive = True case_sensitive = True
def get_settings() -> Settings: def get_settings() -> AppSettings:
return Settings() return AppSettings()

View File

@ -17,11 +17,16 @@ from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot
from app.core.bot import BotApplication from app.core.bot import BotApplication
from app.main import Application as AppApplication from app.main import Application as AppApplication
from settings.config import get_settings from settings.config import AppSettings, get_settings
from tests.integration.bot.networking import NonchalantHttpxRequest from tests.integration.bot.networking import NonchalantHttpxRequest
from tests.integration.factories.bot import BotInfoFactory from tests.integration.factories.bot import BotInfoFactory
@pytest.fixture(scope="session")
def test_settings() -> AppSettings:
return get_settings()
class PytestExtBot(ExtBot): # type: ignore class PytestExtBot(ExtBot): # type: ignore
def __init__(self, *args: Any, **kwargs: Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -213,12 +218,13 @@ async def bot_application(bot_info: dict[str, Any]) -> AsyncGenerator[Any, None]
@pytest_asyncio.fixture(scope="session") @pytest_asyncio.fixture(scope="session")
async def main_application(bot_application: PytestApplication) -> FastAPI: async def main_application(
settings = get_settings() bot_application: PytestApplication, test_settings: AppSettings
bot_app = BotApplication(settings=settings) ) -> AsyncGenerator[FastAPI, None]:
bot_app = BotApplication(settings=test_settings)
bot_app.application = bot_application bot_app.application = bot_application
fast_api_app = AppApplication(settings=settings, bot_app=bot_app).fastapi_app fast_api_app = AppApplication(settings=test_settings, bot_app=bot_app).fastapi_app
return fast_api_app yield fast_api_app
@pytest_asyncio.fixture() @pytest_asyncio.fixture()

View File

@ -99,3 +99,11 @@ async def send_webhook_message(
data=payload, # type: ignore data=payload, # type: ignore
headers=headers, headers=headers,
) )
class MockedRequest:
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
async def json(self) -> dict[str, Any]:
return self.data

View File

@ -1,6 +1,15 @@
import asyncio
import time
from asyncio import AbstractEventLoop
from typing import Any
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from app.core.bot import BotApplication, BotQueue
from app.main import Application
from tests.integration.bot.networking import MockedRequest
pytestmark = [ pytestmark = [
pytest.mark.asyncio, pytest.mark.asyncio,
] ]
@ -10,3 +19,76 @@ async def test_bot_updates(rest_client: AsyncClient) -> None:
response = await rest_client.get("/api/healthcheck") response = await rest_client.get("/api/healthcheck")
assert response.status_code == 200 assert response.status_code == 200
async def test_bot_webhook_endpoint(
rest_client: AsyncClient,
) -> None:
response = await rest_client.post(
url="/api/123456789:AABBCCDDEEFFaabbccddeeff-1234567890",
json={
"update_id": 957250703,
"message": {
"message_id": 417070387,
"from": {
"id": 1000,
"is_bot": "false",
"first_name": "William",
"last_name": "Dalton",
"username": "bolshakovfortunat",
"language_code": "ru",
},
"chat": {
"id": 1,
"first_name": "Gabrielle",
"last_name": "Smith",
"username": "arefi_2019",
"type": "private",
},
"date": time.time(),
"text": "/chatid",
"entities": [{"type": "bot_command", "offset": 0, "length": 7}],
},
},
)
assert response.status_code == 202
async def test_bot_queue(
bot: BotApplication,
bot_application: Any,
main_application: Application,
event_loop: AbstractEventLoop,
) -> None:
bot.application = bot_application
bot_queue = BotQueue(bot_app=bot)
event_loop.create_task(bot_queue.get_updates_from_queue())
mocked_request = MockedRequest(
{
"update_id": 957250703,
"message": {
"message_id": 417070387,
"from": {
"id": 1000,
"is_bot": "false",
"first_name": "William",
"last_name": "Dalton",
"username": "bolshakovfortunat",
"language_code": "ru",
},
"chat": {
"id": 1,
"first_name": "Gabrielle",
"last_name": "Smith",
"username": "arefi_2019",
"type": "private",
},
"date": time.time(),
"text": "/chatid",
"entities": [{"type": "bot_command", "offset": 0, "length": 7}],
},
}
)
await bot_queue.put_updates_on_queue(mocked_request) # type: ignore
await asyncio.sleep(1)
assert bot_queue.queue.empty()