From c0f35512a13cdd4bc7f0dff8a82a04bf4988ea64 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 22 Feb 2025 07:26:04 +0300 Subject: [PATCH] feat(telegram_bot): added campaign creation and updating, added help command --- solution/services/telegram_bot/api/schemas.py | 6 +- .../services/telegram_bot/commands/help.py | 21 ++ .../services/telegram_bot/commands/start.py | 3 +- .../services/telegram_bot/commands/stats.py | 6 +- .../telegram_bot/dialogs/campaigns.py | 340 +++++++++++++++--- .../services/telegram_bot/dialogs/start.py | 4 +- .../services/telegram_bot/dialogs/utils.py | 20 ++ solution/services/telegram_bot/main.py | 2 + solution/services/telegram_bot/pyproject.toml | 1 + .../services/telegram_bot/states/campaigns.py | 1 + 10 files changed, 349 insertions(+), 55 deletions(-) create mode 100644 solution/services/telegram_bot/commands/help.py create mode 100644 solution/services/telegram_bot/dialogs/utils.py diff --git a/solution/services/telegram_bot/api/schemas.py b/solution/services/telegram_bot/api/schemas.py index b99487e..b687847 100644 --- a/solution/services/telegram_bot/api/schemas.py +++ b/solution/services/telegram_bot/api/schemas.py @@ -18,10 +18,12 @@ class NotFoundError(BaseModel): class CampaignTargeting(BaseModel): gender: Literal["MALE", "FEMALE", "ALL"] | None = None - age_from: Annotated[NonNegativeInt, Field(strict=True, ls=100)] | None = ( + age_from: Annotated[NonNegativeInt, Field(strict=False, ls=100)] | None = ( + None + ) + age_to: Annotated[NonNegativeInt, Field(strict=False, ls=100)] | None = ( None ) - age_to: Annotated[NonNegativeInt, Field(strict=True, ls=100)] | None = None location: str | None = None diff --git a/solution/services/telegram_bot/commands/help.py b/solution/services/telegram_bot/commands/help.py new file mode 100644 index 0000000..306c485 --- /dev/null +++ b/solution/services/telegram_bot/commands/help.py @@ -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) diff --git a/solution/services/telegram_bot/commands/start.py b/solution/services/telegram_bot/commands/start.py index da1672f..eb49277 100644 --- a/solution/services/telegram_bot/commands/start.py +++ b/solution/services/telegram_bot/commands/start.py @@ -19,7 +19,8 @@ async def start_command( await message.answer( "Already authenticated as" f" {state_data['advertiser']['name']} " - f"({state_data['advertiser']['advertiser_id']})" + f"({state_data['advertiser']['advertiser_id']})." + "Get all commands with /help." ) return diff --git a/solution/services/telegram_bot/commands/stats.py b/solution/services/telegram_bot/commands/stats.py index 03a381e..83b93fc 100644 --- a/solution/services/telegram_bot/commands/stats.py +++ b/solution/services/telegram_bot/commands/stats.py @@ -26,8 +26,8 @@ async def stats_command( 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}" + 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) diff --git a/solution/services/telegram_bot/dialogs/campaigns.py b/solution/services/telegram_bot/dialogs/campaigns.py index e9447d0..1698cf6 100644 --- a/solution/services/telegram_bot/dialogs/campaigns.py +++ b/solution/services/telegram_bot/dialogs/campaigns.py @@ -6,18 +6,72 @@ from urllib.parse import urlparse import httpx from aiogram.fsm.context import FSMContext -from aiogram.types import CallbackQuery, ContentType +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('
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("
"), + Const('
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("
"), +) + + +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"] @@ -37,50 +91,10 @@ 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: @@ -109,6 +123,146 @@ async def campaign_by_id(**kwargs: dict[Any]) -> dict[str, Any]: 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: @@ -150,16 +304,40 @@ async def delete_campaign( await manager.switch_to(CampaignsDailogState.campaigns) -async def back_to_list( +async def back_to_campaign( callback: CallbackQuery, button: Button, manager: DialogManager ) -> None: - await manager.switch_to(CampaignsDailogState.campaigns) + 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:"), - Button(Const("➕ Create"), id="create_campaign"), + Start( + Const("➕ Create"), + id="create_campaign", + state=CampaignsDailogState.campaign_create, + ), ScrollingGroup( ListGroup( Button( @@ -178,6 +356,25 @@ campaigns_dialog = Dialog( 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: {dialog_data[campaign][campaign_id]}"), @@ -199,12 +396,36 @@ campaigns_dialog = Dialog( 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"), + 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, - data=None, when=campaign_has_not_ad_image, ), Button( @@ -218,8 +439,31 @@ campaigns_dialog = Dialog( id="delete_ad_image", on_click=delete_campaign, ), - Button(Const("⬅️ Back to list"), id="back", on_click=back_to_list), + 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, + ), ) diff --git a/solution/services/telegram_bot/dialogs/start.py b/solution/services/telegram_bot/dialogs/start.py index c85cd60..0cea34f 100644 --- a/solution/services/telegram_bot/dialogs/start.py +++ b/solution/services/telegram_bot/dialogs/start.py @@ -45,7 +45,9 @@ async def advertiser_id_on_success( state_data["advertiser_id"] = message.text await state.set_data(state_data) - await message.answer(f"Successfully authenticated as {message.text}") + await message.answer( + f"Successfully authenticated as {message.text}. Get help: /help." + ) await dialog_manager.mark_closed() diff --git a/solution/services/telegram_bot/dialogs/utils.py b/solution/services/telegram_bot/dialogs/utils.py new file mode 100644 index 0000000..4bc52ca --- /dev/null +++ b/solution/services/telegram_bot/dialogs/utils.py @@ -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], + ) diff --git a/solution/services/telegram_bot/main.py b/solution/services/telegram_bot/main.py index d8e1b20..4a51d09 100644 --- a/solution/services/telegram_bot/main.py +++ b/solution/services/telegram_bot/main.py @@ -8,6 +8,7 @@ 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 @@ -43,6 +44,7 @@ async def main() -> None: dp.message.outer_middleware(AuthMiddleware()) dp.include_routers( + help_router, start_router, campaigns_router, statistics_router, diff --git a/solution/services/telegram_bot/pyproject.toml b/solution/services/telegram_bot/pyproject.toml index bad8265..afbc664 100644 --- a/solution/services/telegram_bot/pyproject.toml +++ b/solution/services/telegram_bot/pyproject.toml @@ -100,6 +100,7 @@ ignore = [ "FBT001", "FBT002", "N813", + "PLR2004", "RUF001", "TC002", ] diff --git a/solution/services/telegram_bot/states/campaigns.py b/solution/services/telegram_bot/states/campaigns.py index b7e4e4b..2ae9bd1 100644 --- a/solution/services/telegram_bot/states/campaigns.py +++ b/solution/services/telegram_bot/states/campaigns.py @@ -5,4 +5,5 @@ class CampaignsDailogState(StatesGroup): campaigns = State() campaign = State() campaign_upload_ad_image = State() + campaign_create = State() campaign_edit = State()