chore: restructured project

This commit is contained in:
ITQ
2025-03-07 19:32:09 +03:00
parent bfb7ad901a
commit 0a35951c62
178 changed files with 304 additions and 376 deletions
+181
View File
@@ -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
+7
View File
@@ -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
+170
View File
@@ -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
+37
View File
@@ -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
+83
View File
@@ -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
```
+168
View File
@@ -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())
+23
View File
@@ -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
+87
View File
@@ -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
)
+21
View File
@@ -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)
+20
View File
@@ -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.")
+29
View File
@@ -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
)
+33
View File
@@ -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)
+16
View File
@@ -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')}"
+469
View File
@@ -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,
),
)
+65
View File
@@ -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,
),
)
+20
View File
@@ -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],
)
+17
View File
@@ -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"))
+69
View File
@@ -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...")
+43
View File
@@ -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)
+116
View File
@@ -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
+8
View File
@@ -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"
+5
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
from aiogram.fsm.state import State, StatesGroup
class StartDialogState(StatesGroup):
start = State()