chore: restructured project
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Ruff files
|
||||
.ruff_cache
|
||||
|
||||
# Docker files
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Template env file
|
||||
.env.template
|
||||
@@ -0,0 +1,7 @@
|
||||
# Change all vars before going to production and remove all comments (!)
|
||||
# Below all environment variables and default values
|
||||
|
||||
AIOGRAM_BOT_TOKEN=
|
||||
AIOGRAM_BACKEND_ADDRESS=http://localhost:8080
|
||||
REDIS_URI=redis://localhost:6379
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
@@ -0,0 +1,170 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Ruff files
|
||||
.ruff_cache
|
||||
@@ -0,0 +1,37 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM docker.io/python:3.11-alpine3.20 AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.4.30 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONOPTIMIZE=2 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN uv sync --no-dev --no-install-project --no-cache
|
||||
|
||||
|
||||
# Stage 2: Start the application
|
||||
FROM docker.io/python:3.11-alpine3.20
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN adduser -D -g '' app && chown -R app:app ./
|
||||
|
||||
USER app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONOPTIMIZE=2 \
|
||||
PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
CMD python main.py
|
||||
@@ -0,0 +1,83 @@
|
||||
# AdNova Telegram Bot
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure you have the following installed on your system:
|
||||
|
||||
- [Python](https://www.python.org/) (>=3.10,<3.12)
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- [Docker](https://www.docker.com/) (for containerized setup)
|
||||
|
||||
## Basic setup
|
||||
|
||||
### Installation
|
||||
|
||||
#### Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.prodcontest.ru/2025-final-projects-back/devitq.git
|
||||
```
|
||||
|
||||
#### Go to the project directory
|
||||
|
||||
```bash
|
||||
cd devitq/solution/services/telegram_bot
|
||||
```
|
||||
|
||||
#### Customize environment
|
||||
|
||||
```bash
|
||||
cp .env.template .env
|
||||
```
|
||||
|
||||
And setup env vars according to your needs.
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
##### For dev environment
|
||||
|
||||
```bash
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
##### For prod environment
|
||||
|
||||
```bash
|
||||
uv sync --no-dev
|
||||
```
|
||||
|
||||
#### Running
|
||||
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
## Containerized setup
|
||||
|
||||
### Clone the project
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.prodcontest.ru/2025-final-projects-back/devitq.git
|
||||
```
|
||||
|
||||
### Go to the project directory
|
||||
|
||||
```bash
|
||||
cd devitq/solution/services/telegram_bot
|
||||
```
|
||||
|
||||
### Build docker image
|
||||
|
||||
```bash
|
||||
docker build -t adnova-telegram_bot .
|
||||
```
|
||||
|
||||
### Customize environment
|
||||
|
||||
Customize environment with `docker run` command (or bind .env file to container), for all environment vars and default values see [.env.template](./.env.template).
|
||||
|
||||
### Run docker image
|
||||
|
||||
```bash
|
||||
docker run --name adnova-telegram_bot adnova-telegram_bot
|
||||
```
|
||||
@@ -0,0 +1,168 @@
|
||||
from http import HTTPStatus as status
|
||||
from typing import Self
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
from api import errors, schemas
|
||||
|
||||
|
||||
class AdNovaClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = config.API_ENDPOINT
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
self.client = httpx.AsyncClient(base_url=self.base_url)
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self, exc_type: object, exc_val: object, exc_tb: object
|
||||
) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
def _handle_response(self, response: httpx.Response) -> httpx.Response:
|
||||
if response.status_code == status.BAD_REQUEST:
|
||||
error = schemas.BadRequestError.model_validate(response.json())
|
||||
raise errors.BadRequestError(error.detail)
|
||||
if response.status_code == status.FORBIDDEN:
|
||||
error = schemas.ForbiddenError.model_validate(response.json())
|
||||
raise errors.ForbiddenError(error.detail)
|
||||
if response.status_code == status.NOT_FOUND:
|
||||
error = schemas.NotFoundError.model_validate(response.json())
|
||||
raise errors.NotFoundError(error.detail)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response
|
||||
|
||||
def sync_get_advertiser(self, advertiser_id: str) -> schemas.Advertiser:
|
||||
client = httpx.Client(base_url=self.base_url)
|
||||
response = client.get(f"/advertisers/{advertiser_id}")
|
||||
self._handle_response(response)
|
||||
return schemas.Advertiser.model_validate(response.json())
|
||||
|
||||
async def get_advertiser(self, advertiser_id: str) -> schemas.Advertiser:
|
||||
response = await self.client.get(f"/advertisers/{advertiser_id}")
|
||||
self._handle_response(response)
|
||||
return schemas.Advertiser.model_validate(response.json())
|
||||
|
||||
async def create_campaign(
|
||||
self, advertiser_id: str, data: schemas.CampaignCreateIn
|
||||
) -> schemas.CampaignOut:
|
||||
response = await self.client.post(
|
||||
f"/advertisers/{advertiser_id}/campaigns", json=data.model_dump()
|
||||
)
|
||||
self._handle_response(response)
|
||||
return schemas.CampaignOut.model_validate(response.json())
|
||||
|
||||
async def list_campaigns(
|
||||
self, advertiser_id: str, page: int = 1, size: int = 1000
|
||||
) -> list[schemas.CampaignOut]:
|
||||
params = {"page": page, "size": size}
|
||||
response = await self.client.get(
|
||||
f"/advertisers/{advertiser_id}/campaigns", params=params
|
||||
)
|
||||
self._handle_response(response)
|
||||
return [
|
||||
schemas.CampaignOut.model_validate(item)
|
||||
for item in response.json()
|
||||
]
|
||||
|
||||
async def get_campaign(
|
||||
self, advertiser_id: str, campaign_id: str
|
||||
) -> schemas.CampaignOut:
|
||||
response = await self.client.get(
|
||||
f"/advertisers/{advertiser_id}/campaigns/{campaign_id}"
|
||||
)
|
||||
self._handle_response(response)
|
||||
return schemas.CampaignOut.model_validate(response.json())
|
||||
|
||||
async def update_campaign(
|
||||
self,
|
||||
advertiser_id: str,
|
||||
campaign_id: str,
|
||||
data: schemas.CampaignUpdateIn,
|
||||
) -> schemas.CampaignOut:
|
||||
response = await self.client.put(
|
||||
f"/advertisers/{advertiser_id}/campaigns/{campaign_id}",
|
||||
json=data.model_dump(),
|
||||
)
|
||||
self._handle_response(response)
|
||||
return schemas.CampaignOut.model_validate(response.json())
|
||||
|
||||
async def delete_campaign(
|
||||
self, advertiser_id: str, campaign_id: str
|
||||
) -> None:
|
||||
response = await self.client.delete(
|
||||
f"/advertisers/{advertiser_id}/campaigns/{campaign_id}"
|
||||
)
|
||||
self._handle_response(response)
|
||||
|
||||
async def upload_ad_image(
|
||||
self, advertiser_id: str, campaign_id: str, file: bytes
|
||||
) -> schemas.CampaignOut:
|
||||
files = {"ad_image": file}
|
||||
response = await self.client.post(
|
||||
f"/advertisers/{advertiser_id}/campaigns/{campaign_id}/ad_image",
|
||||
files=files,
|
||||
)
|
||||
self._handle_response(response)
|
||||
return schemas.CampaignOut.model_validate(response.json())
|
||||
|
||||
async def delete_ad_image(
|
||||
self, advertiser_id: str, campaign_id: str
|
||||
) -> None:
|
||||
response = await self.client.delete(
|
||||
f"/advertisers/{advertiser_id}/campaigns/{campaign_id}/ad_image"
|
||||
)
|
||||
self._handle_response(response)
|
||||
|
||||
async def get_advertiser_statistics(
|
||||
self, advertiser_id: str
|
||||
) -> schemas.Stat:
|
||||
response = await self.client.get(f"/stats/advertisers/{advertiser_id}")
|
||||
self._handle_response(response)
|
||||
return schemas.Stat.model_validate(response.json())
|
||||
|
||||
async def get_daily_advertiser_statistics(
|
||||
self, advertiser_id: str
|
||||
) -> list[schemas.DailyStat]:
|
||||
response = await self.client.get(
|
||||
f"/stats/advertisers/{advertiser_id}/daily"
|
||||
)
|
||||
self._handle_response(response)
|
||||
return [
|
||||
schemas.DailyStat.model_validate(item) for item in response.json()
|
||||
]
|
||||
|
||||
async def get_campaign_statistics(self, campaign_id: str) -> schemas.Stat:
|
||||
response = await self.client.get(f"/stats/campaigns/{campaign_id}")
|
||||
self._handle_response(response)
|
||||
return schemas.Stat.model_validate(response.json())
|
||||
|
||||
async def get_daily_campaign_statistics(
|
||||
self, campaign_id: str
|
||||
) -> list[schemas.DailyStat]:
|
||||
response = await self.client.get(
|
||||
f"/stats/campaigns/{campaign_id}/daily"
|
||||
)
|
||||
self._handle_response(response)
|
||||
return [
|
||||
schemas.DailyStat.model_validate(item) for item in response.json()
|
||||
]
|
||||
|
||||
async def generate_ad_text(
|
||||
self, data: schemas.GenerateAdTextIn
|
||||
) -> schemas.GenerateAdTextResult:
|
||||
response = await self.client.post(
|
||||
"/generate/ad_text", json=data.model_dump()
|
||||
)
|
||||
self._handle_response(response)
|
||||
return schemas.GenerateAdTextResult.model_validate(response.json())
|
||||
|
||||
async def get_generate_ad_text_result(
|
||||
self, task_id: str
|
||||
) -> schemas.GenerateAdTextResult:
|
||||
response = await self.client.get(f"/generate/ad_text/{task_id}/result")
|
||||
self._handle_response(response)
|
||||
return schemas.GenerateAdTextResult.model_validate(response.json())
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
class HTTPError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BadRequestError(HTTPError):
|
||||
def __init__(self, detail: Any) -> None:
|
||||
super().__init__(f"Bad Request: {detail}")
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class ForbiddenError(HTTPError):
|
||||
def __init__(self, detail: str = "Forbidden") -> None:
|
||||
super().__init__(f"Forbidden: {detail}")
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NotFoundError(HTTPError):
|
||||
def __init__(self, detail: str = "Not Found") -> None:
|
||||
super().__init__(f"Not Found: {detail}")
|
||||
self.detail = detail
|
||||
@@ -0,0 +1,87 @@
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, NonNegativeFloat, NonNegativeInt
|
||||
|
||||
|
||||
class BadRequestError(BaseModel):
|
||||
detail: Any
|
||||
|
||||
|
||||
class ForbiddenError(BaseModel):
|
||||
detail: str = "Forbidden"
|
||||
|
||||
|
||||
class NotFoundError(BaseModel):
|
||||
detail: str = "Not Found"
|
||||
|
||||
|
||||
class CampaignTargeting(BaseModel):
|
||||
gender: Literal["MALE", "FEMALE", "ALL"] | None = None
|
||||
age_from: Annotated[NonNegativeInt, Field(strict=False, ls=100)] | None = (
|
||||
None
|
||||
)
|
||||
age_to: Annotated[NonNegativeInt, Field(strict=False, ls=100)] | None = (
|
||||
None
|
||||
)
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class CampaignCreateIn(BaseModel):
|
||||
targeting: CampaignTargeting
|
||||
ad_title: str
|
||||
ad_text: str
|
||||
impressions_limit: NonNegativeInt
|
||||
clicks_limit: NonNegativeInt
|
||||
cost_per_impression: NonNegativeFloat
|
||||
cost_per_click: NonNegativeFloat
|
||||
start_date: NonNegativeInt
|
||||
end_date: NonNegativeInt
|
||||
|
||||
|
||||
class CampaignUpdateIn(CampaignCreateIn):
|
||||
pass
|
||||
|
||||
|
||||
class CampaignOut(BaseModel):
|
||||
campaign_id: str
|
||||
advertiser_id: str
|
||||
targeting: CampaignTargeting
|
||||
ad_title: str
|
||||
ad_text: str
|
||||
ad_image: str | None = None
|
||||
impressions_limit: NonNegativeInt
|
||||
clicks_limit: NonNegativeInt
|
||||
cost_per_impression: NonNegativeFloat
|
||||
cost_per_click: NonNegativeFloat
|
||||
start_date: NonNegativeInt
|
||||
end_date: NonNegativeInt
|
||||
|
||||
|
||||
class Advertiser(BaseModel):
|
||||
advertiser_id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class Stat(BaseModel):
|
||||
impressions_count: int
|
||||
clicks_count: int
|
||||
conversion: float
|
||||
spent_impressions: float
|
||||
spent_clicks: float
|
||||
spent_total: float
|
||||
|
||||
|
||||
class DailyStat(Stat):
|
||||
date: int
|
||||
|
||||
|
||||
class GenerateAdTextIn(BaseModel):
|
||||
advertiser_name: str
|
||||
ad_title: str
|
||||
|
||||
|
||||
class GenerateAdTextResult(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
result: str
|
||||
@@ -0,0 +1,19 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import DialogManager, StartMode
|
||||
|
||||
from filters.auth import AuthenticatedFilter
|
||||
from states.campaigns import CampaignsDailogState
|
||||
|
||||
campaigns_router = Router()
|
||||
|
||||
|
||||
@campaigns_router.message(Command("campaigns"), AuthenticatedFilter())
|
||||
async def stats_command(
|
||||
message: Message, dialog_manager: DialogManager, state: FSMContext
|
||||
) -> None:
|
||||
await dialog_manager.start(
|
||||
state=CampaignsDailogState.campaigns, mode=StartMode.RESET_STACK
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import DialogManager
|
||||
|
||||
help_router = Router()
|
||||
|
||||
|
||||
@help_router.message(Command("help"))
|
||||
async def help_command(
|
||||
message: Message, dialog_manager: DialogManager, state: FSMContext
|
||||
) -> None:
|
||||
response = (
|
||||
"Commands:\n\n"
|
||||
"/start - Start the bot and authenticate as advertiser\n"
|
||||
"/campaigns - Manage your campaigns\n"
|
||||
"/statistics - See your overall statistics\n"
|
||||
"/logout - Logout of current advertiser account"
|
||||
)
|
||||
await message.answer(response)
|
||||
@@ -0,0 +1,20 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import DialogManager
|
||||
|
||||
logout_router = Router()
|
||||
|
||||
|
||||
@logout_router.message(Command("logout"))
|
||||
async def logout_command(
|
||||
message: Message, dialog_manager: DialogManager, state: FSMContext
|
||||
) -> None:
|
||||
state_data = await state.get_data()
|
||||
|
||||
if state_data["authenticated"]:
|
||||
await dialog_manager.reset_stack()
|
||||
del state_data["advertiser_id"]
|
||||
await state.set_data(state_data)
|
||||
await message.answer("Successfully logged out.")
|
||||
@@ -0,0 +1,29 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import DialogManager, StartMode
|
||||
|
||||
from states.start import StartDialogState
|
||||
|
||||
start_router = Router()
|
||||
|
||||
|
||||
@start_router.message(CommandStart())
|
||||
async def start_command(
|
||||
message: Message, dialog_manager: DialogManager, state: FSMContext
|
||||
) -> None:
|
||||
state_data = await state.get_data()
|
||||
|
||||
if state_data["authenticated"]:
|
||||
await message.answer(
|
||||
"Already authenticated as"
|
||||
f" <code>{state_data['advertiser']['name']}</code> "
|
||||
f"(<code>{state_data['advertiser']['advertiser_id']}</code>)."
|
||||
"Get all commands with /help."
|
||||
)
|
||||
return
|
||||
|
||||
await dialog_manager.start(
|
||||
state=StartDialogState.start, mode=StartMode.RESET_STACK
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import DialogManager
|
||||
|
||||
from api.client import AdNovaClient
|
||||
from filters.auth import AuthenticatedFilter
|
||||
|
||||
statistics_router = Router()
|
||||
|
||||
|
||||
@statistics_router.message(Command("statistics"), AuthenticatedFilter())
|
||||
async def stats_command(
|
||||
message: Message, dialog_manager: DialogManager, state: FSMContext
|
||||
) -> None:
|
||||
state_data = await state.get_data()
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
stats = await client.get_advertiser_statistics(advertiser_id)
|
||||
|
||||
response = (
|
||||
f"📊 Overall <code>{state_data['advertiser']['name']}</code>"
|
||||
" statistics:\n\n"
|
||||
f"\t• Impressions: {stats.impressions_count}\n"
|
||||
f"\t• Clicks: {stats.clicks_count}\n"
|
||||
f"\t• Conversion: {stats.conversion:.2f}%\n"
|
||||
f"\t• Spent on impressions: {stats.spent_impressions:.2f}\n"
|
||||
f"\t• Spent on clicks: {stats.spent_clicks:.2f}\n"
|
||||
f"\t• Spent total: {stats.spent_total:.2f}"
|
||||
)
|
||||
await message.answer(response)
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
BOT_TOKEN = os.getenv("AIOGRAM_BOT_TOKEN", None)
|
||||
|
||||
API_ENDPOINT = os.getenv("AIOGRAM_BACKEND_URL", "http://localhost:8080")
|
||||
|
||||
REDIS_URI = os.getenv("REDIS_URI", "redis://localhost:6379")
|
||||
|
||||
MINIO_URL = f"http://{os.getenv('MINIO_ENDPOINT', 'localhost:9000')}"
|
||||
@@ -0,0 +1,469 @@
|
||||
import tempfile
|
||||
from http import HTTPStatus as status
|
||||
from mimetypes import guess_extension
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, ContentType, Message
|
||||
from aiogram_dialog import Dialog, DialogManager, Window
|
||||
from aiogram_dialog.api.entities import MediaAttachment
|
||||
from aiogram_dialog.widgets.common import Whenable
|
||||
from aiogram_dialog.widgets.input import ManagedTextInput, TextInput
|
||||
from aiogram_dialog.widgets.kbd import Button, ListGroup, ScrollingGroup, Start
|
||||
from aiogram_dialog.widgets.media import DynamicMedia
|
||||
from aiogram_dialog.widgets.text import Const, Format
|
||||
from pydantic import ValidationError
|
||||
|
||||
import config
|
||||
from api.client import AdNovaClient
|
||||
from api.errors import BadRequestError, ForbiddenError
|
||||
from dialogs import utils
|
||||
from states.campaigns import CampaignsDailogState
|
||||
|
||||
campaign_info = (
|
||||
Const('<pre><code class="language-input-format">Title\n'),
|
||||
Const("Text\n"),
|
||||
Const("Impressions limit\n"),
|
||||
Const("Clicks limit\n"),
|
||||
Const("Cost per impression\n"),
|
||||
Const("Cost per click\n"),
|
||||
Const("Start date\n"),
|
||||
Const("End date\n"),
|
||||
Const("Targeting gender (ALL, FEMALE, MALE, None)\n"),
|
||||
Const("Targeting age from (could be None)\n"),
|
||||
Const("Targeting age to (could be None)\n"),
|
||||
Const("Targeting location (could be None)"),
|
||||
Const("</code></pre>"),
|
||||
Const('<pre><code class="language-example">Some title\n'),
|
||||
Const("Some text\n"),
|
||||
Const("15\n"),
|
||||
Const("10\n"),
|
||||
Const("0.4\n"),
|
||||
Const("0.5\n"),
|
||||
Const("100\n"),
|
||||
Const("110\n"),
|
||||
Const("ALL\n"),
|
||||
Const("12\n"),
|
||||
Const("None\n"),
|
||||
Const("Moscow"),
|
||||
Const("</code></pre>"),
|
||||
)
|
||||
|
||||
|
||||
def campaign_has_ad_image(
|
||||
data: dict, widget: Whenable, manager: DialogManager
|
||||
) -> bool:
|
||||
return bool(data["dialog_data"]["campaign"]["ad_image"])
|
||||
|
||||
|
||||
def campaign_has_not_ad_image(
|
||||
data: dict, widget: Whenable, manager: DialogManager
|
||||
) -> bool:
|
||||
return not data["dialog_data"]["campaign"]["ad_image"]
|
||||
|
||||
|
||||
def check_campaign(campaign_data: str) -> None:
|
||||
fields = campaign_data.split("\n\n")
|
||||
|
||||
if len(fields) != 12:
|
||||
raise ValueError
|
||||
|
||||
utils.campaign_from_list(fields)
|
||||
|
||||
|
||||
async def campaigns(**kwargs: dict[Any]) -> dict[str, Any]:
|
||||
state: FSMContext = kwargs["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
campaigns = await client.list_campaigns(state_data["advertiser_id"])
|
||||
|
||||
campaigns = (
|
||||
[campaign.model_dump(mode="json") for campaign in campaigns]
|
||||
if campaigns != []
|
||||
else [{"campaign_id": ""}]
|
||||
)
|
||||
|
||||
return {
|
||||
"campaigns": campaigns,
|
||||
}
|
||||
|
||||
|
||||
async def campaign_by_id(**kwargs: dict[Any]) -> dict[str, Any]:
|
||||
manager: DialogManager = kwargs["dialog_manager"]
|
||||
manager_data = await manager.load_data()
|
||||
|
||||
ad_image_url = manager_data["dialog_data"]["campaign"]["ad_image"]
|
||||
|
||||
if not ad_image_url:
|
||||
return {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(ad_image_url)
|
||||
|
||||
if response.status_code == status.OK:
|
||||
content_type = response.headers.get("Content-Type", "image/jpeg")
|
||||
ext = guess_extension(content_type) or ".jpg"
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=ext, delete=False
|
||||
) as temp_file:
|
||||
temp_file.write(response.content)
|
||||
temp_file.flush()
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
attachment = MediaAttachment(
|
||||
type=ContentType.PHOTO, path=temp_file_path
|
||||
)
|
||||
else:
|
||||
attachment = None
|
||||
|
||||
return {"ad_image": attachment}
|
||||
|
||||
|
||||
async def campaign_on_error(
|
||||
message: Message,
|
||||
widget: ManagedTextInput,
|
||||
dialog_manager: DialogManager,
|
||||
error: object,
|
||||
) -> None:
|
||||
if isinstance(error, ValidationError):
|
||||
await message.answer(f"Invalid campaign data: {error.json()}")
|
||||
elif isinstance(error, ValueError):
|
||||
await message.answer(f"Invalid campaign data {error!s}")
|
||||
|
||||
|
||||
async def campaign_create_on_success(
|
||||
message: Message,
|
||||
widget: ManagedTextInput,
|
||||
dialog_manager: DialogManager,
|
||||
campaign_data: str,
|
||||
) -> None:
|
||||
state = dialog_manager.middleware_data["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
fields = message.text.split("\n\n")
|
||||
|
||||
campaign = utils.campaign_from_list(fields)
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
try:
|
||||
await client.create_campaign(
|
||||
advertiser_id=state_data["advertiser_id"], data=campaign
|
||||
)
|
||||
await dialog_manager.switch_to(CampaignsDailogState.campaigns)
|
||||
except BadRequestError as e:
|
||||
await message.answer(
|
||||
f"Invalid data: {e.model_dump(mode='json')['detail']}"
|
||||
)
|
||||
|
||||
|
||||
async def campaign_edit_on_success(
|
||||
message: Message,
|
||||
widget: ManagedTextInput,
|
||||
dialog_manager: DialogManager,
|
||||
campaign_data: str,
|
||||
) -> None:
|
||||
fields = message.text.split("\n\n")
|
||||
|
||||
new_campaign = utils.campaign_from_list(fields)
|
||||
|
||||
state = dialog_manager.middleware_data["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
manager_data: dict[Any] = await dialog_manager.load_data()
|
||||
|
||||
campaign_id = manager_data["dialog_data"]["campaign"]["campaign_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
try:
|
||||
new_campaign = await client.update_campaign(
|
||||
advertiser_id=state_data["advertiser_id"],
|
||||
campaign_id=campaign_id,
|
||||
data=new_campaign,
|
||||
)
|
||||
await dialog_manager.update(
|
||||
{
|
||||
"campaign": new_campaign.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
await dialog_manager.switch_to(CampaignsDailogState.campaign)
|
||||
except BadRequestError as e:
|
||||
await message.answer(
|
||||
f"Invalid data: {e.model_dump(mode='json')['detail']}"
|
||||
)
|
||||
except ForbiddenError as e:
|
||||
await message.answer(
|
||||
"Forbidden changing campaign: "
|
||||
f"{e.model_dump(mode='json')['detail']}"
|
||||
)
|
||||
|
||||
|
||||
async def campaign_detail_on_click(
|
||||
callback: CallbackQuery, button: Button, manager: DialogManager
|
||||
) -> None:
|
||||
manager_data: dict[Any] = await manager.load_data()
|
||||
state: FSMContext = manager_data["middleware_data"]["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
campaign_id = manager.item_id
|
||||
|
||||
if campaign_id == "":
|
||||
return
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
campaign = await client.get_campaign(
|
||||
advertiser_id=advertiser_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
campaign_statistics = await client.get_campaign_statistics(
|
||||
campaign_id=campaign_id
|
||||
)
|
||||
|
||||
if campaign.ad_image:
|
||||
campaign.ad_image = (
|
||||
f"{config.MINIO_URL}{urlparse(campaign.ad_image).path}"
|
||||
)
|
||||
|
||||
await manager.update(
|
||||
{
|
||||
"campaign": campaign.model_dump(mode="json"),
|
||||
"campaign_statistics": campaign_statistics.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
await callback.answer()
|
||||
await manager.switch_to(CampaignsDailogState.campaign)
|
||||
|
||||
|
||||
async def campaign_edit_on_click(
|
||||
callback: CallbackQuery, button: Button, manager: DialogManager
|
||||
) -> None:
|
||||
manager_data: dict[Any] = await manager.load_data()
|
||||
state: FSMContext = manager_data["middleware_data"]["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
campaign_id = manager_data["dialog_data"]["campaign"]["campaign_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
campaign = await client.get_campaign(
|
||||
advertiser_id=advertiser_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
await manager.update(
|
||||
{
|
||||
"campaign": campaign.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
await callback.answer()
|
||||
await manager.switch_to(CampaignsDailogState.campaign_edit)
|
||||
|
||||
|
||||
async def delete_ad_image(
|
||||
callback: CallbackQuery, button: Button, manager: DialogManager
|
||||
) -> None:
|
||||
manager_data = await manager.load_data()
|
||||
state: FSMContext = manager_data["middleware_data"]["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
campaign = manager_data["dialog_data"]["campaign"]
|
||||
campaign_id = campaign["campaign_id"]
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
await client.delete_ad_image(
|
||||
advertiser_id=advertiser_id, campaign_id=campaign_id
|
||||
)
|
||||
|
||||
campaign["ad_image"] = None
|
||||
|
||||
await callback.answer("Campaign image deleted")
|
||||
|
||||
|
||||
async def delete_campaign(
|
||||
callback: CallbackQuery, button: Button, manager: DialogManager
|
||||
) -> None:
|
||||
manager_data = await manager.load_data()
|
||||
state: FSMContext = manager_data["middleware_data"]["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
campaign = manager_data["dialog_data"]["campaign"]
|
||||
campaign_id = campaign["campaign_id"]
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
await client.delete_campaign(
|
||||
advertiser_id=advertiser_id, campaign_id=campaign_id
|
||||
)
|
||||
|
||||
await callback.answer("Campaign deleted")
|
||||
await manager.switch_to(CampaignsDailogState.campaigns)
|
||||
|
||||
|
||||
async def back_to_campaign(
|
||||
callback: CallbackQuery, button: Button, manager: DialogManager
|
||||
) -> None:
|
||||
manager_data = await manager.load_data()
|
||||
state: FSMContext = manager_data["middleware_data"]["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
campaign = manager_data["dialog_data"]["campaign"]
|
||||
campaign_id = campaign["campaign_id"]
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
|
||||
async with AdNovaClient() as client:
|
||||
campaign = await client.get_campaign(
|
||||
advertiser_id=advertiser_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
await manager.update(
|
||||
{
|
||||
"campaign": campaign.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
await callback.answer()
|
||||
await manager.switch_to(CampaignsDailogState.campaign)
|
||||
|
||||
|
||||
campaigns_dialog = Dialog(
|
||||
Window(
|
||||
Const("Campaigns:"),
|
||||
Start(
|
||||
Const("➕ Create"),
|
||||
id="create_campaign",
|
||||
state=CampaignsDailogState.campaign_create,
|
||||
),
|
||||
ScrollingGroup(
|
||||
ListGroup(
|
||||
Button(
|
||||
Format("{item[campaign_id]}"),
|
||||
id="detail",
|
||||
on_click=campaign_detail_on_click,
|
||||
),
|
||||
id="campaigns",
|
||||
item_id_getter=lambda item: item["campaign_id"],
|
||||
items="campaigns",
|
||||
),
|
||||
id="pagination",
|
||||
width=1,
|
||||
height=4,
|
||||
),
|
||||
state=CampaignsDailogState.campaigns,
|
||||
getter=campaigns,
|
||||
),
|
||||
Window(
|
||||
Const(
|
||||
"Enter campaign info in following format "
|
||||
"(each statement on new line with one enter between each):"
|
||||
),
|
||||
*campaign_info,
|
||||
Start(
|
||||
Const("⬅️ Back to list"),
|
||||
id="back",
|
||||
state=CampaignsDailogState.campaigns,
|
||||
),
|
||||
TextInput(
|
||||
id="campaign",
|
||||
type_factory=check_campaign,
|
||||
on_success=campaign_create_on_success,
|
||||
on_error=campaign_on_error,
|
||||
),
|
||||
state=CampaignsDailogState.campaign_create,
|
||||
),
|
||||
Window(
|
||||
DynamicMedia("ad_image", when=campaign_has_ad_image),
|
||||
Format("• ID: <code>{dialog_data[campaign][campaign_id]}</code>"),
|
||||
Format("• Title: {dialog_data[campaign][ad_title]}"),
|
||||
Format("• Text: {dialog_data[campaign][ad_text]}"),
|
||||
Format(
|
||||
"• Impressions limit: {dialog_data[campaign][impressions_limit]}"
|
||||
),
|
||||
Format("• Clicks limit: {dialog_data[campaign][clicks_limit]}"),
|
||||
Format(
|
||||
"• Cost per impression: "
|
||||
"{dialog_data[campaign][cost_per_impression]}"
|
||||
),
|
||||
Format("• Cost per click: {dialog_data[campaign][cost_per_click]}"),
|
||||
Format("• Start date: {dialog_data[campaign][start_date]}"),
|
||||
Format("• End date: {dialog_data[campaign][end_date]}"),
|
||||
Format("• Targeting"),
|
||||
Format("\t • Gender: {dialog_data[campaign][targeting][gender]}"),
|
||||
Format("\t • Age from: {dialog_data[campaign][targeting][age_from]}"),
|
||||
Format("\t • Age to: {dialog_data[campaign][targeting][age_to]}"),
|
||||
Format("\t • Location: {dialog_data[campaign][targeting][location]}"),
|
||||
Const("\n📊 Statistics:\n"),
|
||||
Format(
|
||||
"Impressions: "
|
||||
"{dialog_data[campaign_statistics][impressions_count]}"
|
||||
),
|
||||
Format("Clicks: {dialog_data[campaign_statistics][clicks_count]}"),
|
||||
Format(
|
||||
"Conversion: "
|
||||
"{dialog_data[campaign_statistics][impressions_count]:.2f}%"
|
||||
),
|
||||
Format(
|
||||
"Spent on impressions: "
|
||||
"{dialog_data[campaign_statistics][impressions_count]:.2f}"
|
||||
),
|
||||
Format(
|
||||
"Spent on clicks: "
|
||||
"{dialog_data[campaign_statistics][spent_clicks]:.2f}"
|
||||
),
|
||||
Format(
|
||||
"Spent total: {dialog_data[campaign_statistics][spent_total]:.2f}"
|
||||
),
|
||||
Button(
|
||||
Const("📝 Edit campaign"),
|
||||
id="edit_campaign",
|
||||
on_click=campaign_edit_on_click,
|
||||
),
|
||||
Start(
|
||||
Const("⬆️ Upload image"),
|
||||
id="upload_ad_image",
|
||||
state=CampaignsDailogState.campaign_upload_ad_image,
|
||||
when=campaign_has_not_ad_image,
|
||||
),
|
||||
Button(
|
||||
Const("🗑️ Delete image"),
|
||||
id="delete_image",
|
||||
on_click=delete_ad_image,
|
||||
when=campaign_has_ad_image,
|
||||
),
|
||||
Button(
|
||||
Const("🗑️ Delete campaign"),
|
||||
id="delete_ad_image",
|
||||
on_click=delete_campaign,
|
||||
),
|
||||
Start(
|
||||
Const("⬅️ Back to list"),
|
||||
id="back",
|
||||
state=CampaignsDailogState.campaigns,
|
||||
),
|
||||
state=CampaignsDailogState.campaign,
|
||||
getter=campaign_by_id,
|
||||
),
|
||||
Window(
|
||||
Const(
|
||||
"Enter new campaign info in following format "
|
||||
"(each statement on new line with one enter between each):"
|
||||
),
|
||||
*campaign_info,
|
||||
Button(
|
||||
Const("⬅️ Back to campaign"),
|
||||
id="back_to_campaign",
|
||||
on_click=back_to_campaign,
|
||||
),
|
||||
TextInput(
|
||||
id="campaign_update",
|
||||
type_factory=check_campaign,
|
||||
on_success=campaign_edit_on_success,
|
||||
on_error=campaign_on_error,
|
||||
),
|
||||
state=CampaignsDailogState.campaign_edit,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from aiogram.types import Message
|
||||
from aiogram_dialog import Dialog, DialogManager, Window
|
||||
from aiogram_dialog.widgets.input import ManagedTextInput, TextInput
|
||||
from aiogram_dialog.widgets.text import Const
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.client import AdNovaClient
|
||||
from api.errors import NotFoundError
|
||||
from api.schemas import Advertiser
|
||||
from states.start import StartDialogState
|
||||
|
||||
|
||||
def check_advertiser_id(advertiser_id: str) -> None:
|
||||
Advertiser.__pydantic_validator__.validate_assignment(
|
||||
Advertiser.model_construct(), "advertiser_id", advertiser_id
|
||||
)
|
||||
|
||||
try:
|
||||
client = AdNovaClient()
|
||||
client.sync_get_advertiser(advertiser_id)
|
||||
except NotFoundError:
|
||||
raise ValueError from None
|
||||
|
||||
|
||||
async def advertiser_id_on_error(
|
||||
message: Message,
|
||||
widget: ManagedTextInput,
|
||||
dialog_manager: DialogManager,
|
||||
error: object,
|
||||
) -> None:
|
||||
if isinstance(error, ValidationError):
|
||||
await message.answer("Invalid advertiser UUID.")
|
||||
elif isinstance(error, ValueError):
|
||||
await message.answer("Advertiser with this UUID not found.")
|
||||
|
||||
|
||||
async def advertiser_id_on_success(
|
||||
message: Message,
|
||||
widget: ManagedTextInput,
|
||||
dialog_manager: DialogManager,
|
||||
advertiser_id: str,
|
||||
) -> None:
|
||||
state = dialog_manager.middleware_data["state"]
|
||||
state_data = await state.get_data()
|
||||
state_data["advertiser_id"] = message.text
|
||||
await state.set_data(state_data)
|
||||
|
||||
await message.answer(
|
||||
f"Successfully authenticated as {message.text}. Get help: /help."
|
||||
)
|
||||
await dialog_manager.mark_closed()
|
||||
|
||||
|
||||
start_dialog = Dialog(
|
||||
Window(
|
||||
Const("Enter adveritser UUID:"),
|
||||
TextInput(
|
||||
id="advertiser_id",
|
||||
type_factory=check_advertiser_id,
|
||||
on_success=advertiser_id_on_success,
|
||||
on_error=advertiser_id_on_error,
|
||||
),
|
||||
state=StartDialogState.start,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from api.schemas import CampaignCreateIn, CampaignTargeting
|
||||
|
||||
|
||||
def campaign_from_list(fields: list[str]) -> CampaignCreateIn:
|
||||
return CampaignCreateIn(
|
||||
targeting=CampaignTargeting(
|
||||
gender=None if fields[8] == "None" else fields[8],
|
||||
age_from=None if fields[9] == "None" else fields[9],
|
||||
age_to=None if fields[10] == "None" else fields[10],
|
||||
location=None if fields[11] == "None" else fields[11],
|
||||
),
|
||||
ad_title=fields[0],
|
||||
ad_text=fields[1],
|
||||
impressions_limit=fields[2],
|
||||
clicks_limit=fields[3],
|
||||
cost_per_impression=fields[4],
|
||||
cost_per_click=fields[5],
|
||||
start_date=fields[6],
|
||||
end_date=fields[7],
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
from aiogram.filters import Filter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
class AuthenticatedFilter(Filter):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
) -> bool:
|
||||
state_data = await state.get_data()
|
||||
|
||||
return bool(state_data.get("authenticated"))
|
||||
@@ -0,0 +1,69 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.fsm.storage.redis import DefaultKeyBuilder, RedisStorage
|
||||
from aiogram_dialog import setup_dialogs
|
||||
|
||||
import config
|
||||
from commands.campaigns import campaigns_router
|
||||
from commands.help import help_router
|
||||
from commands.logout import logout_router
|
||||
from commands.start import start_router
|
||||
from commands.stats import statistics_router
|
||||
from dialogs.campaigns import campaigns_dialog
|
||||
from dialogs.start import start_dialog
|
||||
from middlewares.auth import AuthMiddleware
|
||||
from middlewares.throttling import ThrottlingMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format=(
|
||||
"%(levelname)-8s %(filename)s:%(lineno)d"
|
||||
" [%(asctime)s] - %(name)s - %(message)s"
|
||||
),
|
||||
)
|
||||
logger.info("Starting bot...")
|
||||
|
||||
bot: Bot = Bot(
|
||||
config.BOT_TOKEN, default=DefaultBotProperties(parse_mode="HTML")
|
||||
)
|
||||
dp: Dispatcher = Dispatcher(
|
||||
storage=RedisStorage.from_url(
|
||||
config.REDIS_URI,
|
||||
key_builder=DefaultKeyBuilder(with_destiny=True),
|
||||
),
|
||||
)
|
||||
|
||||
dp.message.middleware(ThrottlingMiddleware(0.5))
|
||||
dp.message.outer_middleware(AuthMiddleware())
|
||||
|
||||
dp.include_routers(
|
||||
help_router,
|
||||
start_router,
|
||||
campaigns_router,
|
||||
statistics_router,
|
||||
logout_router,
|
||||
)
|
||||
dp.include_routers(start_dialog, campaigns_dialog)
|
||||
|
||||
setup_dialogs(dp)
|
||||
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
await dp.storage.close()
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logger.info("Stopping bot...")
|
||||
@@ -0,0 +1,43 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import Message
|
||||
|
||||
from api.client import AdNovaClient
|
||||
from api.errors import HTTPError
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
state: FSMContext = data["state"]
|
||||
state_data = await state.get_data()
|
||||
|
||||
if "advertiser_id" in state_data:
|
||||
advertiser_id = state_data["advertiser_id"]
|
||||
async with AdNovaClient() as client:
|
||||
try:
|
||||
advertiser = await client.get_advertiser(advertiser_id)
|
||||
state_data["authenticated"] = True
|
||||
state_data["advertiser"] = advertiser.model_dump(
|
||||
mode="json"
|
||||
)
|
||||
except HTTPError:
|
||||
state_data["authenticated"] = False
|
||||
state_data["advertiser_id"] = None
|
||||
else:
|
||||
state_data["authenticated"] = False
|
||||
state_data["advertiser_id"] = None
|
||||
|
||||
await state.set_data(state_data)
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,24 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message
|
||||
from cachetools import TTLCache
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
def __init__(self, time_limit: float = 2) -> None:
|
||||
self.limit = TTLCache(maxsize=10_000, ttl=time_limit)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
|
||||
event: Message,
|
||||
data: dict[str, Any],
|
||||
) -> Any | None:
|
||||
if event.chat.id in self.limit:
|
||||
return None
|
||||
|
||||
self.limit[event.chat.id] = None
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,116 @@
|
||||
[project]
|
||||
name = "adnova-telegram_bot"
|
||||
version = "0.1.0"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.12"
|
||||
dependencies = [
|
||||
"aiogram-dialog>=2.3.1",
|
||||
"aiogram>=3.17.0",
|
||||
"cachetools>=5.5.1",
|
||||
"httpx>=0.28.1",
|
||||
"openapi-python-client>=0.23.1",
|
||||
"python-dotenv>=1.0.1",
|
||||
"redis>=5.2.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff>=0.9.6",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
builtins = []
|
||||
cache-dir = ".ruff_cache"
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"dist",
|
||||
"migrations",
|
||||
"node_modules",
|
||||
"venv",
|
||||
]
|
||||
extend-exclude = []
|
||||
extend-include = []
|
||||
fix = false
|
||||
fix-only = false
|
||||
force-exclude = true
|
||||
include = ["*.py", "*.pyi", "*.ipynb", "**/pyproject.toml"]
|
||||
indent-width = 4
|
||||
line-length = 79
|
||||
namespace-packages = []
|
||||
output-format = "full"
|
||||
preview = false
|
||||
required-version = ">=0.8.4"
|
||||
respect-gitignore = true
|
||||
show-fixes = true
|
||||
src = [".", "src"]
|
||||
target-version = "py310"
|
||||
unsafe-fixes = false
|
||||
|
||||
[tool.ruff.analyze]
|
||||
detect-string-imports = true
|
||||
direction = "Dependencies"
|
||||
exclude = []
|
||||
include-dependencies = {}
|
||||
preview = false
|
||||
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = true
|
||||
docstring-code-line-length = 79
|
||||
exclude = []
|
||||
indent-style = "space"
|
||||
line-ending = "lf"
|
||||
preview = false
|
||||
quote-style = "double"
|
||||
skip-magic-trailing-comma = false
|
||||
|
||||
[tool.ruff.lint]
|
||||
allowed-confusables = ["ℹ"]
|
||||
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
exclude = ["tests.py"]
|
||||
explicit-preview-rules = false
|
||||
extend-fixable = []
|
||||
extend-per-file-ignores = {}
|
||||
extend-safe-fixes = []
|
||||
extend-select = []
|
||||
extend-unsafe-fixes = []
|
||||
external = []
|
||||
fixable = ["ALL"]
|
||||
ignore = [
|
||||
"ARG",
|
||||
"D",
|
||||
"ANN401",
|
||||
"COM812",
|
||||
"DJ001",
|
||||
"FBT001",
|
||||
"FBT002",
|
||||
"N813",
|
||||
"PLR2004",
|
||||
"RUF001",
|
||||
"TC002",
|
||||
]
|
||||
logger-objects = []
|
||||
per-file-ignores = {}
|
||||
preview = false
|
||||
select = ["ALL"]
|
||||
task-tags = ["TODO", "FIXME", "HACK", "WORKOUT"]
|
||||
typing-modules = []
|
||||
unfixable = []
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
max-args = 6
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
GREEN='\033[1;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
uvx ruff format .
|
||||
uvx ruff check . --fix
|
||||
printf "${GREEN}Linters/formatters runned${NC}\n"
|
||||
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class AuthState(StatesGroup):
|
||||
advertiser_id = State()
|
||||
@@ -0,0 +1,9 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class CampaignsDailogState(StatesGroup):
|
||||
campaigns = State()
|
||||
campaign = State()
|
||||
campaign_upload_ad_image = State()
|
||||
campaign_create = State()
|
||||
campaign_edit = State()
|
||||
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class StartDialogState(StatesGroup):
|
||||
start = State()
|
||||
Reference in New Issue
Block a user