Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.12", "3.13"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v4
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ repos:
require_serial: true
verbose: true
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.8
rev: v0.14.6 # latest as of 11/21/2025
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
args: [--fix, --unsafe-fixes]
- id: ruff-format
- repo: local
hooks:
Expand Down Expand Up @@ -46,7 +46,7 @@ repos:
files: \.py$
- id: pytest
name: pytest
entry: pytest
entry: pytest .
language: system
pass_filenames: false
always_run: true
Expand Down
7 changes: 1 addition & 6 deletions src/capy_app/frontend/bot.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
"""Discord bot module for handling discord-related functionality."""

import json

# Standard library imports
import logging
import pathlib
import typing
from dataclasses import asdict
from datetime import datetime
from pathlib import Path

# Third-party imports
import discord

# Local imports
from backend.db.database import Database
from discord.ext import commands, tasks
from discord.ext.commands import Context
from frontend.onboarding.onboarding_manager import OnboardingManager

from capy_app.stats import Statistics
from config import settings
from stats import Statistics # type: ignore[attr-defined]


class Bot(commands.AutoShardedBot):
Expand Down
2 changes: 1 addition & 1 deletion src/capy_app/frontend/cogs/features/event_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,7 @@ async def on_raw_reaction_remove(self, payload):
"""
Handle reaction removal from event announcements.
"""
valid, channel, message = await self.is_valid_reaction(payload)
valid, _channel, message = await self.is_valid_reaction(payload)
if not valid:
return

Expand Down
154 changes: 139 additions & 15 deletions src/capy_app/frontend/cogs/features/profile_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ def __init__(self, parent_cog, action, invalid_data=None):

@discord.ui.button(label="Try Again", style=discord.ButtonStyle.primary)
async def retry_button(self, interaction: discord.Interaction, _: discord.ui.Button[Any]):
# Acknowledge the interaction to avoid timeouts/jitter
await interaction.response.defer(ephemeral=True)
# Don't defer - we need to pass the interaction to handle_profile
await self.parent_cog.handle_profile(interaction, self.action, retry_data=self.invalid_data)
self.stop()

