diff --git a/handlers/admin/create_campaign.py b/handlers/admin/create_campaign.py index b184a3f..20dc3e1 100644 --- a/handlers/admin/create_campaign.py +++ b/handlers/admin/create_campaign.py @@ -67,10 +67,10 @@ async def on_description_entered( async def on_icon_entered(mes: Message, wid: MessageInput, dialog_manager: DialogManager): - if mes.photo: + if mes.photo and mes.bot: photo = mes.photo[-1] file = await mes.bot.get_file(photo.file_id) - bin_stream: BinaryIO = await mes.bot.download_file(file.file_path) + bin_stream: BinaryIO | None = await mes.bot.download_file(file.file_path or "") object_id = uuid.uuid4() settings.minio.put_object( @@ -99,7 +99,7 @@ async def on_confirm(mes: CallbackQuery, button: Button, dialog_manager: DialogM new_campaign: Campaign = await Campaign.create( title=campaign_data.get("title", ""), description=campaign_data.get("description", ""), - icon=campaign_data.get("icon", ""), + icon=campaign_data.get("icon"), verified=verified, ) diff --git a/handlers/admin/inventory_management.py b/handlers/admin/inventory_management.py index cacf9e8..bf91f80 100644 --- a/handlers/admin/inventory_management.py +++ b/handlers/admin/inventory_management.py @@ -256,7 +256,7 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) add_item_description_window = Window( - Format("Название {new_item_name}"), + Format("📦 Название: {new_item_name}"), Const("📝 Введите описание предмета (или '-' чтобы пропустить):"), TextInput( id="item_description_input", @@ -268,8 +268,8 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) add_item_quantity_window = Window( - Format("Название: {new_item_name}"), - Format("Описание {new_item_description}"), + Format("📦 Название: {new_item_name}"), + Format("📄 Описание {new_item_description}"), Const("🔢 Введите количество предмета:"), TextInput( id="item_quantity_input", @@ -283,32 +283,31 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial edit_inventory_item_window = Window( Multi( Const("✏️ Редактирование предмета"), - Format("📦 {item.title}"), + Format("📦 Название: {item.title}"), Format("📝 Описание: {item.description}", when="has_description"), - Const("📝 Описание отсутствует", when=lambda data, *_: not data.get("item", {}).description), + Const("📭 Описание отсутствует", when=lambda data, *_: not data.get("item", {}).description), Format("🔢 Количество: {item.quantity}"), - Const(""), - Const("Выберите что изменить:"), + Const("\nВыберите что изменить:"), sep="\n", ), Group( SwitchTo( - Const("✏️ Название"), + Const("✏️ Изменить название"), id="edit_name", state=states.ManageInventory.edit_inventory_item_name, ), SwitchTo( - Const("📝 Описание"), + Const("📝 Изменить описание"), id="edit_description", state=states.ManageInventory.edit_inventory_item_description, ), SwitchTo( - Const("🔢 Количество"), + Const("🔢 Изменить количество"), id="edit_quantity", state=states.ManageInventory.edit_inventory_item_quantity, ), SwitchTo( - Const("🗑️ Удалить"), + Const("🗑️ Удалить предмет"), id="delete_item", state=states.ManageInventory.accept_delete, ), @@ -354,7 +353,7 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) accept_delete_item_window = Window( - Const("🎯 Точно удалить?"), + Const("⚠️ Вы точно хотите удалить этот предмет?"), Button( Const("🚫 Удалить"), id="accept_delete", diff --git a/handlers/admin/invitation.py b/handlers/admin/invitation.py index 9ead916..38806e5 100644 --- a/handlers/admin/invitation.py +++ b/handlers/admin/invitation.py @@ -6,7 +6,7 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import ManagedTextInput, TextInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next, Row from aiogram_dialog.widgets.link_preview import LinkPreview from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format, Multi @@ -28,7 +28,7 @@ async def get_link(dialog_manager: DialogManager, **_): created_by: User = dialog_manager.middleware_data["user"] - link = dialog_manager.dialog_data.get("link") + link = dialog_manager.dialog_data.get("link", "") if "link" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): campaign_id = dialog_manager.start_data.get("campaign_id", 0) role = dialog_manager.start_data.get("role", Role.PLAYER) @@ -177,16 +177,24 @@ async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager getter=get_qr, ) - invite_window = Window( - Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), - Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), - Cancel(Const("❌ Отказаться")), + Multi( + Const("🎉 Вам пришло приглашение!"), + Const(""), + Format("🏰 Кампания: {campaign_title}"), + Format("👑 Роль: {role}"), + Const(""), + Const("Присоединиться к кампании?"), + sep="\n", + ), + Row( + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), + ), getter=invitation_getter, state=states.InviteMenu.invite, ) - # === Создание диалога и роутера === dialog = Dialog(invite_menu_window, qr_window, invite_window) router = Router() diff --git a/handlers/player/academy.py b/handlers/player/academy.py index 06ff79b..bfba651 100644 --- a/handlers/player/academy.py +++ b/handlers/player/academy.py @@ -4,9 +4,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, Column +from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Row from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from services.character_data import character_preview_getter from states.academy import Academy @@ -41,26 +41,46 @@ async def on_campaigns(c: CallbackQuery, b: Button, m: DialogManager): async def character_data_getter(dialog_manager: DialogManager, **kwargs) -> dict: user = dialog_manager.middleware_data["user"] - data = json.loads(user.data["data"]) - - return character_preview_getter(user, data) - - -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Column( - Button(Const("Посмотреть инвентарь"), id="inventory", on_click=on_inventory), - Button(Const("Загрузить обновленный .json"), id="update_json", on_click=on_update), - Button(Const("Рейтинг"), id="rating", on_click=on_rating), - Button(Const("Кампании внутри академии"), id="campaigns", on_click=on_campaigns), - Cancel(Const("Назад")), + + if user.data is None: + return {"has_character_data": False, "avatar": False} + + data = json.loads(user.data.get("data", "{}")) + + character_preview = character_preview_getter(user, data) + + return { + **character_preview, + "has_character_data": True, + } + + +academy_dialog = Dialog( + Window( + Multi( + Const("🎓 Ваш профиль в академии"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="has_character_data"), + Const( + "📭 У вас пока нет загруженного персонажа", when=lambda data, *_: not data.get("has_character_data", False) + ), + Column( + Row( + Button(Const("🏰 Кампании"), id="campaigns", on_click=on_campaigns), + Button(Const("🎒 Инвентарь"), id="inventory", on_click=on_inventory), + ), + Row( + Button(Const("📈 Рейтинг"), id="rating", on_click=on_rating), + Button(Const("📤 Обновить .json"), id="update_json", on_click=on_update), ), - getter=character_data_getter, - state=Academy.main, + Cancel(Const("⬅️ Назад")), ), - on_start=redirect, - ) + getter=character_data_getter, + state=Academy.main, + ), + on_start=redirect, ) + +router.include_router(academy_dialog) diff --git a/handlers/player/academy_campaigns.py b/handlers/player/academy_campaigns.py index 3b8efe7..eeca7d2 100644 --- a/handlers/player/academy_campaigns.py +++ b/handlers/player/academy_campaigns.py @@ -4,9 +4,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Cancel, ScrollingGroup, Select from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Participation from services.campaigns import campaign_getter @@ -19,12 +19,15 @@ async def campaigns_getter(dialog_manager: DialogManager, **kwargs): user = dialog_manager.middleware_data["user"] + participations = await Participation.filter(user=user, campaign__verified=True).prefetch_related("campaign").all() + return { - "campaigns": await Participation.filter(user=user, campaign__verified=True).prefetch_related("campaign").all() + "campaigns": participations, + "has_campaigns": len(participations) > 0, } -async def on_campaign(c: CallbackQuery, b: Button, m: DialogManager, participation_id: UUID): +async def on_campaign(c: CallbackQuery, b: Select, m: DialogManager, participation_id: UUID): participation = await Participation.get(id=participation_id).prefetch_related("campaign") await m.start( AcademyCampaignPreview.preview, @@ -32,40 +35,56 @@ async def on_campaign(c: CallbackQuery, b: Button, m: DialogManager, participati ) -router.include_router( - Dialog( - Window( - Const("Кампании внутри академии, в которых вы участвуете"), - ScrollingGroup( - Select( - Format("{item.campaign.title}"), - id="campaign", - items="campaigns", - item_id_getter=lambda x: x.id, - on_click=on_campaign, - ), - hide_on_single_page=True, - height=5, - id="campaigns", +# Диалог списка кампаний академии +campaigns_dialog = Dialog( + Window( + Multi( + Const("🏰 Кампании академии"), + Const(""), + Const("Здесь собраны официальные кампании, в которых вы участвуете."), + Const(""), + Const("📭 У вас пока нет кампаний в академии", when=lambda data, *_: not data.get("has_campaigns", False)), + sep="\n", + ), + ScrollingGroup( + Select( + Format("🎮 {item.campaign.title}"), + id="campaign", + items="campaigns", + item_id_getter=lambda x: x.id, + on_click=on_campaign, + type_factory=UUID, ), - Cancel(Const("Назад")), - getter=campaigns_getter, - state=AcademyCampaigns.campaigns, + hide_on_single_page=True, + height=5, + id="campaigns", + when="has_campaigns", ), - on_start=redirect, - ) + Cancel(Const("⬅️ Назад")), + getter=campaigns_getter, + state=AcademyCampaigns.campaigns, + ), + on_start=redirect, ) -router.include_router( - Dialog( - Window( - Format("Информация о кампании: {title}\n\nОписание: {description}\n\nВыберите действие:"), - DynamicMedia("icon"), - Cancel(Const("Назад")), - getter=campaign_getter, - state=AcademyCampaignPreview.preview, +# Диалог предпросмотра кампании +preview_dialog = Dialog( + Window( + DynamicMedia("icon"), + Multi( + Format("🎓 Информация о кампании: {title}"), + Const(""), + Format("📝 Описание: {description}"), + Const(""), + Const("Здесь вы можете управлять своей кампанией."), + sep="\n", ), - on_start=redirect, - ) + Cancel(Const("⬅️ Назад")), + getter=campaign_getter, + state=AcademyCampaignPreview.preview, + ), + on_start=redirect, ) + +router.include_routers(campaigns_dialog, preview_dialog) diff --git a/handlers/player/inventory.py b/handlers/player/inventory.py index 83b23fc..4a494bb 100644 --- a/handlers/player/inventory.py +++ b/handlers/player/inventory.py @@ -5,7 +5,7 @@ from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Back, Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from pydantic import BaseModel, field_validator from db.models import Campaign, Item @@ -59,6 +59,7 @@ async def inventory_data_getter(dialog_manager: DialogManager, **kwargs) -> dict return { "inventory": items, "has_items": len(items) > 0, + "items_count": len(items), } @@ -69,45 +70,59 @@ async def get_inventory_item_data(dialog_manager: DialogManager, **kwargs): return {"item": None} item = await Item.get(id=item_id) - return {"item": item, "has_description": item.description != ""} + return { + "item": item, + "has_description": item.description != "", + "is_empty": item.description == "", + } -async def on_inventory_item_selected(c: CallbackQuery, b: Button, m: DialogManager, item_id: UUID): +async def on_inventory_item_selected(c: CallbackQuery, b: Select, m: DialogManager, item_id: UUID): m.dialog_data["selected_item_id"] = item_id await m.switch_to(InventoryView.preview) -router.include_router( - Dialog( - Window( - Format("Инвентарь персонажа"), - ScrollingGroup( - Select( - Format("{item.title} ×{item.quantity}"), - id="inventory_select", - item_id_getter=lambda item: item.id, - items="inventory", - on_click=on_inventory_item_selected, - type_factory=UUID, - ), - id="inventory_scroll", - width=1, - height=10, - hide_on_single_page=True, - when="has_items", +inventory_dialog = Dialog( + Window( + Multi( + Const("🎒 Инвентарь персонажа"), + Const(""), + Format("📦 Всего предметов: {items_count}", when="has_items"), + Const("📭 Инвентарь пуст", when=lambda data, *_: not data.get("has_items", False)), + Const("Выберите предмет для просмотра:"), + sep="\n", + ), + ScrollingGroup( + Select( + Format("📦 {item.title} ×{item.quantity}"), + id="inventory_select", + item_id_getter=lambda item: item.id, + items="inventory", + on_click=on_inventory_item_selected, + type_factory=UUID, ), - Cancel(Const("Назад")), - getter=inventory_data_getter, - state=InventoryView.view, + id="inventory_scroll", + width=1, + height=8, + hide_on_single_page=True, + when="has_items", ), - Window( - Format("📦 {item.title}"), + Cancel(Const("⬅️ Назад")), + getter=inventory_data_getter, + state=InventoryView.view, + ), + Window( + Multi( + Format("📦 Предмет: {item.title}"), + Const(""), Format("📝 Описание: {item.description}", when="has_description"), - Const("📝 Описание отсутствует", when=~F["has_description"]), + Const("📭 Описание отсутствует", when=~F["has_description"]), Format("🔢 Количество: {item.quantity}"), - Back(Const("Назад")), - getter=get_inventory_item_data, - state=InventoryView.preview, ), - ) + Back(Const("⬅️ Назад к инвентарю")), + getter=get_inventory_item_data, + state=InventoryView.preview, + ), ) + +router.include_router(inventory_dialog) diff --git a/handlers/player/invitation.py b/handlers/player/invitation.py index 0fa6a83..cfce6f9 100644 --- a/handlers/player/invitation.py +++ b/handlers/player/invitation.py @@ -3,8 +3,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.kbd import Button, Cancel -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Button, Cancel, Row +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Invitation from services.invitation import handle_accept_invitation, invitation_getter @@ -53,13 +53,24 @@ async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager pass -invite_window = Window( - Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), - Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), - Cancel(Const("❌ Отказаться")), - getter=invitation_getter, - state=InvitationAccept.invitation, +invite_dialog = Dialog( + Window( + Multi( + Const("🎉 Вам пришло приглашение!"), + Const(""), + Format("🏰 Кампания: {campaign_title}"), + Format("👑 Роль: {role}"), + Const(""), + Const("Присоединиться к кампании?"), + sep="\n", + ), + Row( + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), + ), + getter=invitation_getter, + state=InvitationAccept.invitation, + ) ) - -router.include_router(Dialog(invite_window)) +router.include_router(invite_dialog) diff --git a/handlers/player/other_games.py b/handlers/player/other_games.py index e4fcb87..1efc88d 100644 --- a/handlers/player/other_games.py +++ b/handlers/player/other_games.py @@ -4,8 +4,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Campaign, Character, Participation, User from states.other_games import OtherGames @@ -42,10 +42,13 @@ async def available_campaigns_getter(dialog_manager: DialogManager, **kwargs) -> for p in (await Participation.filter(user=user, campaign__verified=False).prefetch_related("campaign").all()) ] - return {"participations": participations, "participations_exist": len(participations) > 0} + return { + "participations": participations, + "has_participations": len(participations) > 0, + } -async def on_character_selected(c: CallbackQuery, b: Button, m: DialogManager, character_id: UUID): +async def on_character_selected(c: CallbackQuery, b: Select, m: DialogManager, character_id: UUID): await m.start( OtherGamesCharacter.preview, data={ @@ -58,7 +61,7 @@ async def on_available_games(c: CallbackQuery, b: Button, m: DialogManager): await m.switch_to(OtherGames.available) -async def on_campaign_selected(c: CallbackQuery, b: Button, m: DialogManager, participation_id: UUID): +async def on_campaign_selected(c: CallbackQuery, b: Select, m: DialogManager, participation_id: UUID): user: User = m.middleware_data["user"] participation = await Participation.get(id=participation_id).prefetch_related("campaign") campaign: Campaign = participation.campaign @@ -76,50 +79,71 @@ async def on_campaign_selected(c: CallbackQuery, b: Button, m: DialogManager, pa ) -router.include_router( - Dialog( - Window( - Const("Вы находитесь в меню неофициальных игр"), - ScrollingGroup( - Select( - Format("{item[1].name} - {item[2].title}"), - id="character_select", - items="characters_data", - item_id_getter=lambda c: c[0].id, - on_click=on_character_selected, - type_factory=UUID, - ), - id="characters_scroll", - width=1, - height=8, - hide_on_single_page=True, - when="has_characters", +other_games_dialog = Dialog( + Window( + Multi( + Const("🎮 Другие игры"), + Const(""), + Const("Здесь вы можете управлять своими персонажами в неофициальных кампаниях."), + Const(""), + Const( + "📭 У вас пока нет персонажей в других играх", + when=lambda data, *_: not data.get("has_characters", False), ), - Button(Const("Посмотреть доступные кампании"), id="available_games", on_click=on_available_games), - Cancel(Const("Назад")), - getter=main_getter, - state=OtherGames.main, + Const("Выберите персонажа для управления:"), + sep="\n", ), - Window( - Const("Вот кампании к которым у вас есть доступ"), - ScrollingGroup( - Select( - Format("{item[0].title} - {item[1].role.name}"), - id="campaign_select", - items="participations", - item_id_getter=lambda c: c[1].id, - on_click=on_campaign_selected, - type_factory=UUID, - ), - id="participations_scroll", - width=1, - height=8, - hide_on_single_page=True, - when="participations_exist", + ScrollingGroup( + Select( + Format("👤 {item[1].name} - {item[2].title}"), + id="character_select", + items="characters_data", + item_id_getter=lambda c: c[0].id, + on_click=on_character_selected, + type_factory=UUID, ), - Back(Const("Назад")), - getter=available_campaigns_getter, - state=OtherGames.available, + id="characters_scroll", + width=1, + height=6, + hide_on_single_page=True, + when="has_characters", ), - ) + Row( + Button(Const("🏰 Доступные кампании"), id="available_games", on_click=on_available_games), + Cancel(Const("⬅️ Назад")), + ), + getter=main_getter, + state=OtherGames.main, + ), + Window( + Multi( + Const("🏰 Доступные кампании"), + Const(""), + Const("Кампании, к которым у вас есть доступ:"), + Const(""), + Const("📭 У вас нет доступных кампаний", when=lambda data, *_: not data.get("has_participations", False)), + Const("Выберите кампанию для присоединения:"), + sep="\n", + ), + ScrollingGroup( + Select( + Format("🎮 {item[0].title} (Роль: {item[1].role.name})"), + id="campaign_select", + items="participations", + item_id_getter=lambda c: c[1].id, + on_click=on_campaign_selected, + type_factory=UUID, + ), + id="participations_scroll", + width=1, + height=6, + hide_on_single_page=True, + when="has_participations", + ), + Back(Const("⬅️ Назад")), + getter=available_campaigns_getter, + state=OtherGames.available, + ), ) + +router.include_router(other_games_dialog) diff --git a/handlers/player/other_games_campaigns.py b/handlers/player/other_games_campaigns.py index c60d287..b182138 100644 --- a/handlers/player/other_games_campaigns.py +++ b/handlers/player/other_games_campaigns.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Cancel from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Character from services.campaigns import campaign_getter @@ -15,13 +15,16 @@ async def campaign_preview_getter(dialog_manager: DialogManager, **kwargs): - campaign_id = dialog_manager.start_data.get("campaign_id") + if "campaign_id" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["campaign_id"] = dialog_manager.start_data.get("campaign_id", 0) + campaign_id = dialog_manager.dialog_data["campaign_id"] user = dialog_manager.middleware_data["user"] character: Character | None = await Character.get_or_none(campaign_id=campaign_id, user=user) return { **await campaign_getter(dialog_manager, **kwargs), "should_join": character is None, + "has_character": character is not None, } @@ -31,20 +34,28 @@ async def on_join_campaign(c: CallbackQuery, b: Button, m: DialogManager): data={ "target_type": TargetType.CHARACTER, "target_id": None, - "campaign_id": m.start_data.get("campaign_id"), + "campaign_id": m.dialog_data["campaign_id"], }, ) -router.include_router( - Dialog( - Window( - Format("Информация о кампании: {title}\n\nОписание: {description}\n\nВыберите действие:"), - DynamicMedia("icon"), - Button(Const("Присоединиться"), id="join", on_click=on_join_campaign, when="should_join"), - Cancel(Const("Назад")), - getter=campaign_preview_getter, - state=OtherGamesCampaign.preview, - ) +campaign_preview_dialog = Dialog( + Window( + DynamicMedia("icon"), + Multi( + Format("🎮 Кампания: {title}"), + Const(""), + Format("📝 Описание: {description}"), + Const(""), + Const("🌟 Вы ещё не создали персонажа для этой кампании", when="should_join"), + Const("✅ У вас уже есть персонаж в этой кампании", when="has_character"), + sep="\n", + ), + Button(Const("➕ Присоединиться"), id="join", on_click=on_join_campaign, when="should_join"), + Cancel(Const("⬅️ Назад")), + getter=campaign_preview_getter, + state=OtherGamesCampaign.preview, ) ) + +router.include_router(campaign_preview_dialog) diff --git a/handlers/player/other_games_character.py b/handlers/player/other_games_character.py index c461e81..4d24dac 100644 --- a/handlers/player/other_games_character.py +++ b/handlers/player/other_games_character.py @@ -3,9 +3,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel +from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Row from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Campaign, Character, Participation, User from services.character_data import character_preview_getter @@ -21,7 +21,12 @@ async def character_data_getter(dialog_manager: DialogManager, **kwargs) -> dict character = await Character.get(id=dialog_manager.start_data["character_id"]) data = json.loads(character.data["data"]) - return character_preview_getter(character, data) + character_preview = character_preview_getter(character, data) + + return { + **character_preview, + "has_character_data": character_preview.get("character_data_preview") not in [None, ""], + } async def on_campaign_info(c: CallbackQuery, b: Button, m: DialogManager): @@ -63,17 +68,26 @@ async def on_upload_json(c: CallbackQuery, b: Button, m: DialogManager): ) -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Button(Const("Посмотреть инвентарь"), id="inventory", on_click=on_inventory), - Button(Const("Информация о кампании"), id="campaign_info", on_click=on_campaign_info), - Button(Const("Загрузить обновленный .json"), id="update_json", on_click=on_upload_json), - Cancel(Const("Назад")), - getter=character_data_getter, - state=OtherGamesCharacter.preview, - ) +character_dialog = Dialog( + Window( + Multi( + Const("👤 Персонаж"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="character_data_preview"), + Const("📭 У персонажа нет данных", when=lambda data, *_: not data.get("character_data_preview", "")), + Column( + Row( + Button(Const("🎒 Инвентарь"), id="inventory", on_click=on_inventory), + Button(Const("🏰 Кампания"), id="campaign_info", on_click=on_campaign_info), + ), + Button(Const("📤 Обновить .json"), id="update_json", on_click=on_upload_json), + Cancel(Const("⬅️ Назад")), + ), + getter=character_data_getter, + state=OtherGamesCharacter.preview, ) ) + +router.include_router(character_dialog) diff --git a/handlers/player/player_preview.py b/handlers/player/player_preview.py index 754c5fe..302f85d 100644 --- a/handlers/player/player_preview.py +++ b/handlers/player/player_preview.py @@ -3,9 +3,9 @@ from aiogram import Router from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Cancel, Url +from aiogram_dialog.widgets.kbd import Cancel, Row, Url from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import User from services.character_data import character_preview_getter @@ -16,24 +16,42 @@ async def preview_getter(dialog_manager: DialogManager, **kwargs): - user = await User.get(id=dialog_manager.start_data["user_id"]) + if "light" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["light"] = dialog_manager.start_data.get("light", True) + dialog_manager.dialog_data["user_id"] = dialog_manager.start_data.get("user_id", 0) + + user = await User.get(id=dialog_manager.dialog_data["user_id"]) data = json.loads(user.data["data"]) + light = dialog_manager.dialog_data["light"] + + character_preview = character_preview_getter(user, data, light=light) return { "profile_link": f"tg://user?id={user.id}", - **character_preview_getter(user, data), + "username": user.username or "Пользователь", + **character_preview, + "has_character_data": character_preview.get("character_data_preview") not in [None, ""], } -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Url(Const("Перейти в профиль"), Format("{profile_link}")), - Cancel(Const("Назад")), - getter=preview_getter, - state=PlayerPreview.preview, - ) +preview_dialog = Dialog( + Window( + Multi( + Format("👤 Профиль: @{username}"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="character_data_preview"), + Const( + "📭 У игрока нет загруженного персонажа", when=lambda data, *_: not data.get("character_data_preview", "") + ), + Row( + Url(Const("📨 Написать"), Format("{profile_link}")), + Cancel(Const("⬅️ Назад")), + ), + getter=preview_getter, + state=PlayerPreview.preview, ) ) + +router.include_router(preview_dialog) diff --git a/handlers/player/rating.py b/handlers/player/rating.py index a12fcc6..dd1591a 100644 --- a/handlers/player/rating.py +++ b/handlers/player/rating.py @@ -3,8 +3,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Cancel, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import User from states.player_preview import PlayerPreview @@ -15,33 +15,50 @@ async def rating_getter(dialog_manager: DialogManager, **kwargs): - return {"top": await User.all().order_by("-rating")} - - -async def on_preview(c: CallbackQuery, b: Button, m: DialogManager, user_id: int): - await m.start(PlayerPreview.preview, data={"user_id": user_id}) - - -router.include_router( - Dialog( - Window( - Const("Вот топ по рейтингу"), - ScrollingGroup( - Select( - Format("@{item.username} - {item.rating}"), - id="preview", - items="top", - item_id_getter=lambda x: x.id, - on_click=on_preview, - ), - hide_on_single_page=True, - width=1, - height=5, - id="top", + top_users = await User.all().order_by("-rating") + + # Добавляем позиции в рейтинге + top_with_positions = [(i + 1, user) for i, user in enumerate(top_users)] + + return { + "top_with_positions": top_with_positions, + "has_users": len(top_with_positions) > 0, + } + + +async def on_preview(c: CallbackQuery, b: Select, m: DialogManager, user_id: int): + await m.start(PlayerPreview.preview, data={"user_id": user_id, "light": True}) + + +rating_dialog = Dialog( + Window( + Multi( + Const("🏆 Рейтинг игроков"), + Const(""), + Const("Топ игроков по рейтингу в академии:"), + Const(""), + Const("📭 В рейтинге пока нет игроков", when=lambda data, *_: not data.get("has_users", False)), + sep="\n", + ), + ScrollingGroup( + Select( + Format("{item[0]}. @{item[1].username} - {item[1].rating} ⭐"), + id="preview", + items="top_with_positions", + item_id_getter=lambda x: x[1].id, + on_click=on_preview, + type_factory=int, ), - Cancel(Const("Назад")), - getter=rating_getter, - state=AcademyRating.rating, + hide_on_single_page=True, + width=1, + height=6, + id="top", + when="has_users", ), - ) + Cancel(Const("⬅️ Назад")), + getter=rating_getter, + state=AcademyRating.rating, + ), ) + +router.include_router(rating_dialog) diff --git a/handlers/player/start.py b/handlers/player/start.py index 6488ed2..b5df548 100644 --- a/handlers/player/start.py +++ b/handlers/player/start.py @@ -5,7 +5,7 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column -from aiogram_dialog.widgets.text import Const +from aiogram_dialog.widgets.text import Const, Multi from db.models import Invitation, User from db.models.participation import Participation @@ -84,9 +84,8 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D command.args, invite.user.id, ) - await message.reply( - f"🗳️ Вы уже участвуете в этой кампании в качестве {'игрока' if (i := participation.role == 0) else str(i)}" - ) + role_name = "игрока" if participation.role.value == 0 else participation.role.name + await message.reply(f"🗳️ Вы уже участвуете в этой кампании в качестве {role_name}") return logger.debug( @@ -122,18 +121,25 @@ async def on_other(c: CallbackQuery, b: Button, m: DialogManager): await m.start(OtherGames.main) -router.include_router( - Dialog( - Window( - Const("Обычный /start"), - Column( - Button(Const("Академия"), id="academy", on_click=on_academy), - Button(Const("Другие игры"), id="other_games", on_click=on_other), - # TODO (@pxc1984): Добавить ближайшие встречи - # https://github.com/cu-tabletop/dnd/issues/11 - ), - state=StartSimple.simple, +start_dialog = Dialog( + Window( + Multi( + Const("🎲 Добро пожаловать!"), + Const(""), + Const("Я - бот для настольных ролевых игр."), + Const(""), + Const("Выберите раздел для продолжения:"), + sep="\n", ), - on_start=redirect, - ) + Column( + Button(Const("🎓 Академия"), id="academy", on_click=on_academy), + Button(Const("🎮 Другие игры"), id="other_games", on_click=on_other), + # TODO (@pxc1984): Добавить ближайшие встречи + # https://github.com/cu-tabletop/dnd/issues/11 + ), + state=StartSimple.simple, + ), + on_start=redirect, ) + +router.include_router(start_dialog) diff --git a/handlers/player/upload.py b/handlers/player/upload.py index 58f629c..9d1f4a8 100644 --- a/handlers/player/upload.py +++ b/handlers/player/upload.py @@ -9,13 +9,15 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Cancel -from aiogram_dialog.widgets.text import Const -from pydantic import BaseModel, field_validator +from aiogram_dialog.widgets.link_preview import LinkPreview +from aiogram_dialog.widgets.text import Const, Multi +from pydantic import BaseModel, ValidationError, field_validator from db.models import Character from services.character_data import update_char_data from states.inventory_view import TargetType from states.upload_character import UploadCharacter +from utils.character import parse_character_data if TYPE_CHECKING: from db.models.base import CharacterData @@ -77,7 +79,7 @@ def validate_campaign_id(cls, v: int | None, values: dict) -> int: async def upload_document(msg: Message, _: MessageInput, manager: DialogManager): if not msg.document or not msg.document.file_name.endswith(".json"): - await msg.answer("Отправь .json!") + await msg.answer("❌ Пожалуйста, отправьте файл в формате .json!") logger.warning("User %d didn't send us a valid json", msg.from_user.id) return @@ -104,30 +106,46 @@ async def upload_document(msg: Message, _: MessageInput, manager: DialogManager) return try: - await update_char_data(source, json.loads(content.decode("utf-8"))) + data = json.loads(content.decode("utf-8")) + parse_character_data(data) + await update_char_data(source, data) except UnicodeDecodeError: logger.warning("Failed to unicode decode payload from user %d", msg.from_user.id) - await msg.answer("Это не json, проверь еще раз") + await msg.answer("❌ Не удалось прочитать файл. Убедитесь, что это корректный JSON-файл.") return - except json.JSONDecodeError: + except (json.JSONDecodeError, ValidationError): logger.warning("User %d sent incorrect json", msg.from_user.id) - await msg.answer("Это не json, проверь еще раз") + await msg.answer("❌ Это невалидный JSON-файл. Проверьте его содержимое.") return - await msg.answer("Успешно загружено") + await msg.answer("✅ Данные персонажа успешно загружены!") await manager.done() +async def has_data(dialog_manager: DialogManager, **kwargs): + return {"has_data": dialog_manager.middleware_data["user"].data} + + """ Этот диалог обязательно должен включать в start_data параметр request: UploadCharacterRequest """ -router.include_router( - Dialog( - Window( - Const("Отправь нам .json из LSH"), - Cancel(Const("Отмена")), - MessageInput(content_types=ContentType.DOCUMENT, func=upload_document), - state=UploadCharacter.upload, +upload_dialog = Dialog( + Window( + Multi( + Const("📤 Загрузка персонажа"), + Const(""), + Const("Отправьте файл персонажа в формате .json."), + Const('Файл должен быть экспортирован из LSH.'), + Const(""), + Const("⚠️ Внимание: существующие данные будут перезаписаны.", when=""), + sep="\n", ), - ) + LinkPreview(is_disabled=True), + Cancel(Const("❌ Отмена")), + MessageInput(content_types=ContentType.DOCUMENT, func=upload_document), + getter=has_data, + state=UploadCharacter.upload, + ), ) + +router.include_router(upload_dialog) diff --git a/services/character.py b/services/character.py new file mode 100644 index 0000000..cbfdf9a --- /dev/null +++ b/services/character.py @@ -0,0 +1,245 @@ +import logging +from typing import Any + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class CharacterStat(BaseModel): + score: int = 0 + modifier: int = 0 + + +class CharacterHP(BaseModel): + current: int = 0 + max: int = 0 + temp: int = 0 + ac: int = 0 + speed: int = 0 + + +class CharacterWeapon(BaseModel): + name: str = "Неизвестно" + mod: str = "" + damage: str = "" + notes: str = "" + + +class CharacterCoins(BaseModel): + pp: int = 0 + gp: int = 0 + ep: int = 0 + sp: int = 0 + cp: int = 0 + + +class CharacterData(BaseModel): + name: str = "Неизвестно" + klass: str = Field(default="Неизвестно", alias="class") + subclass: str = "" + level: int = 0 + race: str = "Неизвестно" + background: str = "Неизвестно" + alignment: str = "Неизвестно" + avatar_link: str | None + + age: str = "" + height: str = "" + weight: str = "" + eyes: str = "" + skin: str = "" + hair: str = "" + + proficiency: int = 0 + stats: dict[str, CharacterStat] = Field(default_factory=dict) + + skills: dict[str, dict] = Field(default_factory=dict) + prof_skills: list[str] = Field(default_factory=list) + + hp: CharacterHP = Field(default_factory=CharacterHP) + + weapons: list[CharacterWeapon] = Field(default_factory=list) + + traits: str = "" + equipment: str = "" + background_story: str = "" + personality: str = "" + appearance: str = "" + allies: str = "" + proficiencies: str = "" + + coins: CharacterCoins = Field(default_factory=CharacterCoins) + + class Config: + validate_by_name = True + + def preview(self) -> str: + return ( + f"Имя: {self.name}\n" + f"Класс: {self.klass} {f'({self.subclass})' if self.subclass else ''}\n" + f"Уровень: {self.level}\n" + f"Хиты: {self.hp.current}/{self.hp.max} {f'(+{self.hp.temp} временное)' if self.hp.temp else ''}\n" + f"Класс брони: {self.hp.ac}\n" + f"Скорость: {self.hp.speed} фт.\n" + f"Раса: {self.race}\n" + f"Предыстория: {self.background}\n" + f"Мировоззрение: {self.alignment}" + ) + + def preview_stats(self) -> str: + return "\n".join( + [f"{STATS_CONVERSION[key]}: {value.score}({value.modifier})" for key, value in self.stats.items()] + ) + + +STATS_CONVERSION = { + "str": "Сила", + "dex": "Ловкость", + "con": "Телосложение", + "int": "Интеллект", + "wis": "Мудрость", + "cha": "Харизма", +} + + +def parse_character_data(data: dict) -> CharacterData: + """ + Превращает json персонажа Long Story Short в адекватный Pydantic объект + """ + basic_info = data.get("info", {}) + sub_info = data.get("subInfo", {}) + vitality = data.get("vitality", {}) + coins_data = data.get("coins", {}) + prof_skills = [] + for skill_name, skill_data in data.get("skills", {}).items(): + if skill_data.get("isProf"): + prof_skills.append(skill_name) + stats = {} + for stat_name, stat_data in data.get("stats", {}).items(): + stats[stat_name] = CharacterStat( + score=stat_data.get("score", 0), + modifier=stat_data.get("modifier", 0), + ) + weapons = [ + CharacterWeapon( + name=weapon.get("name", {}).get("value", "Неизвестно"), + mod=weapon.get("mod", {}).get("value", ""), + damage=weapon.get("dmg", {}).get("value", ""), + notes=weapon.get("notes", {}).get("value", ""), + ) + for weapon in data.get("weaponsList", []) + ] + + # noinspection PyArgumentList + return CharacterData( + # Basic info + name=data.get("name", {}).get("value", "Неизвестно"), + klass=basic_info.get("charClass", {}).get("value", "Неизвестно"), + subclass=basic_info.get("charSubclass", {}).get("value", ""), + level=basic_info.get("level", {}).get("value", 0), + race=basic_info.get("race", {}).get("value", "Неизвестно"), + background=basic_info.get("background", {}).get("value", "Неизвестно"), + alignment=basic_info.get("alignment", {}).get("value", "Неизвестно"), + avatar_link=basic_info.get("avatar", {}).get("webp") or basic_info.get("avatar", {}).get("jpeg"), + # Physical characteristics + age=sub_info.get("age", {}).get("value", ""), + height=sub_info.get("height", {}).get("value", ""), + weight=sub_info.get("weight", {}).get("value", ""), + eyes=sub_info.get("eyes", {}).get("value", ""), + skin=sub_info.get("skin", {}).get("value", ""), + hair=sub_info.get("hair", {}).get("value", ""), + # Stats + proficiency=data.get("proficiency", 0), + stats=stats, + # Skills + skills=data.get("skills", {}), + prof_skills=prof_skills, + # Vitality + hp=CharacterHP( + current=vitality.get("hp-current", {}).get("value", 0), + max=vitality.get("hp-max", {}).get("value", 0), + temp=vitality.get("hp-temp", {}).get("value", 0), + ac=vitality.get("ac", {}).get("value", 0), + speed=vitality.get("speed", {}).get("value", 0), + ), + # Weapons + weapons=weapons, + # Text content + traits=extract_telegram_text(data.get("text", {}).get("traits", {}).get("value", {})), + equipment=extract_telegram_text(data.get("equipment", {}).get("value", {})), + background_story=extract_telegram_text(data.get("quests", {}).get("value", {})), + personality=extract_telegram_text(data.get("background", {}).get("value", {})), + appearance=extract_telegram_text(data.get("appearance", {}).get("value", {})), + allies=extract_telegram_text(data.get("allies", {}).get("value", {})), + proficiencies=extract_telegram_text(data.get("prof", {}).get("value", {})), + # Currency + coins=CharacterCoins( + pp=coins_data.get("pp", {}).get("value", 0), + gp=coins_data.get("gp", {}).get("value", 0), + ep=coins_data.get("ep", {}).get("value", 0), + sp=coins_data.get("sp", {}).get("value", 0), + cp=coins_data.get("cp", {}).get("value", 0), + ), + ) + + +def extract_telegram_text(text_data: dict[str, Any]) -> str: + """ + Превращает json документооборот в HTML + """ + if not text_data: + return "" + + content = text_data.get("data", {}).get("content", []) + paragraphs = [] + + for block in content: + if block.get("type") == "paragraph": + paragraph_text = process_paragraph(block.get("content", [])) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +def process_paragraph(items: list[dict[str, Any]]) -> str: + """Process all items in paragraph into HTML.""" + result = [] + + for item in items: + item_type = item.get("type") + + if item_type == "text": + html_text = process_text_item(item) + if html_text: + result.append(html_text) + + elif item_type == "roller": + result.append(process_roller_item(item)) + + return " ".join(result).strip() + + +def process_text_item(item: dict[str, Any]) -> str: + """Apply HTML formatting to text item.""" + text = item.get("text", "").strip() + if not text: + return "" + + for mark in item.get("marks", []): + mark_type = mark.get("type") + if mark_type == "bold": + text = f"{text}" + elif mark_type == "italic": + text = f"{text}" + elif mark_type == "underline": + text = f"{text}" + + return text + + +def process_roller_item(item: dict[str, Any]) -> str: + """Convert roller item to placeholder HTML-like form.""" + roller_text = item.get("content", [{}])[0].get("text", "") + return f"[{roller_text}]" diff --git a/services/character_data.py b/services/character_data.py index 30ae6f2..cfd809a 100644 --- a/services/character_data.py +++ b/services/character_data.py @@ -4,6 +4,8 @@ from aiogram_dialog.api.entities import MediaAttachment from db.models.base import CharacterData as BaseCharacterData +from db.models.character import Character +from db.models.user import User from utils.character import CharacterData, parse_character_data logger = logging.getLogger(__name__) @@ -14,10 +16,10 @@ async def update_char_data(holder: BaseCharacterData, data: dict): await holder.save() -def character_preview_getter(user: BaseCharacterData, data: dict): +def character_preview_getter(user: User | Character, data: dict, *, light: bool = False): ret = {} info: CharacterData = parse_character_data(data) - ret["character_data_preview"] = info.preview() + ret["character_data_preview"] = info.light_preview() if light else info.preview() avatar_url = data.get("avatar", {}).get("webp") if avatar_url: ret["avatar"] = MediaAttachment( diff --git a/services/settings.py b/services/settings.py index e44b9ff..7c9e467 100644 --- a/services/settings.py +++ b/services/settings.py @@ -40,7 +40,7 @@ class Settings(BaseSettings): DB_PASSWORD: str = Field(default="admin", alias="POSTGRES_PASSWORD") # ^ Minio - MINIO_HOST: str = "localhost" + MINIO_HOST: str = "minio" MINIO_PORT: str = "9000" MINIO_ROOT_USER: str = Field(default="admin", alias="MINIO_ROOT_USER") MINIO_ROOT_PASSWORD: str = Field(default="admin", alias="MINIO_ROOT_PASSWORD") diff --git a/utils/character.py b/utils/character.py index cbfdf9a..90dff19 100644 --- a/utils/character.py +++ b/utils/character.py @@ -87,6 +87,9 @@ def preview(self) -> str: f"Мировоззрение: {self.alignment}" ) + def light_preview(self) -> str: + return f"Имя: {self.name}\n" + def preview_stats(self) -> str: return "\n".join( [f"{STATS_CONVERSION[key]}: {value.score}({value.modifier})" for key, value in self.stats.items()]