Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=
151 changes: 128 additions & 23 deletions src/cmds/core/other.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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.")
Expand Down
Empty file added src/constants/__init__.py
Empty file.
39 changes: 39 additions & 0 deletions src/constants/feedback.py
Original file line number Diff line number Diff line change
@@ -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]
4 changes: 4 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
42 changes: 42 additions & 0 deletions src/helpers/feedback_service.py
Original file line number Diff line number Diff line change
@@ -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")
78 changes: 77 additions & 1 deletion tests/src/cmds/core/test_other.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading