diff --git a/.test.env b/.test.env index 38d5ab8..4cd9d2f 100644 --- a/.test.env +++ b/.test.env @@ -102,3 +102,7 @@ SLACK_FEEDBACK_WEBHOOK="https://hook.slack.com/sdfsdfsf" #Feedback Webhook JIRA_WEBHOOK="https://automation.atlassian.com/sdfsdfsf" + +# FeedbackIngest +# FEEDBACK_SERVICE_URL=http://localhost:8000/api/ingest/discord +# FEEDBACK_SERVICE_API_KEY= \ No newline at end of file diff --git a/src/cmds/core/other.py b/src/cmds/core/other.py index 11e5bf2..a425905 100644 --- a/src/cmds/core/other.py +++ b/src/cmds/core/other.py @@ -1,54 +1,144 @@ import logging +from datetime import UTC, datetime import discord from discord import ApplicationContext, Interaction, Message, Option, slash_command from discord.ext import commands from discord.ui import Button, InputText, Modal, View from slack_sdk.webhook import WebhookClient +from sqlalchemy import select from src.bot import Bot +from src.constants.feedback import feedback_kind_choices, feedback_platform_choices from src.core import settings -from src.helpers import webhook +from src.database.models import HtbDiscordLink +from src.database.session import AsyncSessionLocal +from src.helpers import feedback_service, webhook logger = logging.getLogger(__name__) +def _sanitize_feedback_text(text: str) -> str: + """Strip characters that could trigger Slack @-mentions.""" + return text.replace("@", "[at]").replace("<", "[bracket]") + + +def _modal_field_value(modal: Modal, custom_id: str) -> str: + for child in modal.children: + if getattr(child, "custom_id", None) == custom_id: + return (child.value or "").strip() + return "" + + class FeedbackModal(Modal): - """Feedback modal.""" + """Collect structured feedback for the feedback service.""" - def __init__(self, *args, **kwargs) -> None: - """Initialize the Feedback Modal with input fields.""" - super().__init__(*args, **kwargs) - self.add_item(InputText(label="Title")) - self.add_item(InputText(label="Feedback", style=discord.InputTextStyle.long)) + def __init__(self, *, kind: str, platform: str, **kwargs) -> None: + super().__init__(**kwargs) + self.kind = kind + self.platform = platform + self.add_item( + InputText( + label="Summary", + custom_id="summary", + placeholder="Brief summary (e.g. VPN drops during Pro Labs)", + max_length=150, + required=True, + ) + ) + self.add_item( + InputText( + label="Details", + custom_id="details", + placeholder="What happened? Steps to reproduce, expected vs actual behavior…", + style=discord.InputTextStyle.long, + max_length=4000, + required=True, + ) + ) + self.add_item( + InputText( + label="Product area (optional)", + custom_id="product", + placeholder="e.g. machines, modules, VPN, certifications", + max_length=100, + required=False, + ) + ) + + async def _lookup_htb_user_id(self, discord_user_id: int) -> str: + async with AsyncSessionLocal() as session: + stmt = select(HtbDiscordLink).filter( + HtbDiscordLink.discord_user_id == discord_user_id + ).limit(1) + result = await session.scalars(stmt) + link = result.first() + if link: + return str(link.htb_user_id) + return "" async def callback(self, interaction: discord.Interaction) -> None: - """Handle the modal submission by sending feedback to Slack.""" + """Handle the modal submission — forward to feedback or legacy Slack.""" await interaction.response.send_message("Thank you, your feedback has been recorded.", ephemeral=True) - webhook = WebhookClient(settings.SLACK_FEEDBACK_WEBHOOK) + summary = _modal_field_value(self, "summary") + details = _modal_field_value(self, "details") + product = _modal_field_value(self, "product") - if interaction.user: # Protects against some weird edge cases - title = f"{self.children[0].value} - {interaction.user.name}" + if interaction.user: + author_source_user_id = str(interaction.user.id) + author_htb_user_id = await self._lookup_htb_user_id(interaction.user.id) + slack_title = f"{summary} - {interaction.user.name}" else: - title = f"{self.children[0].value}" + author_source_user_id = "" + author_htb_user_id = "" + slack_title = summary + + if feedback_service.is_configured(): + payload: dict[str, str] = { + "external_id": str(interaction.id), + "title": summary, + "body": details, + "kind": self.kind, + "platform": self.platform, + "author_source_user_id": author_source_user_id, + "submitted_at": datetime.now(UTC).isoformat(), + } + if product: + payload["product"] = product + if author_htb_user_id: + payload["author_htb_user_id"] = author_htb_user_id + if interaction.guild: + payload["source_label"] = interaction.guild.name + await feedback_service.ingest_discord_feedback(payload) + return - message_body = self.children[1].value - # Slack has no way to disallow @(@everyone calls), so we strip it out and replace it with a safe version - title = title.replace("@", "[at]").replace("<", "[bracket]") - message_body = message_body.replace("@", "[at]").replace("<", "[bracket]") + if not settings.SLACK_FEEDBACK_WEBHOOK: + logger.warning( + "No feedback destination configured (FEEDBACK_SERVICE_* or SLACK_FEEDBACK_WEBHOOK)" + ) + return - response = webhook.send( + kind_label = self.kind.replace("_", " ") + platform_label = self.platform.replace("htb_", "").replace("_", " ") + slack_header = f"[{kind_label} / {platform_label}]" + if product: + slack_header += f" ({product})" + + title = _sanitize_feedback_text(f"{slack_header} {slack_title}") + message_body = _sanitize_feedback_text(details) + slack_webhook = WebhookClient(settings.SLACK_FEEDBACK_WEBHOOK) + response = slack_webhook.send( text=f"{title} - {message_body}", blocks=[ { "type": "section", "text": { "type": "mrkdwn", - "text": f"{title}:\n {message_body}" - } + "text": f"{title}:\n {message_body}", + }, } - ] + ], ) assert response.status_code == 200 assert response.body == "ok" @@ -160,9 +250,24 @@ async def spoiler(self, ctx: ApplicationContext) -> None: @slash_command(guild_ids=settings.guild_ids, description="Provide feedback to HTB.") @commands.cooldown(1, 60, commands.BucketType.user) - async def feedback(self, ctx: ApplicationContext) -> Interaction: - """Provide feedback to HTB.""" - modal = FeedbackModal(title="Feedback") + async def feedback( + self, + ctx: ApplicationContext, + kind: Option( + str, + "Type of feedback", + choices=feedback_kind_choices(), + required=True, + ), + platform: Option( + str, + "HTB platform this relates to", + choices=feedback_platform_choices(), + required=True, + ), + ) -> Interaction: + """Provide structured feedback to HTB.""" + modal = FeedbackModal(title="HTB Feedback", kind=kind, platform=platform) return await ctx.send_modal(modal) @slash_command(guild_ids=settings.guild_ids, description="Report a suspected cheater on the main platform.") diff --git a/src/constants/__init__.py b/src/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/constants/feedback.py b/src/constants/feedback.py new file mode 100644 index 0000000..b7d464f --- /dev/null +++ b/src/constants/feedback.py @@ -0,0 +1,39 @@ +"""Feedback form choices aligned with the feedback service ingest schema.""" + +from __future__ import annotations + +from discord import OptionChoice + +# (display label, ingest slug) — matches feedback service FeedbackKind +FEEDBACK_KINDS: list[tuple[str, str]] = [ + ("Bug report", "bug"), + ("Feature request", "product_feature"), + ("Suggestion", "suggestion"), + ("Comment", "comment"), + ("Review", "review"), + ("Other", "other"), +] + +# (display label, ingest slug) — matches feedback service HTBPlatform catalog +FEEDBACK_PLATFORMS: list[tuple[str, str]] = [ + ("HTB Labs", "htb_labs"), + ("HTB Academy", "htb_academy"), + ("HTB Enterprise", "htb_enterprise"), + ("HTB CTF", "htb_ctf"), + ("HTB Discord", "htb_discord"), + ("HTB Account", "htb_account"), + ("HTB Profile", "htb_profile"), + ("HTB Website", "htb_landing_website"), + ("Other", "other"), +] + +FEEDBACK_KIND_VALUES = [value for _, value in FEEDBACK_KINDS] +FEEDBACK_PLATFORM_VALUES = [value for _, value in FEEDBACK_PLATFORMS] + + +def feedback_kind_choices() -> list[OptionChoice]: + return [OptionChoice(label, value) for label, value in FEEDBACK_KINDS] + + +def feedback_platform_choices() -> list[OptionChoice]: + return [OptionChoice(label, value) for label, value in FEEDBACK_PLATFORMS] diff --git a/src/core/config.py b/src/core/config.py index 5fe1dd2..f879607 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -196,6 +196,10 @@ class Global(BaseSettings): SLACK_FEEDBACK_WEBHOOK: str = "" JIRA_WEBHOOK: str = "" + # Feedback service ingest (POST /api/ingest/discord) + FEEDBACK_SERVICE_URL: str = "" + FEEDBACK_SERVICE_API_KEY: str = "" + ROOT: Path = None VERSION: str = "unknown" diff --git a/src/helpers/feedback_service.py b/src/helpers/feedback_service.py new file mode 100644 index 0000000..6f9fc15 --- /dev/null +++ b/src/helpers/feedback_service.py @@ -0,0 +1,42 @@ +"""Send Discord feedback to the feedback service ingest API.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp + +from src.core import settings + +logger = logging.getLogger(__name__) + + +def is_configured() -> bool: + """Return True when feedback service URL and API key are both set.""" + return bool(settings.FEEDBACK_SERVICE_URL and settings.FEEDBACK_SERVICE_API_KEY) + + +async def ingest_discord_feedback(payload: dict[str, Any]) -> None: + """POST a payload to the feedback service /api/ingest/discord endpoint.""" + if not is_configured(): + return + + headers = {"Authorization": f"Bearer {settings.FEEDBACK_SERVICE_API_KEY}"} + async with aiohttp.ClientSession() as session: + try: + async with session.post( + settings.FEEDBACK_SERVICE_URL, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=15), + ) as response: + if response.status != 202: + body = await response.text() + logger.error( + "Feedback service ingest failed: %s - %s", + response.status, + body[:500], + ) + except Exception: + logger.exception("Feedback service ingest request failed") diff --git a/tests/src/cmds/core/test_other.py b/tests/src/cmds/core/test_other.py index 5b9fc40..78cb2ad 100644 --- a/tests/src/cmds/core/test_other.py +++ b/tests/src/cmds/core/test_other.py @@ -7,7 +7,7 @@ from src.cmds.core import other from src.cmds.core.other import OtherCog, SpoilerModal from src.core import settings -from src.helpers import webhook +from src.helpers import feedback_service, webhook class TestWebhookHelper: @@ -179,6 +179,82 @@ async def test_cheater_command(self, bot, ctx): + @pytest.mark.asyncio + async def test_feedback_modal_sends_to_feedback_service(self): + """Test the feedback modal posts structured payload to the feedback service.""" + modal = other.FeedbackModal( + title="HTB Feedback", + kind="suggestion", + platform="htb_discord", + ) + interaction = AsyncMock() + interaction.id = 999888777 + interaction.user.id = 123456789012345678 + interaction.user.name = "TestUser" + interaction.guild = None + for child in modal.children: + if child.custom_id == "summary": + child.value = "Badge precedence" + elif child.custom_id == "details": + child.value = "CWPE should override CPTS badge in Discord" + elif child.custom_id == "product": + child.value = "certifications" + + with ( + patch.object(modal, "_lookup_htb_user_id", new_callable=AsyncMock, return_value="42"), + patch.object(feedback_service, "is_configured", return_value=True), + patch.object(feedback_service, "ingest_discord_feedback", new_callable=AsyncMock) as mock_ingest, + patch("src.cmds.core.other.WebhookClient") as mock_slack_client, + ): + mock_slack_client.return_value.send.return_value.status_code = 200 + mock_slack_client.return_value.send.return_value.body = "ok" + + await modal.callback(interaction) + + interaction.response.send_message.assert_called_once_with( + "Thank you, your feedback has been recorded.", + ephemeral=True, + ) + mock_ingest.assert_called_once_with( + { + "external_id": "999888777", + "title": "Badge precedence", + "body": "CWPE should override CPTS badge in Discord", + "kind": "suggestion", + "platform": "htb_discord", + "product": "certifications", + "author_source_user_id": "123456789012345678", + "submitted_at": mock_ingest.call_args[0][0]["submitted_at"], + "author_htb_user_id": "42", + } + ) + + @pytest.mark.asyncio + async def test_feedback_modal_falls_back_to_slack_when_feedback_service_unconfigured(self): + """Test legacy Slack path when feedback service URL/key are unset.""" + modal = other.FeedbackModal(title="HTB Feedback", kind="bug", platform="htb_labs") + interaction = AsyncMock() + interaction.id = 111 + interaction.user = None + for child in modal.children: + if child.custom_id == "summary": + child.value = "Title" + elif child.custom_id == "details": + child.value = "Body" + + with ( + patch.object(feedback_service, "is_configured", return_value=False), + patch.object(feedback_service, "ingest_discord_feedback", new_callable=AsyncMock) as mock_ingest, + patch("src.cmds.core.other.WebhookClient") as mock_slack_client, + ): + mock_slack_client.return_value.send.return_value.status_code = 200 + mock_slack_client.return_value.send.return_value.body = "ok" + + await modal.callback(interaction) + + mock_ingest.assert_not_called() + mock_slack_client.return_value.send.assert_called_once() + def test_setup(self, bot): """Test the setup method of the cog.""" # Invoke the command diff --git a/tests/src/constants/test_feedback.py b/tests/src/constants/test_feedback.py new file mode 100644 index 0000000..d260825 --- /dev/null +++ b/tests/src/constants/test_feedback.py @@ -0,0 +1,25 @@ +from src.constants.feedback import ( + FEEDBACK_KIND_VALUES, + FEEDBACK_PLATFORM_VALUES, + feedback_kind_choices, + feedback_platform_choices, +) + + +def test_feedback_kind_values_match_feedback_service(): + assert "bug" in FEEDBACK_KIND_VALUES + assert "product_feature" in FEEDBACK_KIND_VALUES + assert "other" in FEEDBACK_KIND_VALUES + + +def test_feedback_platform_values_match_feedback_service_catalog(): + assert "htb_labs" in FEEDBACK_PLATFORM_VALUES + assert "htb_academy" in FEEDBACK_PLATFORM_VALUES + assert "htb_discord" in FEEDBACK_PLATFORM_VALUES + + +def test_option_choices_use_ingest_slugs(): + kind_values = {choice.value for choice in feedback_kind_choices()} + platform_values = {choice.value for choice in feedback_platform_choices()} + assert kind_values == set(FEEDBACK_KIND_VALUES) + assert platform_values == set(FEEDBACK_PLATFORM_VALUES) diff --git a/tests/src/helpers/test_feedback_service.py b/tests/src/helpers/test_feedback_service.py new file mode 100644 index 0000000..11cbb63 --- /dev/null +++ b/tests/src/helpers/test_feedback_service.py @@ -0,0 +1,39 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from src.helpers import feedback_service + + +class TestFeedbackServiceHelper: + @pytest.mark.asyncio + async def test_ingest_discord_feedback_success(self): + with ( + patch.object( + feedback_service.settings, + "FEEDBACK_SERVICE_URL", + "http://feedback.test/api/ingest/discord", + ), + patch.object(feedback_service.settings, "FEEDBACK_SERVICE_API_KEY", "test-key"), + patch("aiohttp.ClientSession.post") as mock_post, + ): + mock_response = AsyncMock() + mock_response.status = 202 + mock_post.return_value.__aenter__.return_value = mock_response + + await feedback_service.ingest_discord_feedback({"title": "Test"}) + + mock_post.assert_called_once() + call_kwargs = mock_post.call_args.kwargs + assert call_kwargs["headers"] == {"Authorization": "Bearer test-key"} + assert call_kwargs["json"] == {"title": "Test"} + + @pytest.mark.asyncio + async def test_ingest_skipped_when_not_configured(self): + with ( + patch.object(feedback_service.settings, "FEEDBACK_SERVICE_URL", ""), + patch.object(feedback_service.settings, "FEEDBACK_SERVICE_API_KEY", ""), + patch("aiohttp.ClientSession.post") as mock_post, + ): + await feedback_service.ingest_discord_feedback({"title": "Test"}) + mock_post.assert_not_called()