Expand Down Expand Up @@ -78,8 +77,7 @@ async def accept_button(self, interaction: discord.Interaction, _button: discord
@discord.ui.button(label="Try Again", style=discord.ButtonStyle.primary)
async def retry_button(self, interaction: discord.Interaction, _button: discord.ui.Button[Any]):
"""Reject suggestions and return to form with all data except majors."""
# Acknowledge the interaction to avoid timeouts/jitter
await interaction.response.defer(ephemeral=True)
# Don't defer - we need to show a modal, which requires an unacknowledged interaction
self.accepted = False
# Keep all profile data EXCEPT the major field - user needs to re-enter majors
retry_data = {k: v for k, v in self.profile_data.items() if k != "major(s)"}
Expand All @@ -88,7 +86,9 @@ async def retry_button(self, interaction: discord.Interaction, _button: discord.


async def delete_profile_from_events(user):
user = Database.get_document(User, user.id)
# Delete user profile from events
if not hasattr(user, "events"):
return

for event_id in user.events:
event = Database.get_document(Event, event_id)
Expand All @@ -104,16 +104,18 @@ async def delete_profile_from_events(user):
if user.id in event.maybe_users:
event.maybe_users.remove(user.id)
event.details.reactions.modify("maybe", -1)
event.save()
event.save()


async def delete_profile_from_guilds(user):
user = Database.get_document(User, user.id)
if not hasattr(user, "guilds"):
return

for guild_id in user.guilds:
guild = Database.get_document(Guild, guild_id)
if guild and user.id in guild.users:
guild.users.remove(user.id)
guild.save()


class ProfileCog(commands.Cog):
Expand Down Expand Up @@ -385,7 +387,7 @@ async def _process_and_validate_majors(
processed_majors = self.process_majors_from_text(majors_text)

# Validate and normalize majors with fuzzy matching for typos
all_valid, validated_majors, invalid_majors, auto_corrections, suggestions = (
_all_valid, validated_majors, invalid_majors, auto_corrections, suggestions = (
self.major_handler.validate_majors_with_corrections(processed_majors)
)

Expand Down Expand Up @@ -414,7 +416,10 @@ async def _process_and_validate_majors(
if invalid_majors:
error_msg = self.major_handler.get_validation_error_message(invalid_majors)
error_msg += "\nPlease check your spelling and try again."
await message.edit(content=error_msg)
# Keep all profile data EXCEPT the major field - user needs to re-enter majors
retry_data = {k: v for k, v in profile_data.items() if k != "major(s)"}
view = TryAgainView(self, action, retry_data)
await message.edit(content=error_msg, view=view)
return None

return majors_string
Expand Down Expand Up @@ -713,6 +718,7 @@ async def _save_profile(
Database.add_document(new_user)
user = new_user
self.logger.info(f"Successfully created new profile for {interaction.user}")
await self._link_user_to_guilds(interaction)
else:
updates = {f"profile__{k}": v for k, v in profile_data_dict.items()}
Database.update_document(user, updates)
Expand All @@ -729,6 +735,109 @@ async def _save_profile(
ephemeral=True,
)

def _get_target_guild_ids(self, interaction: discord.Interaction) -> list[int]:
"""Determine which guild IDs to assign to the user.

Args:
interaction: The Discord interaction

Returns:
List of guild IDs to assign to the user
"""
user_id = interaction.user.id

# If interaction is in a guild, return that guild ID
if interaction.guild_id:
self.logger.info(f"Profile created in guild {interaction.guild_id}")
return [int(interaction.guild_id)]

# If in DMs, find all mutual guilds where the bot sees the user as a member
mutual_guild_ids = []
for guild in self.bot.guilds:
member = guild.get_member(user_id)
if member and not member.bot:
mutual_guild_ids.append(int(guild.id))
self.logger.debug(f"Found user {user_id} in guild {guild.id} ({guild.name})")

self.logger.info(f"Profile created in DMs, found {len(mutual_guild_ids)} mutual guilds")
return mutual_guild_ids

def _ensure_guild_exists(self, guild_id: int) -> Guild:
"""Ensure a Guild document exists in the database.

Args:
guild_id: The guild ID to ensure exists

Returns:
The Guild document
"""
guild_doc = Database.get_document(Guild, guild_id)
if not guild_doc:
guild_doc = Guild(_id=guild_id)
Database.add_document(guild_doc)
self.logger.info(f"Created Guild document for {guild_id}")
return guild_doc

def _add_user_to_guild(self, guild_id: int, user_id: int) -> None:
"""Add a user to a guild's user list.

Args:
guild_id: The guild ID
user_id: The user ID to add
"""
try:
guild_doc = self._ensure_guild_exists(guild_id)

existing_users = getattr(guild_doc, "users", []) or []
if user_id not in existing_users:
existing_users.append(user_id)
Database.update_document(guild_doc, {"users": sorted(existing_users)})
self.logger.info(f"Added user {user_id} to Guild.users for {guild_id}")
else:
self.logger.debug(f"User {user_id} already in Guild.users for {guild_id}")
except Exception as e:
# Non-fatal: log and continue
self.logger.error(f"Failed to add user {user_id} to Guild.users for {guild_id}: {e}")

async def _link_user_to_guilds(self, interaction: discord.Interaction) -> None:
"""Link a user to their guilds after profile creation.

This handles both server and DM initiation paths:
- In servers: links to the current guild
- In DMs: links to all mutual guilds where the bot sees the user

Args:
interaction: The Discord interaction
"""
user_id = interaction.user.id

try:
# Determine which guilds to link
target_guild_ids = self._get_target_guild_ids(interaction)

if not target_guild_ids:
self.logger.warning(f"No guilds found for user {user_id}")
return

# Update User.guilds with add-to-set semantics (no duplicates)
for guild_id in target_guild_ids:
try:
Database.bulk_update_attr(User, [user_id], "guilds", guild_id)
self.logger.info(f"Added guild {guild_id} to User.guilds for {user_id}")
except Exception as e:
# Non-fatal: log and continue with other guilds
self.logger.error(f"Failed to add guild {guild_id} to User.guilds for {user_id}: {e}")

# Ensure Guild documents exist and add user to Guild.users
for guild_id in target_guild_ids:
self._add_user_to_guild(guild_id, user_id)

self.logger.info(f"Successfully linked user {user_id} to {len(target_guild_ids)} guild(s)")

except Exception as e:
# Non-fatal: profile is more important than guild links
self.logger.error(f"Error linking user {user_id} to guilds: {e}", exc_info=True)

async def show_profile_embed(
self,
message_or_interaction: discord.Message | discord.Interaction,
Expand Down Expand Up @@ -812,21 +921,36 @@ async def delete_profile(self, interaction: discord.Interaction) -> None:
await interaction.edit_original_response(content="You don't have a profile to delete.")
return

view = ConfirmDeleteView()
view = ConfirmDeleteView(timeout=60)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider making the timeout value configurable.

Hardcoding the timeout to 60 seconds reduces flexibility. Making it configurable would better support different use cases.

Suggested implementation:

    async def show_profile_embed(
        self,
        message_or_interaction: discord.Message | discord.Interaction,
        delete_profile_timeout: int = 60,
        view = ConfirmDeleteView(timeout=delete_profile_timeout)

If show_profile_embed is called from other places, you may want to pass the delete_profile_timeout argument from those call sites, or rely on the default value.
You may also want to document the new parameter in the function's docstring.

await interaction.edit_original_response(
content="⚠️ Are you sure you want to delete your profile? This action cannot be undone.",
view=view,
)

await view.wait()
if view.value:
Database.delete_document(user)
# Wait for button press
timed_out = await view.wait()

if view.value and view.interaction:
# Use the button interaction to update the message - this acknowledges it
await view.interaction.response.edit_message(content="⏳ Deleting your profile...", view=None)

# Remove user from events and guilds BEFORE deleting the user document
await delete_profile_from_events(user)
await delete_profile_from_guilds(user)

await interaction.edit_original_response(content="Your profile has been deleted.", view=None)
# Now delete the user document
Database.delete_document(user)

# Show success message
await interaction.edit_original_response(content="✅ Your profile has been deleted.", view=None)
elif timed_out:
await interaction.edit_original_response(content="❌ Profile deletion timed out.", view=None)
elif view.interaction:
# User cancelled - use button interaction to respond
await view.interaction.response.edit_message(content="❌ Profile deletion cancelled.", view=None)
else:
await interaction.edit_original_response(content="Profile deletion cancelled.", view=None)
# Fallback if no interaction (shouldn't happen)
await interaction.edit_original_response(content="❌ Profile deletion cancelled.", view=None)


async def setup(bot: commands.Bot) -> None:
Expand Down
10 changes: 6 additions & 4 deletions src/capy_app/frontend/interactions/bases/button_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def __init__(self, ephemeral: bool = True, **options) -> None:
self._completed: bool = False
self._timed_out: bool = False
self.value: bool | None = None
self.interaction: Interaction | None = None

async def _send_status_message(self, content: str) -> None:
"""Update status message."""
Expand Down Expand Up @@ -77,19 +78,20 @@ class AcceptCancelView(BaseButtonView):
@discord.ui.button(label="Accept", style=ButtonStyle.success)
async def accept(self, interaction: Interaction, _button: discord.ui.Button[Any]) -> None:
"""Handle accept button press."""
await interaction.response.defer()
# Store the interaction so the caller can respond to it
self.interaction = interaction
self.value = True
self._completed = True
await self._send_status_message("Operation accepted")
self.stop()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Duplicate call to self.stop() in cancel handler.

Please remove the redundant 'self.stop()' call to simplify the method.


@discord.ui.button(label="Cancel", style=ButtonStyle.danger)
async def cancel(self, interaction: Interaction, _button: discord.ui.Button[Any]) -> None:
"""Handle cancel button press."""
await interaction.response.defer()
# Store the interaction so the caller can respond to it
self.interaction = interaction
self.value = False
self._completed = True
await self._send_status_message("Operation cancelled")
self.stop()
self.stop()


Expand Down
4 changes: 2 additions & 2 deletions tests/capy_app/frontend/test_auto_vs_suggest.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_suggestion_medium_confidence(major_handler):
# We'll need to find a string that scores between 60-90%
# For now, let's create an artificially low-scoring match
input_majors = ["comp sci"] # Very short - likely won't match well
all_valid, valid_majors, invalid_majors, auto_corrections, suggestions = (
all_valid, _valid_majors, invalid_majors, auto_corrections, suggestions = (
major_handler.validate_majors_with_corrections(input_majors)
)

Expand All @@ -69,7 +69,7 @@ def test_mixed_auto_and_suggest(major_handler):
# "Compter Science" should auto-correct (high score)
# Let's try to find something that scores medium
input_majors = ["Compter Science", "Physics"]
all_valid, valid_majors, invalid_majors, auto_corrections, suggestions = (
_all_valid, valid_majors, _invalid_majors, auto_corrections, suggestions = (
major_handler.validate_majors_with_corrections(input_majors)
)

Expand Down
2 changes: 1 addition & 1 deletion tests/capy_app/frontend/test_major_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def test_validate_majors_with_abbreviations(major_handler):
"""Test that abbreviations require user confirmation via suggestions."""
# Test common abbreviations - they should NOT be auto-expanded
input_majors = ["CS", "me", "PHYS"]
all_valid, valid_majors, invalid_majors = major_handler.validate_majors(input_majors)
all_valid, valid_majors, _invalid_majors = major_handler.validate_majors(input_majors)

# validate_majors doesn't handle abbreviations, so they'll be invalid or fuzzy matched
# This is expected - abbreviations only work with validate_majors_with_corrections
Expand Down
2 changes: 1 addition & 1 deletion tests/capy_app/frontend/test_typo_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def test_fuzzy_match_rejects_too_different(major_handler):
]

for invalid_input in test_cases:
all_valid, valid_majors, invalid_majors = major_handler.validate_majors([invalid_input])
all_valid, _valid_majors, invalid_majors = major_handler.validate_majors([invalid_input])
assert all_valid is False
assert invalid_input in invalid_majors

Expand Down
Loading