From b6e9966d3444d7712e0a55f3ad9d3a20d9b1c6f1 Mon Sep 17 00:00:00 2001 From: Ariel Sousa Date: Wed, 21 Oct 2026 19:40:05 -0300 Subject: [PATCH] feat: open private daily project picker with slash command --- README.md | 4 ++++ src/hub_daily.py | 27 ++++++++++++++++++++++++--- src/hub_main.py | 6 +++++- tests/test_hub_daily.py | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f9f6a58..23b6a84 100644 --- a/README.md +++ b/README.md @@ -284,3 +284,7 @@ python -m unittest discover -s tests -v ``` Os testes usam transportes locais e não enviam mensagens para pessoas reais no Discord. + +### Comando `/daily` (modo Hub) + +Use `/daily` em qualquer canal do servidor ou no privado do bot para selecionar um projeto e preencher o daily. No servidor, a seleção e o formulário aparecem só para você. Conecte sua conta em **Minha conta → Integrações** no Hub; apenas projetos em que você participa com daily e Discord ativos em servidores aprovados aparecem. No privado, a lista reúne seus projetos habilitados. Reinicie o bot após atualizar para sincronizar o comando global. diff --git a/src/hub_daily.py b/src/hub_daily.py index 20e3390..69aabfa 100644 --- a/src/hub_daily.py +++ b/src/hub_daily.py @@ -131,7 +131,7 @@ async def on_submit(self, interaction: discord.Interaction) -> None: class DailyProjectPickerView(discord.ui.View): """Private, paginated project choices; the API rechecks access on selection.""" - def __init__(self, bridge: HubBridge, guild_id: str, discord_id: str, projects: list[dict], channel_id: str | None = None) -> None: + def __init__(self, bridge: HubBridge, guild_id: str | None, discord_id: str, projects: list[dict], channel_id: str | None = None) -> None: super().__init__(timeout=900) self.bridge = bridge self.guild_id = guild_id @@ -185,7 +185,9 @@ async def open_project(self, interaction: discord.Interaction, project_id: str) await interaction.response.send_message("Selecione um dos projetos disponíveis no menu.", ephemeral=True) return await interaction.response.defer(ephemeral=True) - payload = {"guildId": self.guild_id, "discordId": str(interaction.user.id)} + selected = next(project for project in self.projects if project["projectId"] == project_id) + guild_id = self.guild_id or selected["guildId"] + payload = {"guildId": guild_id, "discordId": str(interaction.user.id)} if self.channel_id is not None: payload["channelId"] = self.channel_id try: @@ -197,7 +199,7 @@ async def open_project(self, interaction: discord.Interaction, project_id: str) if any(isinstance(answer.get("value"), str) and len(answer["value"]) > 4000 for answer in previous.get("answers", [])) or len(previous.get("blockerDetails") or "") > 4000: await interaction.followup.send("Este daily contém respostas maiores que o limite do Discord. Para preservar o conteúdo, atualize este relatório pelo Hub.", ephemeral=True) return - draft = DailyDraft(self.bridge, project_id, self.guild_id, str(interaction.user.id), form) + draft = DailyDraft(self.bridge, project_id, guild_id, str(interaction.user.id), form) name = discord.utils.escape_markdown(form["projectName"]) await interaction.followup.send(f"**Daily · {name}**\nPreencha as perguntas do projeto. Só você vê este formulário. O envio final ficará disponível para a liderança no Hub.\n\n{draft.prompt()}", view=DailyStepView(draft), ephemeral=True, allowed_mentions=NO_MENTIONS) @@ -233,3 +235,22 @@ async def open_daily(self, interaction: discord.Interaction) -> None: return view = DailyProjectPickerView(self.bridge, guild_id, str(interaction.user.id), result["projects"], channel_id) await interaction.followup.send(view.prompt(), view=view, ephemeral=True, allowed_mentions=NO_MENTIONS) + + +async def open_daily_command(interaction: discord.Interaction, bridge: HubBridge) -> None: + """Open private choices from any guild channel or the bot DM.""" + await interaction.response.defer(ephemeral=True) + guild_id = str(interaction.guild_id) if interaction.guild_id is not None else None + payload = {"discordId": str(interaction.user.id)} + if guild_id is not None: + payload["guildId"] = guild_id + try: + result = await bridge.request("POST", "/projects", payload) + except HubError as exc: + await interaction.followup.send(str(exc), ephemeral=True, allowed_mentions=NO_MENTIONS) + return + if not result["projects"]: + await interaction.followup.send("Nenhum projeto disponível. Confira seu vínculo com o Hub e sua participação em um projeto com daily e Discord habilitados.", ephemeral=True, allowed_mentions=NO_MENTIONS) + return + view = DailyProjectPickerView(bridge, guild_id, str(interaction.user.id), result["projects"]) + await interaction.followup.send(view.prompt(), view=view, ephemeral=True, allowed_mentions=NO_MENTIONS) diff --git a/src/hub_main.py b/src/hub_main.py index 333c42a..eaf8101 100644 --- a/src/hub_main.py +++ b/src/hub_main.py @@ -10,7 +10,7 @@ from discord import app_commands from src.hub_bridge import HubBridge, HubError -from src.hub_daily import HubDailyPanelView, NO_MENTIONS +from src.hub_daily import HubDailyPanelView, NO_MENTIONS, open_daily_command logger = logging.getLogger(__name__) @@ -52,6 +52,10 @@ async def link(interaction: discord.Interaction, codigo: str) -> None: await interaction.followup.send("✅ Conta vinculada ao Hub. Agora você pode usar o botão Registrar Daily. Os lembretes por DM podem ser desativados no Hub.", ephemeral=True) self.tree.add_command(app_commands.Command(name="hub-vincular", description="Vincular sua conta do Discord ao Kina Hub", callback=link)) + async def daily(interaction: discord.Interaction) -> None: + await open_daily_command(interaction, self.bridge) + + self.tree.add_command(app_commands.Command(name="daily", description="Selecionar um projeto e preencher seu daily em privado", callback=daily, allowed_contexts=app_commands.AppCommandContext(guild=True, dm_channel=True, private_channel=False), allowed_installs=app_commands.AppInstallationType(guild=True, user=False))) await self.tree.sync() self.poll_task = asyncio.create_task(self.poll()) diff --git a/tests/test_hub_daily.py b/tests/test_hub_daily.py index 779ab45..70173c9 100644 --- a/tests/test_hub_daily.py +++ b/tests/test_hub_daily.py @@ -8,7 +8,7 @@ from aiohttp.test_utils import TestServer from src.hub_bridge import HubBridge, HubError -from src.hub_daily import DailyDraft, DailyProjectPickerView, DailyStepView, HubDailyModal, HubDailyPanelView, field_pages, parse_boolean +from src.hub_daily import DailyDraft, DailyProjectPickerView, DailyStepView, HubDailyModal, HubDailyPanelView, field_pages, parse_boolean, open_daily_command from src.hub_main import HubDailyBot @@ -110,6 +110,38 @@ async def asyncSetUp(self): self.bridge.request.return_value = {"projects": self.projects} self.bridge.project.return_value = form() + async def test_daily_command_in_any_channel_does_not_require_a_panel(self): + event = interaction() + await open_daily_command(event, self.bridge) + self.bridge.request.assert_awaited_once_with("POST", "/projects", {"guildId": "111111111111111111", "discordId": "333333333333333333"}) + menu = event.followup.send.call_args.kwargs["view"] + self.assertIsNone(menu.channel_id) + self.assertTrue(event.followup.send.call_args.kwargs["ephemeral"]) + + async def test_daily_command_in_dm_uses_selected_project_guild(self): + self.bridge.request.return_value = {"projects": [{"projectId": "alpha", "projectName": "Alfa", "guildId": "111111111111111111"}]} + event = interaction(guild_id=None) + await open_daily_command(event, self.bridge) + self.bridge.request.assert_awaited_once_with("POST", "/projects", {"discordId": "333333333333333333"}) + menu = event.followup.send.call_args.kwargs["view"] + self.assertTrue(await menu.interaction_check(event)) + await menu.open_project(event, "alpha") + self.bridge.project.assert_awaited_once_with("alpha", "form", {"guildId": "111111111111111111", "discordId": "333333333333333333"}) + self.assertEqual(event.followup.send.call_args.kwargs["view"].draft.guild_id, "111111111111111111") + + async def test_command_registration_allows_guild_and_bot_dm(self): + bot = HubDailyBot(self.bridge, "revision") + bot.tree.sync = AsyncMock() + bot.poll = AsyncMock() + try: + await bot.setup_hook() + command = bot.tree.get_command("daily") + self.assertTrue(command.allowed_contexts.guild) + self.assertTrue(command.allowed_contexts.dm_channel) + self.assertFalse(command.allowed_contexts.private_channel) + finally: + await bot.close() + async def test_persistent_panel_shows_private_choices_before_loading_any_report(self): panel = HubDailyPanelView(None, self.bridge, {}) self.assertTrue(panel.is_persistent())