Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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"
Comment thread
Copilot marked this conversation as resolved.
Outdated
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
Comment thread
ToxicBiohazard marked this conversation as resolved.

# (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
Comment thread
Copilot marked this conversation as resolved.
Outdated


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