Skip to content
284 changes: 239 additions & 45 deletions bot/cogs/challenges.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
challenge_execution_api_default_timeout_ms,
challenge_execution_api_default_url,
challenge_modal_submission_max_length,
challenge_optimal_solution_bonus,
challenge_pipeline_smoke_test_cases,
challenge_pipeline_smoke_tests,
challenge_problem_title_max_length,
Expand All @@ -29,11 +30,13 @@
success_emoji,
failure_emoji, challenge_logs_public_channel_id,
)
from bot.utils.checks import check_if_tortoise_staff
from bot.utils.checks import check_if_tortoise_mod, check_if_tortoise_staff
from bot.utils.challenge import (
CHALLENGE_ATTACHMENT_FILENAMES,
ExecutionApiClient,
Problem,
TestCase,
arrange_challenge_attachments,
clean_slug,
download_text,
judge_submission,
Expand All @@ -48,6 +51,7 @@
success,
warning,
)
from bot.utils.exceptions import TortoiseStaffCheckFailure


logger = logging.getLogger(__name__)
Expand All @@ -68,6 +72,30 @@
]


def submission_log_view(
submission_id: int,
*,
moderator: bool = False,
optimal_awarded: bool = False,
) -> discord.ui.View:
view = discord.ui.View(timeout=None)
view.add_item(discord.ui.Button(
label="See solution",
style=discord.ButtonStyle.primary,
emoji="👀",
custom_id=f"challenge_solution:{submission_id}",
))
if moderator:
view.add_item(discord.ui.Button(
label="Optimal solution (+50)",
style=discord.ButtonStyle.success,
emoji="✅",
custom_id=f"challenge_optimal:{submission_id}",
disabled=optimal_awarded,
))
return view


