diff --git a/solution/services/telegram_bot/.env.template b/solution/services/telegram_bot/.env.template index 56922d2..e046608 100644 --- a/solution/services/telegram_bot/.env.template +++ b/solution/services/telegram_bot/.env.template @@ -4,3 +4,4 @@ AIOGRAM_BOT_TOKEN= AIOGRAM_BACKEND_ADDRESS=http://localhost:8080 REDIS_URI=redis://localhost:6379 +MINIO_ENDPOINT=localhost:9000 diff --git a/solution/services/telegram_bot/config.py b/solution/services/telegram_bot/config.py index 11a434e..b472db5 100644 --- a/solution/services/telegram_bot/config.py +++ b/solution/services/telegram_bot/config.py @@ -12,3 +12,5 @@ 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')}" diff --git a/solution/services/telegram_bot/dialogs/campaigns.py b/solution/services/telegram_bot/dialogs/campaigns.py index 012b4b5..98874f5 100644 --- a/solution/services/telegram_bot/dialogs/campaigns.py +++ b/solution/services/telegram_bot/dialogs/campaigns.py @@ -1,13 +1,23 @@ +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 +from aiogram.types import CallbackQuery, ContentType from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, ListGroup, ScrollingGroup +from aiogram_dialog.api.entities import MediaAttachment +from aiogram_dialog.widgets.common import Whenable +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 +import config from api.client import AdNovaClient from states.campaigns import CampaignsDailogState +from states.campaign import CampaignDialogState async def campaigns(**kwargs: dict[Any]) -> dict[str, Any]: @@ -17,7 +27,11 @@ async def campaigns(**kwargs: dict[Any]) -> dict[str, Any]: async with AdNovaClient() as client: campaigns = await client.list_campaigns(state_data["advertiser_id"]) - campaigns = [campaign.model_dump(mode="json") for campaign in campaigns] + campaigns = ( + [campaign.model_dump(mode="json") for campaign in campaigns] + if campaigns != [] + else [{"campaign_id": "‎"}] + ) return { "campaigns": campaigns, @@ -27,16 +41,130 @@ async def campaigns(**kwargs: dict[Any]) -> dict[str, Any]: 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, + ) + + 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")}) await callback.answer() + await manager.switch_to(CampaignsDailogState.campaign) + + +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"] + + +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 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_list( + callback: CallbackQuery, button: Button, manager: DialogManager +) -> None: + await manager.switch_to(CampaignsDailogState.campaigns) campaigns_dialog = Dialog( Window( Const("Campaigns:"), + Button(Const("➕ Create"), id="create_campaign"), ScrollingGroup( ListGroup( Button( - Format("{item[ad_title]}"), + Format("{item[campaign_id]}"), id="detail", on_click=campaign_detail_on_click, ), @@ -51,4 +179,48 @@ campaigns_dialog = Dialog( state=CampaignsDailogState.campaigns, getter=campaigns, ), + Window( + DynamicMedia("ad_image", when=campaign_has_ad_image), + Format("• ID: {dialog_data[campaign][campaign_id]}"), + 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]}"), + Button(Const("📝 Edit campaign"), id="edit_campaign"), + Start( + Const("⬆️ Upload image"), + id="upload_ad_image", + state=CampaignDialogState.delete_ad_image, + data= + 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, + ), + Button(Const("⬅️ Back to list"), id="back", on_click=back_to_list), + state=CampaignsDailogState.campaign, + getter=campaign_by_id, + ), ) diff --git a/solution/services/telegram_bot/states/campaign.py b/solution/services/telegram_bot/states/campaign.py index af4aacf..e305290 100644 --- a/solution/services/telegram_bot/states/campaign.py +++ b/solution/services/telegram_bot/states/campaign.py @@ -1,7 +1,7 @@ from aiogram.fsm.state import State, StatesGroup -class CampaignState(StatesGroup): +class CampaignDialogState(StatesGroup): ad_title = State() ad_text = State() impressions_limit = State() @@ -14,3 +14,4 @@ class CampaignState(StatesGroup): age_from = State() age_to = State() location = State() + delete_ad_image = State()