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 @@ -101,3 +101,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=
156 changes: 133 additions & 23 deletions src/cmds/core/other.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,149 @@
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
if await feedback_service.ingest_discord_feedback(payload):
return

if not settings.SLACK_FEEDBACK_WEBHOOK:
if feedback_service.is_configured():
logger.warning(
"Feedback service ingest failed and SLACK_FEEDBACK_WEBHOOK is not configured"
)
else:
logger.warning(
"No feedback destination configured (FEEDBACK_SERVICE_* or SLACK_FEEDBACK_WEBHOOK)"
)
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]")
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})"

response = webhook.send(
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 +255,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 @@ -220,6 +220,10 @@ class Global(BaseSettings):
JIRA_WEBHOOK: str = ""
JIRA_SPOILER_WEBHOOK: str = ""

# Feedback service ingest (POST /api/ingest/discord)
FEEDBACK_SERVICE_URL: str = ""
FEEDBACK_SERVICE_API_KEY: str = ""

ROOT: Path | None = None
VERSION: str = "unknown"
SEASON_ID: int = 0
Expand Down
48 changes: 48 additions & 0 deletions src/helpers/feedback_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""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 settings.FEEDBACK_SERVICE_URL and settings.FEEDBACK_SERVICE_API_KEY


async def ingest_discord_feedback(payload: dict[str, Any]) -> bool:
"""POST a payload to the feedback service /api/ingest/discord endpoint.

Returns True when the service accepts the payload (HTTP 202).
"""
if not is_configured():
return False

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:
return True
body = await response.text()
logger.error(
"Feedback service ingest failed: %s - %s",
response.status,
body[:500],
)
return False
except Exception:
logger.exception("Feedback service ingest request failed")
return False
Loading