async def challenge_problem_autocomplete(
interaction: discord.Interaction,
current: str,
Expand Down Expand Up @@ -195,6 +223,86 @@ def challenge_log_public_channel(self) -> discord.TextChannel:
self._challenge_log_public_channel = self.bot.get_channel(challenge_logs_public_channel_id)
return self._challenge_log_public_channel

@commands.Cog.listener()
async def on_interaction(self, interaction: discord.Interaction):
if interaction.type != discord.InteractionType.component or not interaction.data:
return

custom_id = interaction.data.get("custom_id", "")
if not custom_id.startswith(("challenge_solution:", "challenge_optimal:")):
return

try:
submission_id = int(custom_id.rsplit(":", 1)[1])
except (IndexError, ValueError):
return

if custom_id.startswith("challenge_solution:"):
await interaction.response.defer(ephemeral=True)
submission = await self.challenge_manager.get_submission_solution(submission_id)
if submission is None:
await interaction.followup.send(
embed=failure("This accepted solution is no longer available."),
ephemeral=True,
)
return

file = discord.File(
io.BytesIO(submission["solution"].encode("utf-8")),
filename=f"{submission['problem_slug']}-{submission['language']}-solution.txt",
spoiler=True,
)
await interaction.followup.send(
content=f"Accepted solution from <@{submission['user_id']}>.",
file=file,
ephemeral=True,
allowed_mentions=discord.AllowedMentions.none(),
)
return

try:
await check_if_tortoise_mod(interaction)
except TortoiseStaffCheckFailure:
await interaction.response.send_message(
embed=failure("Only moderators can award the optimal-solution bonus."),
ephemeral=True,
)
return

await interaction.response.defer(ephemeral=True)
status, user_id, total_points = await self.challenge_manager.award_optimal_solution(
submission_id,
interaction.user.id,
)
if status != "awarded":
message = {
"missing": "Submission not found.",
"not_accepted": "Only accepted submissions can receive this bonus.",
"already_awarded": "The optimal-solution bonus was already awarded.",
}[status]
await interaction.followup.send(embed=warning(message), ephemeral=True)
return

if interaction.message and interaction.message.embeds:
embed = discord.Embed.from_dict(interaction.message.embeds[0].to_dict())
embed.add_field(
name="Optimal solution",
value=f"+{challenge_optimal_solution_bonus} points awarded by {interaction.user.mention}",
inline=False,
)
await interaction.message.edit(
embed=embed,
view=submission_log_view(submission_id, moderator=True, optimal_awarded=True),
)

await interaction.followup.send(
embed=success(
f"Awarded <@{user_id}> **{challenge_optimal_solution_bonus} points**. "
f"New total: **{total_points}**."
),
ephemeral=True,
)

@challenge_group.command(name="rules", description="Show challenge guidelines.")
async def challenge_rules(self, interaction: discord.Interaction):
await interaction.response.send_message(embed=build_rules_embed(self.bot.user))
Expand Down Expand Up @@ -359,6 +467,7 @@ async def challenge_points(
@app_commands.check(check_if_tortoise_staff)
@app_commands.describe(
title="Problem title.",
bulk="True to upload all seven files in one follow-up message.",
statement="Markdown/text file containing the full problem statement.",
python_boilerplate="Python driver/starter file containing {{SOLUTION}}.",
javascript_boilerplate="JavaScript driver/starter file containing {{SOLUTION}}.",
Expand All @@ -371,45 +480,56 @@ async def challenge_add(
self,
interaction: discord.Interaction,
title: app_commands.Range[str, challenge_problem_title_min_length, challenge_problem_title_max_length],
statement: discord.Attachment,
python_boilerplate: discord.Attachment,
javascript_boilerplate: discord.Attachment,
cpp_boilerplate: discord.Attachment,
java_boilerplate: discord.Attachment,
test_inputs: discord.Attachment,
expected_outputs: discord.Attachment,
bulk: bool,
statement: Optional[discord.Attachment] = None,
python_boilerplate: Optional[discord.Attachment] = None,
javascript_boilerplate: Optional[discord.Attachment] = None,
cpp_boilerplate: Optional[discord.Attachment] = None,
java_boilerplate: Optional[discord.Attachment] = None,
test_inputs: Optional[discord.Attachment] = None,
expected_outputs: Optional[discord.Attachment] = None,
):
await interaction.response.defer(ephemeral=True)

try:
problem_statement = await download_text(statement, max_bytes=challenge_statement_max_bytes)
if not problem_statement.strip():
raise ValueError("problem statement cannot be empty.")

boilerplates = {
"python": await download_text(python_boilerplate, max_bytes=challenge_boilerplate_max_bytes),
"javascript": await download_text(javascript_boilerplate, max_bytes=challenge_boilerplate_max_bytes),
"cpp": await download_text(cpp_boilerplate, max_bytes=challenge_boilerplate_max_bytes),
"java": await download_text(java_boilerplate, max_bytes=challenge_boilerplate_max_bytes),
}
for language, boilerplate in boilerplates.items():
if "{{SOLUTION}}" not in boilerplate:
raise ValueError(f"{language} boilerplate must contain a {{SOLUTION}} marker.")

inputs_text = await download_text(test_inputs, max_bytes=challenge_tests_max_bytes)
outputs_text = await download_text(expected_outputs, max_bytes=challenge_tests_max_bytes)
tests = parse_test_files(inputs_text, outputs_text, self.max_tests)

slug = slug_from_title(str(title))
await self.challenge_manager.upsert_problem(
guild_id=interaction.guild_id,
slug=slug,
title=str(title),
statement=problem_statement,
boilerplates=boilerplates,
tests=tests,
created_by=interaction.user.id,
)
if bulk:
filenames = "\n".join(f"`{name}`" for name in CHALLENGE_ATTACHMENT_FILENAMES)
await interaction.response.send_message(
embed=info(
"Send one message in this channel with these seven files attached:\n\n"
f"{filenames}\n\nThis upload expires in 3 minutes.",
interaction.user,
),
ephemeral=True,
)

def is_upload(message: discord.Message) -> bool:
return (
message.author.id == interaction.user.id
and message.channel.id == interaction.channel_id
and message.guild is not None
and message.guild.id == interaction.guild_id
)

message = await self.bot.wait_for("message", check=is_upload, timeout=180)
attachments = arrange_challenge_attachments(message.attachments)
else:
await interaction.response.defer(ephemeral=True)
attachments = {
"statement.md": statement,
"python-boilerplate.py": python_boilerplate,
"javascript-boilerplate.js": javascript_boilerplate,
"cpp-boilerplate.cpp": cpp_boilerplate,
"java-boilerplate.java": java_boilerplate,
"test-inputs.json": test_inputs,
"expected-outputs.json": expected_outputs,
}
missing = [name for name, attachment in attachments.items() if attachment is None]
if missing:
raise ValueError(f"Manual mode requires: {', '.join(missing)}.")

tests = await self._save_problem(interaction, str(title), attachments)
except TimeoutError:
await interaction.followup.send(embed=warning("Bulk challenge upload timed out."), ephemeral=True)
return
except Exception as exc:
logger.exception("Could not add challenge problem")
await interaction.followup.send(embed=failure(f"Could not save problem: {exc}"), ephemeral=True)
Expand All @@ -423,6 +543,52 @@ async def challenge_add(
ephemeral=True,
)

async def _save_problem(
self,
interaction: discord.Interaction,
title: str,
attachments: dict[str, discord.Attachment],
) -> list[TestCase]:
problem_statement = await download_text(
attachments["statement.md"], max_bytes=challenge_statement_max_bytes
)
if not problem_statement.strip():
raise ValueError("problem statement cannot be empty.")

boilerplates = {
"python": await download_text(
attachments["python-boilerplate.py"], max_bytes=challenge_boilerplate_max_bytes
),
"javascript": await download_text(
attachments["javascript-boilerplate.js"], max_bytes=challenge_boilerplate_max_bytes
),
"cpp": await download_text(
attachments["cpp-boilerplate.cpp"], max_bytes=challenge_boilerplate_max_bytes
),
"java": await download_text(
attachments["java-boilerplate.java"], max_bytes=challenge_boilerplate_max_bytes
),
}
for language, boilerplate in boilerplates.items():
if "{{SOLUTION}}" not in boilerplate:
raise ValueError(f"{language} boilerplate must contain a {{SOLUTION}} marker.")

tests = parse_test_files(
await download_text(attachments["test-inputs.json"], max_bytes=challenge_tests_max_bytes),
await download_text(attachments["expected-outputs.json"], max_bytes=challenge_tests_max_bytes),
self.max_tests,
)
await self.challenge_manager.upsert_problem(
guild_id=interaction.guild_id,
slug=slug_from_title(title),
title=title,
statement=problem_statement,
boilerplates=boilerplates,
tests=tests,
created_by=interaction.user.id,
)
return tests

@challenge_group.command(name="remove", description="Deactivate a coding problem.")
@app_commands.check(check_if_tortoise_staff)
@app_commands.autocomplete(problem=challenge_problem_autocomplete)
Expand Down Expand Up @@ -675,6 +841,18 @@ async def process_submission(
)
return

submission_id = await self.challenge_manager.record_submission(
guild_id=interaction.guild_id,
slug=selected.slug,
user_id=interaction.user.id,
language=language_value,
solution=submitted_code,
status="pending",
passed=0,
total=len(selected.tests),
error=None,
)

judge_started_at = time.perf_counter()
try:
result = await judge_submission(
Expand All @@ -685,7 +863,14 @@ async def process_submission(
tests=selected.tests,
)
except Exception as exc:
logger.exception("Judge failed before recording submission")
logger.exception("Judge failed after recording submission")
await self.challenge_manager.update_submission_result(
submission_id,
status="error",
passed=0,
total=len(selected.tests),
error=f"Judge unavailable: {exc}",
)
await interaction.followup.send(embed=failure(f"Judge unavailable: {exc}"), ephemeral=True)
return
judge_elapsed_ms = round((time.perf_counter() - judge_started_at) * 1000)
Expand All @@ -699,11 +884,8 @@ async def process_submission(
result.diagnostic,
)

await self.challenge_manager.record_submission(
guild_id=interaction.guild_id,
slug=selected.slug,
user_id=interaction.user.id,
language=language_value,
await self.challenge_manager.update_submission_result(
submission_id,
status="accepted" if result.accepted else "rejected",
passed=result.passed,
total=result.total,
Expand Down Expand Up @@ -753,6 +935,7 @@ async def process_submission(
current_rank=current_rank,
total_points=total_points,
newly_solved=True,
submission_id=submission_id,
)
await interaction.followup.send(
embed=success(
Expand All @@ -779,6 +962,7 @@ async def process_submission(
current_rank=current_rank,
total_points=total_points,
newly_solved=False,
submission_id=submission_id,
)
await interaction.followup.send(
embed=success(
Expand Down Expand Up @@ -832,6 +1016,7 @@ async def log_accepted_submission(
current_rank: Optional[int],
total_points: int,
newly_solved: bool,
submission_id: int,
):
guild = interaction.guild
if guild is None:
Expand Down Expand Up @@ -862,7 +1047,16 @@ async def log_accepted_submission(
embed.add_field(name="Total points", value=str(total_points), inline=True)
embed.add_field(name="Leaderboard", value=leaderboard_change, inline=True)

await self.challenge_log_public_channel.send(content=interaction.user.mention, embed=embed)
await self.challenge_log_public_channel.send(
content=interaction.user.mention,
embed=embed,
view=submission_log_view(submission_id),
)
await self.challenge_log_channel.send(
content=interaction.user.mention,
embed=embed,
view=submission_log_view(submission_id, moderator=True),
)

async def reveal_tests_for_user(self, interaction: discord.Interaction, problem: Problem):
should_deduct = await self.challenge_manager.mark_tests_revealed(
Expand Down
Loading
Loading