diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 2a8dc92f..bc3ba63a 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -77,6 +77,19 @@ func (api *API) setupRoutes(mw *mw.Middleware) { fmt.Fprintln(w, htmlContent) }) + api.Router.Route("/mobile", func(r chi.Router) { + // This means you have to set a Authorization header with "Key xxx". + r.Use(mw.Auth.RequireMobileAuth) + + r.Get("/events/{eventId}/users/{userId}", api.Handlers.Event.GetUserForEvent) + r.Get("/events/{eventId}/users/by-rfid/{rfid}", api.Handlers.Event.GetUserByRFID) + r.Post("/events/{eventId}/checkin", api.Handlers.Admission.HandleEventCheckIn) + + r.Get("/events/{eventId}/redeemables", api.Handlers.Redeemables.GetRedeemables) + r.Post("/redeemables/{redeemableId}/users/{userId}", api.Handlers.Redeemables.RedeemRedeemable) + r.Post("/events/{eventId}/users/{userId}/update-rfid", api.Handlers.Event.UpdateUserRFID) + }) + // Health check api.Router.Get("/ping", func(w http.ResponseWriter, r *http.Request) { api.Logger.Trace().Str("method", r.Method).Str("path", r.URL.Path).Msg("Received ping.") @@ -152,6 +165,8 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.With(ensureEventStaff).Get("/users/{userId}", api.Handlers.Event.GetUserForEvent) // Get user ID by RFID r.With(ensureEventStaff).Get("/users/by-rfid/{rfid}", api.Handlers.Event.GetUserByRFID) + // Is the user checked in + r.With(ensureEventStaff).Get("/users/{userId}/checked-in-status", api.Handlers.Event.GetCheckedInStatusByIds) // Admin-only r.With(ensureEventAdmin).Post("/queue-confirmation-email", api.Handlers.Email.QueueConfirmationEmail) @@ -225,7 +240,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { // Update and delete specific redeemable r.Route("/{redeemableId}", func(r chi.Router) { r.Patch("/", api.Handlers.Redeemables.UpdateRedeemable) - r.Delete("/", api.Handlers.Redeemables.DeleteRedeemable) + r.With(ensureEventAdmin).Delete("/", api.Handlers.Redeemables.DeleteRedeemable) r.Route("/users/{userId}", func(r chi.Router) { r.Post("/", api.Handlers.Redeemables.RedeemRedeemable) diff --git a/apps/api/internal/api/handlers/events.go b/apps/api/internal/api/handlers/events.go index c91d2ac9..335e8dff 100644 --- a/apps/api/internal/api/handlers/events.go +++ b/apps/api/internal/api/handlers/events.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" "github.com/go-chi/chi/v5" @@ -865,4 +866,96 @@ func (h *EventHandler) GetUserByRFID(w http.ResponseWriter, r *http.Request) { // Return just the user ID as a simple JSON object res.Send(w, http.StatusOK, map[string]string{"user_id": user.ID.String()}) -} \ No newline at end of file +} + +// Get User by RFID +// +// @Summary Retrieves a user's ID by their RFID +// @Description Looks up a user's ID by their RFID code for a specific event. Returns the user ID which can be used for other operations. +// @Tags Event +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param rfid path string true "RFID code (10 digits)" +// @Success 200 {object} map[string]string "OK - Returns user ID" +// @Failure 400 {object} response.ErrorResponse "Bad request/Malformed request." +// @Failure 404 {object} response.ErrorResponse "User not found with the provided RFID" +// @Failure 500 {object} response.ErrorResponse "Server Error: error getting user by RFID" +// @Router /events/{eventId}/users/by-rfid/{rfid} [get] +func (h *EventHandler) GetCheckedInStatusByIds(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + userId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + result, err := h.eventService.GetCheckedInStatusByIds(r.Context(), userId, eventId) + if err != nil { + res.SendError(w, http.StatusNotFound, res.NewError("error", "Something went wrong internally.")) + return + } + // Return just the checked in status as a simple JSON object + res.Send(w, http.StatusOK, map[string]string{ + "checked_in_status": strconv.FormatBool(result), + }) +} + +type UpdateRFID struct { + RFID string `json:"rfid"` +} + +// UpdateUserRFID +// +// @Summary Updates a user's RFID tag +// @Description Associates a new RFID string with a specific user for the given event. This overwrites any existing RFID association. +// @Tags Event +// @Accept json +// @Produce json +// @Param eventId path string true "Event ID" Format(uuid) +// @Param userId path string true "User ID" Format(uuid) +// @Param body body UpdateRFID true "New RFID data" +// @Success 204 "No Content - RFID updated successfully" +// @Failure 400 {object} response.ErrorResponse "Invalid request body or UUID format" +// @Failure 404 {object} response.ErrorResponse "User or Event not found" +// @Failure 500 {object} response.ErrorResponse "Internal server error" +// @Router /events/{eventId}/users/{userId}/update-rfid [post] +func (h *EventHandler) UpdateUserRFID(w http.ResponseWriter, r *http.Request) { + eventId, err := web.PathParamToUUID(r, "eventId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_event_id", "The event ID is missing from the URL!")) + return + } + + userId, err := web.PathParamToUUID(r, "userId") + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("missing_user_id", "The user ID is missing from the URL!")) + return + } + + var payload UpdateRFID + err = json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("body_malformed", "Invalid body")) + return + } + defer r.Body.Close() + + if payload.RFID == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("body_malformed", "Invalid body")) + return + } + + tempRole := sqlc.EventRoleTypeAttendee + + err = h.eventService.UpdateEventRoleByIds(r.Context(), userId, eventId, &tempRole, nil, &payload.RFID) + if err != nil { + res.SendError(w, http.StatusNotFound, res.NewError("error", "Something went wrong internally.")) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/api/middleware/auth.go b/apps/api/internal/api/middleware/auth.go index e799f951..fbb0e392 100644 --- a/apps/api/internal/api/middleware/auth.go +++ b/apps/api/internal/api/middleware/auth.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "slices" + "strings" "time" "github.com/google/uuid" @@ -69,6 +70,34 @@ func NewAuthMiddleware(db *db.DB, logger zerolog.Logger, cfg *config.Config) *Au } } +func (m *AuthMiddleware) RequireMobileAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.logger.Trace().Msg("Incoming mobile request") + + auth := r.Header.Get("Authorization") + if auth == "" { + m.logger.Warn().Msg("Authorization header is empty.") + response.SendError(w, http.StatusUnauthorized, response.NewError("no_auth", "You are not authorized")) + return + } + + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 { + m.logger.Warn().Msg("Authorization header is malformed > 2 parts") + response.SendError(w, http.StatusUnauthorized, response.NewError("no_auth", "You are not authorized")) + return + } + + if parts[0] != "Key" || parts[1] != m.cfg.MobileAuthKey { + m.logger.Warn().Msg("Authorization header is not a valid value") + response.SendError(w, http.StatusUnauthorized, response.NewError("no_auth", "You are not authorized")) + return + } + + next.ServeHTTP(w, r) + }) +} + func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.logger.Trace().Msg("Checking auth status") diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index be937a86..1387331e 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -75,6 +75,8 @@ type Config struct { CoreBuckets CoreBuckets `envPrefix:"CORE_BUCKETS_"` Smtp SmtpConfig `envPrefix:"SMTP_"` AWS AWSConfig `envPrefix:"AWS_"` + + MobileAuthKey string `env:"MOBILE_AUTH_KEY"` } func Load() *Config { diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index a68e0594..a57650a8 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -71,3 +71,12 @@ FROM auth.users u JOIN event_roles er ON u.id = er.user_id WHERE er.event_id = $1 AND er.rfid = $2; + +-- name: GetCheckedInStatusByIds :one +SELECT EXISTS ( + SELECT 1 + FROM event_roles + WHERE user_id = $1 + AND event_id = $2 + AND checked_in_at IS NOT NULL +)::bool; diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index 86ed536e..4599383a 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -16,6 +16,7 @@ var ( ErrDuplicateEvent = errors.New("event already exists in database") ErrNoEventsDeleted = errors.New("no events deleted") ErrMultipleEventsDeleted = errors.New("multiple events affected by delete query while only expecting one to delete one") + ErrUserEventNotFound = errors.New("the user and event id combination was not found") ErrUnknown = errors.New("an unkown error was caught") ) @@ -206,6 +207,21 @@ func (r *EventRepository) GetEventRoleByDiscordIDAndEventId(ctx context.Context, return &eventRole, nil } +func (r *EventRepository) GetCheckedInStatusByUserIdAndEventId(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (bool, error) { + params := sqlc.GetCheckedInStatusByIdsParams{ + UserID: userId, + EventID: eventId, + } + + result, err := r.db.Query.GetCheckedInStatusByIds(ctx, params) + + if err != nil { + return false, err + } + + return result, nil +} + func (r *EventRepository) GetAttendeeUserIdsByEventId(ctx context.Context, eventID uuid.UUID) ([]uuid.UUID, error) { return r.db.Query.GetAttendeeUserIdsByEventId(ctx, eventID) } diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index 32c99a23..5cdea072 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -68,6 +68,28 @@ func (q *Queries) GetAttendeeUserIdsByEventId(ctx context.Context, eventID uuid. return items, nil } +const getCheckedInStatusByIds = `-- name: GetCheckedInStatusByIds :one +SELECT EXISTS ( + SELECT 1 + FROM event_roles + WHERE user_id = $1 + AND event_id = $2 + AND checked_in_at IS NOT NULL +)::bool +` + +type GetCheckedInStatusByIdsParams struct { + UserID uuid.UUID `json:"user_id"` + EventID uuid.UUID `json:"event_id"` +} + +func (q *Queries) GetCheckedInStatusByIds(ctx context.Context, arg GetCheckedInStatusByIdsParams) (bool, error) { + row := q.db.QueryRow(ctx, getCheckedInStatusByIds, arg.UserID, arg.EventID) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const getEventAttendeesWithDiscord = `-- name: GetEventAttendeesWithDiscord :many SELECT a.account_id as discord_id, diff --git a/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html index aaec6d33..7502bcdf 100644 --- a/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html +++ b/apps/api/internal/email/templates/WaitlistAcceptanceEmail.html @@ -39,7 +39,7 @@

Hi {{ .Name }},

- You have 72 hours to confirm your spot. If you + You have 24 hours to confirm your spot. If you don’t, you’ll be moved back to the waitlist to make room for others. diff --git a/apps/api/internal/services/events.go b/apps/api/internal/services/events.go index 67c893ea..c20c5918 100644 --- a/apps/api/internal/services/events.go +++ b/apps/api/internal/services/events.go @@ -422,3 +422,13 @@ func (s *EventService) GetUserInfoForEvent(ctx context.Context, userId, eventId return result, nil } + +func (s *EventService) GetCheckedInStatusByIds(ctx context.Context, userId uuid.UUID, eventId uuid.UUID) (bool, error) { + result, err := s.eventRepo.GetCheckedInStatusByUserIdAndEventId(ctx, userId, eventId) + if err != nil { + s.logger.Err(err).Msg("Failed to get user by ids") + return false, err + } + return result, nil + +} diff --git a/apps/api/internal/services/redeemables.go b/apps/api/internal/services/redeemables.go index bf02076e..c99a797f 100644 --- a/apps/api/internal/services/redeemables.go +++ b/apps/api/internal/services/redeemables.go @@ -60,6 +60,10 @@ func (s *RedeemablesService) UpdateRedeemable(ctx context.Context, redeemableID } func (s *RedeemablesService) RedeemRedeemable(ctx context.Context, redeemableID uuid.UUID, userID uuid.UUID) error { + // Need to do a check to see if the user is checked in + // Probably need event service + + // CREATE NEW SQL function for getting checked in status _, err := s.redeemablesRepo.RedeemRedeemable(ctx, redeemableID, userID) if err != nil { diff --git a/apps/discord-bot/.env.example b/apps/discord-bot/.env.example index 48960c44..621e19ee 100644 --- a/apps/discord-bot/.env.example +++ b/apps/discord-bot/.env.example @@ -1,8 +1,8 @@ DISCORD_TOKEN= API_KEY= -API_URL= +GEMINI_API_KEY= +API_URL=https://api.swamphacks.com SESSION_COOKIE= WEBHOOK_URL= WEBHOOK_PORT= -GEMINI_API_KEY= -PORT= \ No newline at end of file +EVENT_ID= diff --git a/apps/discord-bot/Dockerfile b/apps/discord-bot/Dockerfile index c1c1f97b..9179be4f 100644 --- a/apps/discord-bot/Dockerfile +++ b/apps/discord-bot/Dockerfile @@ -1,19 +1,19 @@ -FROM python:3.12-slim +# FROM python:3.12-slim -# Install prerequisites for uv -RUN apt-get update && apt-get install -y curl git +# RUN pip install uv -# Install uv by Astral -RUN curl -LsSf https://astral.sh/uv/install.sh | sh +# WORKDIR /app -# Set working directory -WORKDIR /app +# COPY pyproject.toml . +# COPY uv.lock . -# Copy bot files -COPY . . +# # ENV PATH="/root/.local/bin:$PATH" -# Environment variables are handled by docker-compose -ENV PATH="/root/.local/bin:$PATH" +# RUN uv sync --frozen --no-cache --all-extras -# Default command to run bot -CMD ["uv", "run", "main.py"] \ No newline at end of file +# COPY . . + +# EXPOSE 3000 + +# CMD ["uv", "run", "main.py"] +# # CMD ["uv", "run", "api_server.py"] diff --git a/apps/discord-bot/cogs/general.py b/apps/discord-bot/cogs/general.py index 35713b41..279609c6 100644 --- a/apps/discord-bot/cogs/general.py +++ b/apps/discord-bot/cogs/general.py @@ -4,10 +4,9 @@ import aiohttp import logging from typing import Literal, Optional -from utils.checks import is_mod_slash +from utils.checks import is_mod_slash, has_bot_full_access, requires_admin, has_bot_full_access_or_hacker, requires_admin_or_moderator import re from typing import Literal -from utils.checks import has_bot_full_access from utils.mentor_functions import set_all_mentors_available from utils.role_assignment import (get_attendees_for_event, format_assignment_summary, assign_roles_to_attendees) import os @@ -47,7 +46,7 @@ async def test(self, ctx: commands.Context) -> None: @app_commands.describe( amount="The amount of messages to delete" ) - @has_bot_full_access() + @requires_admin_or_moderator() async def delete( self, interaction: discord.Interaction, @@ -67,7 +66,7 @@ async def delete( ) @app_commands.command(name="delete_all_threads", description="Delete all threads in a specified channel") - @has_bot_full_access() + @requires_admin_or_moderator() async def delete_all_threads(self, interaction: discord.Interaction, channel: discord.TextChannel, delete_archived: bool = False) -> None: """Delete all threads in a specified channel @@ -96,7 +95,7 @@ async def delete_all_threads(self, interaction: discord.Interaction, channel: di ) @app_commands.command(name="delete_all_vcs", description="Delete all voice channels in a specified category") - @has_bot_full_access() + @requires_admin_or_moderator() async def delete_all_vcs(self, interaction: discord.Interaction, category: discord.CategoryChannel) -> None: """Delete all voice channels in a specified category @@ -201,8 +200,8 @@ async def manage_role( ephemeral=True ) - @has_bot_full_access() @app_commands.command(name="set_available_mentors", description="Set available mentors in the server") + @requires_admin_or_moderator() async def set_all_mentors_available(self, interaction: discord.Interaction) -> None: """ Set all users in the server with acceptable mentor roles to "Available Mentor" @@ -253,6 +252,7 @@ async def set_all_mentors_available(self, interaction: discord.Interaction) -> N @app_commands.command(name="add_to_thread", description="Add a user to the support thread") @app_commands.describe(user="The user to add to the thread") + @has_bot_full_access() async def add_to_thread(self, interaction: discord.Interaction, user: discord.Member) -> None: """ Add a specified user to a support thread @@ -291,20 +291,69 @@ async def add_to_thread(self, interaction: discord.Interaction, user: discord.Me await interaction.response.send_message(f"An error occurred: {str(e)}", ephemeral=True) @app_commands.command(name="create_vc", description="Creates a voice channel for support") - @has_bot_full_access() + @has_bot_full_access_or_hacker() async def create_vc(self, interaction: discord.Interaction) -> None: """ Create a voice channel for support, requires a --- SwampHacks XI (Support-VCs) --- category and a Mentor role + Can only be used in a thread, and only one VC per thread is allowed. Args: interaction: The interaction that triggered this command """ - mentor_role = discord.utils.get(interaction.guild.roles, name="Mentor") + from utils.roles_config import RoleNames, get_role_id + from components.ticket_state import thread_vc_mapping + + # Check if command is being used in a thread + if not isinstance(interaction.channel, discord.Thread): + await interaction.response.send_message( + "❌ This command can only be used inside a support thread.", + ephemeral=True + ) + return + + thread = interaction.channel + + # Check if thread already has a VC associated + if thread.id in thread_vc_mapping: + existing_vc_id = thread_vc_mapping[thread.id] + existing_vc = interaction.guild.get_channel(existing_vc_id) + if existing_vc: + await interaction.response.send_message( + f"❌ This thread already has an associated voice channel: {existing_vc.mention}", + ephemeral=True + ) + return + else: + # VC was deleted, remove from mapping + del thread_vc_mapping[thread.id] + + # Extract thread number from thread name (e.g., "support-5" -> "5") + thread_number = None + try: + if "-" in thread.name: + thread_number = thread.name.split("-")[-1] + # Verify it's a number + int(thread_number) + except (ValueError, IndexError): + await interaction.response.send_message( + "❌ Could not determine thread number from thread name. Thread name should be in format 'support-'.", + ephemeral=True + ) + return + + mentor_role_name = RoleNames.MENTOR_XI + mentor_role_id = get_role_id(mentor_role_name) + + if mentor_role_id: + mentor_role = interaction.guild.get_role(int(mentor_role_id)) + else: + mentor_role = discord.utils.get(interaction.guild.roles, name=mentor_role_name) + category = discord.utils.get(interaction.guild.categories, name="--- SwampHacks XI (Support-VCs) ---") vc_author = interaction.user if not mentor_role: await interaction.response.send_message( - "Error: Could not find the **Mentor** role. Please create it before using this command.", + f"Error: Could not find the **{mentor_role_name}** role. Please create it before using this command.", ephemeral=True ) return @@ -315,12 +364,22 @@ async def create_vc(self, interaction: discord.Interaction) -> None: ) return + # Check if VC with this number already exists + # Use "thread-VC-{number}" format to avoid conflicts with directly created VCs + vc_name = f"thread-VC-{thread_number}" + existing_vc = discord.utils.get(category.voice_channels, name=vc_name) + if existing_vc: + await interaction.response.send_message( + f"❌ A voice channel with name {vc_name} already exists. Please delete it first or use a different thread.", + ephemeral=True + ) + return + overwrites = { interaction.guild.default_role: discord.PermissionOverwrite(view_channel=False, connect=False), mentor_role: discord.PermissionOverwrite(view_channel=True, connect=True), vc_author: discord.PermissionOverwrite(view_channel=True, connect=True), } - vc_name = get_next_support_vc_name(category) # Create the voice channel and get the channel object voice_channel = await interaction.guild.create_voice_channel( name=vc_name, @@ -345,6 +404,9 @@ async def create_vc(self, interaction: discord.Interaction) -> None: print("Voice channel does not have an associated text channel.") return + # Store the association between thread and VC + thread_vc_mapping[thread.id] = voice_channel.id + # ping the user who created the thread await interaction.response.send_message( f"Voice channel created: {voice_channel.mention}", @@ -353,10 +415,10 @@ async def create_vc(self, interaction: discord.Interaction) -> None: @app_commands.command(name="grant_vc_access", description="Grant a user access to a voice channel") @app_commands.describe(user="Grant a user access to a voice channel") - @has_bot_full_access() + @has_bot_full_access_or_hacker() async def grant_vc_access(self, interaction: discord.Interaction, user: discord.Member) -> None: """ - Grant a user access to a voice channel, can only be used in a voice channel under the Support-VCs category + Grant a user access to a voice channel, can only be used in a voice channel under the --- SwampHacks XI (Support-VCs) --- category Args: interaction: The interaction that triggered this command @@ -370,8 +432,8 @@ async def grant_vc_access(self, interaction: discord.Interaction, user: discord. ) return # next ensure the channel is in the support category specifically - if not interaction.channel.category or interaction.channel.category.name != "Support-VCs": - await interaction.response.send_message('This command can only be used in the "Support-VCs" category.', ephemeral=True) + if not interaction.channel.category or interaction.channel.category.name != "--- SwampHacks XI (Support-VCs) ---": + await interaction.response.send_message('This command can only be used in the "--- SwampHacks XI (Support-VCs) ---" category.', ephemeral=True) return # check if user already has access to the voice channel @@ -458,10 +520,11 @@ def replace_mention(match): ) @app_commands.describe( event_id="UUID of event", - role="Discord role to assign to attendees" + role="Discord role to assign to attendees", + test_mode="Test mode: only process first 5 users and stop on first error (default: False)" ) - @is_mod_slash() - async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: str, role: discord.Role) -> None: + @requires_admin_or_moderator() + async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: str, role: discord.Role, test_mode: bool = False) -> None: """Assign role to all attendees from API using webhook Args: @@ -478,7 +541,8 @@ async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: await interaction.followup.send("Error: Could not determine guild.", ephemeral=True) return - api_url = os.getenv("API_URL", "http://localhost:8080") + api_url = os.getenv("API_URL", "https://api.swamphacks.com") + session_cookie = os.getenv("SESSION_COOKIE") if not session_cookie: await interaction.followup.send("Error: SESSION_COOKIE is not set.", ephemeral=True) @@ -494,8 +558,35 @@ async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: await interaction.followup.send("Error: No attendees found for event.", ephemeral=True) return - newly_assigned, already_had, failed, errors = await assign_roles_to_attendees(webhook_url, attendees, role.name, str(guild_id)) - summary = format_assignment_summary(len(attendees), newly_assigned, already_had, failed, errors) + # Limit to 5 users in test mode + if test_mode: + attendees = attendees[:5] + await interaction.followup.send(f"🧪 **TEST MODE**: Processing first 5 attendees only. Will stop on first error.", ephemeral=True) + + total_attendees = len(attendees) + + # Send initial progress message + mode_text = "🧪 TEST MODE: " if test_mode else "" + await interaction.followup.send(f"{mode_text}🔄 Processing {total_attendees} attendees in chunks of 20...", ephemeral=True) + + # Progress callback to send updates + async def progress_update(current: int, total: int): + if current % 20 == 0 or current == total: # Update every 20 or at the end + await interaction.followup.send(f"⏳ Progress: {current}/{total} processed...", ephemeral=True) + + newly_assigned, already_had, failed, errors = await assign_roles_to_attendees( + webhook_url, + attendees, + role.name, + str(guild_id), + chunk_size=20, + progress_callback=progress_update, + test_mode=test_mode + ) + + summary = format_assignment_summary(total_attendees, newly_assigned, already_had, failed, errors) + if test_mode and errors: + summary += f"\n\n⚠️ **Test stopped early due to error.** Fix the issue before running full assignment." await interaction.followup.send(summary, ephemeral=True) except Exception as e: @@ -503,7 +594,7 @@ async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: @app_commands.command(name="remove_role_from_all", description="Remove a specific role from all members in the server") @app_commands.describe(role="The role to remove from all members") - @is_mod_slash() + @requires_admin_or_moderator() async def remove_role_from_all(self, interaction: discord.Interaction, role: discord.Role) -> None: """Remove a specific role from all members in the server """ @@ -575,7 +666,7 @@ async def on_member_join(self, member: discord.Member) -> None: if not guild_id: return - api_url = os.getenv("API_URL", "http://localhost:8080") + api_url = os.getenv("API_URL", "https://api.swamphacks.com") session_cookie = os.getenv("SESSION_COOKIE") event_id = os.getenv("EVENT_ID") diff --git a/apps/discord-bot/components/support_modals.py b/apps/discord-bot/components/support_modals.py index 9e50e564..fb46b250 100644 --- a/apps/discord-bot/components/support_modals.py +++ b/apps/discord-bot/components/support_modals.py @@ -54,16 +54,16 @@ async def on_submit(self, interaction: discord.Interaction) -> None: reports_channel = discord.utils.get(interaction.guild.channels, name="reports") support_channel = discord.utils.get(interaction.guild.channels, name="support") thread_author = interaction.user - mod_role_name = RoleNames.MODERATOR - mod_role_id = get_role_id(mod_role_name) + mentor_role_name = RoleNames.MENTOR_XI + mentor_role_id = get_role_id(mentor_role_name) - if mod_role_id: - mod_role = interaction.guild.get_role(int(mod_role_id)) + if mentor_role_id: + mentor_role = interaction.guild.get_role(int(mentor_role_id)) else: - mod_role = discord.utils.get(interaction.guild.roles, name=mod_role_name) + mentor_role = discord.utils.get(interaction.guild.roles, name=mentor_role_name) - if not mod_role: - await interaction.response.send_message(f"Error: Could not find the **{mod_role_name}** role. Please create it before using this command.", ephemeral=True) + if not mentor_role: + await interaction.response.send_message(f"Error: Could not find the **{mentor_role_name}** role. Please create it before using this command.", ephemeral=True) return # check if the channels exist @@ -109,18 +109,18 @@ async def on_submit(self, interaction: discord.Interaction) -> None: await thread.add_user(thread_author) - close_button = CloseThreadButton(thread, self.description_input) - close_button_view = View() + close_button = CloseThreadButton(thread, self.description_input, thread_author=thread_author) + close_button_view = View(timeout=None) close_button_view.add_item(close_button) # send initial message as embed in thread with inquiry details thread_embed = discord.Embed( title=f"Request: {self.title_input.value}", - description=f"Description: {description}\n\n✅ Thank you for your request, we will be with you shortly!", + description=f"Description: {description}\n\n✅ Thank you for your request, we will be with you shortly!\n\n💡 **Note:** Mentors and hackers may use the `/create_vc` command to create a private voice chat. Only mentors have access to `/add_to_thread`, allowing them to add anyone to a thread.", color=discord.Color.green(), ) await thread.send(content=f"{thread_author.mention}") - await thread.send(embed=thread_embed) + await thread.send(embed=thread_embed, view=close_button_view) # create embed for reports channel reports_embed = discord.Embed( @@ -130,12 +130,13 @@ async def on_submit(self, interaction: discord.Interaction) -> None: ) reports_embed.add_field(name="Opened by", value=f"{thread_author.mention}\n", inline=True) - # soft ping staff and send the embed to the reports channel + # soft ping mentor role and send the embed to the reports channel + # TEMPORARILY DISABLED: Mentor ping removed await reports_channel.send( - content=f"||{mod_role.mention}||", + # content=f"||{mentor_role.mention}||", # Temporarily disabled embed=reports_embed, view=SupportThreadButtons(thread, self.description_input), - allowed_mentions=discord.AllowedMentions(roles=True) + # allowed_mentions=discord.AllowedMentions(roles=True) # Temporarily disabled ) await interaction.response.send_message( @@ -186,19 +187,19 @@ async def on_submit(self, interaction: discord.Interaction) -> None: global last_pinged_mentor_index reports_channel = discord.utils.get(interaction.guild.channels, name="reports") - mod_role_name = RoleNames.MODERATOR - mod_role_id = get_role_id(mod_role_name) + mentor_role_name = RoleNames.MENTOR_XI + mentor_role_id = get_role_id(mentor_role_name) - if mod_role_id: - mod_role = interaction.guild.get_role(int(mod_role_id)) + if mentor_role_id: + mentor_role = interaction.guild.get_role(int(mentor_role_id)) else: - mod_role = discord.utils.get(interaction.guild.roles, name=mod_role_name) + mentor_role = discord.utils.get(interaction.guild.roles, name=mentor_role_name) category = discord.utils.get(interaction.guild.categories, name="--- SwampHacks XI (Support-VCs) ---") vc_author = interaction.user - if not mod_role: - await interaction.response.send_message(f"Error: Could not find the **{mod_role_name}** role. Please create it before using this command.", ephemeral=True) + if not mentor_role: + await interaction.response.send_message(f"Error: Could not find the **{mentor_role_name}** role. Please create it before using this command.", ephemeral=True) return if not reports_channel: @@ -218,10 +219,10 @@ async def on_submit(self, interaction: discord.Interaction) -> None: shortened_description = description[:200] + "..." - # give permissions to the moderator role and the user who clicked the button + # give permissions to the mentor role and the user who clicked the button overwrites = { interaction.guild.default_role: discord.PermissionOverwrite(view_channel=False, connect=False), - mod_role: discord.PermissionOverwrite(view_channel=True, connect=True), + mentor_role: discord.PermissionOverwrite(view_channel=True, connect=True), vc_author: discord.PermissionOverwrite(view_channel=True, connect=True), } @@ -280,9 +281,10 @@ async def on_submit(self, interaction: discord.Interaction) -> None: ) reports_embed.add_field(name="Opened by", value=f"{vc_author.mention}\n", inline=True) + # TEMPORARILY DISABLED: Mentor ping removed await reports_channel.send( - content=f"||{mod_role.mention}||", + # content=f"||{mentor_role.mention}||", # Temporarily disabled embed=reports_embed, view=SupportVCButtons(voice_channel, self.description_input), - allowed_mentions=discord.AllowedMentions(roles=True) + # allowed_mentions=discord.AllowedMentions(roles=True) # Temporarily disabled ) \ No newline at end of file diff --git a/apps/discord-bot/components/support_thread_buttons.py b/apps/discord-bot/components/support_thread_buttons.py index 4aca5e8e..ec54436d 100644 --- a/apps/discord-bot/components/support_thread_buttons.py +++ b/apps/discord-bot/components/support_thread_buttons.py @@ -28,20 +28,44 @@ def __init__(self, thread: discord.Thread, description_input: discord.ui.TextInp class CloseThreadButton(Button): """Button to close a support thread, archive it, and lock it.""" - def __init__(self, thread: discord.Thread, description_input: discord.ui.TextInput, threadID=None): + def __init__(self, thread: discord.Thread, description_input: discord.ui.TextInput, thread_author=None, threadID=None): """ Initializes the CloseThreadButton with the given thread and description input. Args: thread (discord.Thread): The support thread to be closed. description_input (discord.ui.TextInput): The text input containing the description of the issue. + thread_author (discord.Member, optional): The user who created the thread. If provided, only creator and mentors can close. + threadID: Optional thread ID parameter. """ super().__init__(label="Close Thread", style=ButtonStyle.primary, custom_id="close_thread", emoji="❌") self.thread = thread self.threadID = threadID self.description_input = description_input + self.thread_author = thread_author async def callback(self, interaction: Interaction): + # Check if user is authorized to close (creator or mentor) + if self.thread_author: + from utils.roles_config import get_acceptable_roles + + is_creator = interaction.user.id == self.thread_author.id + is_mentor = False + + # Check if user has mentor/acceptable role + if interaction.guild and interaction.user: + member = interaction.guild.get_member(interaction.user.id) + if member: + acceptable_roles = get_acceptable_roles() + is_mentor = any(role.name in acceptable_roles for role in member.roles) + + if not is_creator and not is_mentor: + await interaction.response.send_message( + "❌ Only the thread creator or mentors can close this thread.", + ephemeral=True + ) + return + claimed_tickets.pop(self.thread.id, None) # print(claimed_tickets) @@ -54,16 +78,32 @@ async def callback(self, interaction: Interaction): if not archived_threads_channel: await interaction.response.send_message("❌ Archived threads channel not found.", ephemeral=True) return - bot_avatar_url = interaction.client.user.avatar.url if interaction.client.user.avatar.url else None + bot_avatar_url = interaction.client.user.avatar.url if interaction.client.user.avatar else None + + # Send response first to avoid timeout + await interaction.response.defer(ephemeral=True) try: # rename the thread to get new title prefix = "archived-" title = "" if interaction.message.embeds: - title = interaction.message.embeds[0].title - trimmed_title = title[22:100 - len(prefix)] - title = trimmed_title + embed_title = interaction.message.embeds[0].title + # Handle different embed title formats + if embed_title.startswith("💬 New Thread Request: "): + # Reports channel embed format + title = embed_title[22:] # Remove "💬 New Thread Request: " + elif embed_title.startswith("Request: "): + # Thread embed format + title = embed_title[9:] # Remove "Request: " + else: + # Fallback: use the full title + title = embed_title + + # Trim to fit Discord's thread name limit (100 chars) minus prefix + max_length = 100 - len(prefix) + if len(title) > max_length: + title = title[:max_length] else: title = self.thread.name new_name = f"archived-{title}" @@ -90,9 +130,68 @@ async def callback(self, interaction: Interaction): # send the summary to the archived threads channel await archived_threads_channel.send(embed=embed) - # archive and lock the thread - await interaction.response.send_message(f"Thread: {self.thread.mention} has been archived and locked.", ephemeral=True) - await self.thread.edit(name=new_name,archived=True, locked=True) + # Delete associated VC if it exists + from components.ticket_state import thread_vc_mapping + try: + if self.thread.id in thread_vc_mapping: + vc_id = thread_vc_mapping[self.thread.id] + vc = interaction.guild.get_channel(vc_id) + if vc and isinstance(vc, discord.VoiceChannel): + try: + await vc.delete(reason=f"Thread {self.thread.id} was closed") + except Exception as e: + print(f"Failed to delete VC {vc_id} associated with thread {self.thread.id}: {e}") + # Remove from mapping even if VC doesn't exist anymore + del thread_vc_mapping[self.thread.id] + except Exception as e: + # If VC deletion fails, log but continue with thread closure + print(f"Error handling VC deletion for thread {self.thread.id}: {e}") + + try: + # Fetch members explicitly and collect all member IDs + members_to_remove = set() + + # Add all members from thread.members + for thread_member in self.thread.members: + members_to_remove.add(thread_member.id) + + # Also explicitly include the interaction user and thread author + if interaction.user: + members_to_remove.add(interaction.user.id) + if self.thread_author: + members_to_remove.add(self.thread_author.id) + + # Try to fetch members if the list seems incomplete + if len(members_to_remove) == 0: + try: + async for member in self.thread.fetch_members(): + members_to_remove.add(member.id) + except Exception as fetch_error: + print(f"Failed to fetch members: {fetch_error}") + + for member_id in members_to_remove: + try: + member = interaction.guild.get_member(member_id) + if member: + await self.thread.remove_user(member) + except Exception as remove_error: + print(f"Failed to remove member {member_id} from thread {self.thread.id}: {remove_error}") + except Exception as e: + print(f"Error removing members from thread {self.thread.id}: {e}") + + try: + await self.thread.edit(name=new_name) + await self.thread.edit(archived=True, locked=True) + except Exception as e: + print(f"Failed to archive thread {self.thread.id}: {e}") + try: + await self.thread.edit(archived=True, locked=True) + except Exception as e2: + print(f"Failed to archive thread {self.thread.id} (fallback attempt): {e2}") + await interaction.followup.send(f"⚠️ Thread archiving encountered an error, but the thread should be closed.", ephemeral=True) + return + + await interaction.followup.send(f"Thread has been archived and locked.", ephemeral=True) # Set mentor status - only mark as available if they have no more tickets @@ -101,21 +200,6 @@ async def callback(self, interaction: Interaction): await set_available_mentor(interaction.user, True) await set_busy_mentor(interaction.user, False) - # edit original message to disable claim button - message = interaction.message - if not message: - await interaction.response.send_message( - "Message not found.", - ephemeral=True - ) - return - new_view = SupportThreadButtons(self.thread, self.description_input) - # disable all buttons in the view - for item in new_view.children: - item.disabled = True - # copy the original embed and update its title and description - embed = message.embeds[0] if message.embeds else None - # trim description description = self.description_input.value shortened_description = "" @@ -123,20 +207,92 @@ async def callback(self, interaction: Interaction): shortened_description = description[:200] + "..." else: shortened_description = description - if embed: - new_embed = embed.copy() - new_embed.description = f"Issue: {shortened_description}\n\nActions: {interaction.user.mention} closed {self.thread.name}." - new_embed.color = discord.Color.red() - await message.edit(embed=new_embed, view=new_view) - else: - await message.edit(view=new_view) + + # Create disabled view for updating messages + new_view = SupportThreadButtons(self.thread, self.description_input) + # disable all buttons in the view + for item in new_view.children: + item.disabled = True + + # Update the thread message (if button was clicked from thread) + message = interaction.message + if message: + embed = message.embeds[0] if message.embeds else None + if embed: + new_embed = embed.copy() + new_embed.description = f"Issue: {shortened_description}\n\nActions: Thread closed by {interaction.user.display_name}." + new_embed.color = discord.Color.red() + try: + await message.edit(embed=new_embed, view=new_view, allowed_mentions=discord.AllowedMentions.none()) + except Exception as e: + print(f"Failed to update thread message: {e}") + else: + try: + await message.edit(view=new_view, allowed_mentions=discord.AllowedMentions.none()) + except Exception as e: + print(f"Failed to update thread message view: {e}") + + # Also update the reports channel message + # Find the reports channel message by searching for messages with the thread mention or matching title + try: + reports_channel = discord.utils.get(interaction.guild.channels, name="reports") + if reports_channel: + # Search for the message in reports channel + # Look for messages that mention this thread or have matching embed title + async for reports_message in reports_channel.history(limit=100): + if reports_message.embeds: + embed_title = reports_message.embeds[0].title + # Check if this is the reports channel message for this thread + if (embed_title.startswith("💬 New Thread Request: ") and + title in embed_title): + # Found the reports channel message + reports_embed = reports_message.embeds[0].copy() + reports_embed.description = f"Issue: {shortened_description}\n\nActions: {interaction.user.mention} closed {self.thread.name}." + reports_embed.color = discord.Color.red() + try: + await reports_message.edit(embed=reports_embed, view=new_view) + except Exception as e: + print(f"Failed to update reports channel message: {e}") + break + except Exception as e: + print(f"Failed to find/update reports channel message: {e}") except NotFound: - await interaction.response.send_message( + await interaction.followup.send( "This support thread no longer exists.", ephemeral=True ) except Exception as e: - await interaction.response.send_message(f"Failed to archive the support thread. Error: {e}", ephemeral=True) + await interaction.followup.send(f"Failed to archive the support thread. Error: {e}", ephemeral=True) + +class JoinThreadButton(Button): + """Button to join a claimed support thread for additional mentor assistance.""" + def __init__(self, thread: discord.Thread): + super().__init__(label="Join Thread", style=ButtonStyle.primary, custom_id="join_thread", emoji="➡️") + self.thread = thread + + async def callback(self, interaction: Interaction): + try: + await self.thread.add_user(interaction.user) + await interaction.response.send_message(f"You've joined the thread {self.thread.mention}", ephemeral=True) + except NotFound: + await interaction.response.send_message("❌ This thread no longer exists.", ephemeral=True) + except Exception as e: + await interaction.response.send_message(f"❌ Error joining thread: {str(e)}", ephemeral=True) + +class JoinThreadButton(Button): + """Button to join a claimed support thread for additional mentor assistance.""" + def __init__(self, thread: discord.Thread): + super().__init__(label="Join Thread", style=ButtonStyle.primary, custom_id="join_thread", emoji="➡️") + self.thread = thread + + async def callback(self, interaction: Interaction): + try: + await self.thread.add_user(interaction.user) + await interaction.response.send_message(f"You've joined the thread {self.thread.mention}", ephemeral=True) + except NotFound: + await interaction.response.send_message("❌ This thread no longer exists.", ephemeral=True) + except Exception as e: + await interaction.response.send_message(f"❌ Error joining thread: {str(e)}", ephemeral=True) class JoinThreadButton(Button): """Button to join a claimed support thread for additional mentor assistance.""" diff --git a/apps/discord-bot/components/ticket_state.py b/apps/discord-bot/components/ticket_state.py index ebaa327d..ead327ab 100644 --- a/apps/discord-bot/components/ticket_state.py +++ b/apps/discord-bot/components/ticket_state.py @@ -1 +1,2 @@ -claimed_tickets = {} # {voice_channel_id: user_id or support_thread_id: user_id} \ No newline at end of file +claimed_tickets = {} # {voice_channel_id: user_id or support_thread_id: user_id} +thread_vc_mapping = {} # {thread_id: voice_channel_id} - Maps threads to their associated VCs \ No newline at end of file diff --git a/apps/discord-bot/main.py b/apps/discord-bot/main.py index 18f7e1d0..1b8ec29c 100644 --- a/apps/discord-bot/main.py +++ b/apps/discord-bot/main.py @@ -28,7 +28,9 @@ ) # Load environment variables -load_dotenv() +# Explicitly load from discord-bot directory +env_path = pathlib.Path(__file__).parent / '.env' +load_dotenv(env_path) token: Optional[str] = os.getenv('DISCORD_TOKEN') # Configure bot intents diff --git a/apps/discord-bot/test_prod_db.py b/apps/discord-bot/test_prod_db.py new file mode 100755 index 00000000..706bc5fe --- /dev/null +++ b/apps/discord-bot/test_prod_db.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Test script to verify Discord bot can access production database via API. +This tests the same endpoint that assign_hacker_roles uses. +""" + +import os +import sys +import asyncio +import aiohttp +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +API_URL = os.getenv("API_URL", "https://api.swamphacks.com") +SESSION_COOKIE = os.getenv("SESSION_COOKIE") +EVENT_ID = os.getenv("EVENT_ID") + +async def test_api_connection(): + """Test basic API connectivity""" + print(f"🔍 Testing API connection to: {API_URL}") + + async with aiohttp.ClientSession() as session: + try: + async with session.get(f"{API_URL}/ping") as response: + if response.status == 200: + text = await response.text() + print(f"✅ API is reachable: {text.strip()}") + return True + else: + print(f"❌ API ping failed with status: {response.status}") + return False + except Exception as e: + print(f"❌ Failed to connect to API: {e}") + return False + +async def test_attendees_endpoint(): + """Test the attendees endpoint that assign_hacker_roles uses""" + if not SESSION_COOKIE: + print("❌ SESSION_COOKIE is not set in .env file") + return False + + if not EVENT_ID: + print("❌ EVENT_ID is not set in .env file") + return False + + print(f"\n🔍 Testing attendees endpoint:") + print(f" API URL: {API_URL}") + print(f" Event ID: {EVENT_ID}") + print(f" Session Cookie: {SESSION_COOKIE[:20]}...") + + endpoint = f"{API_URL}/discord/event/{EVENT_ID}/attendees" + + async with aiohttp.ClientSession() as session: + headers = {"Cookie": f"sh_session_id={SESSION_COOKIE}"} + + try: + async with session.get(endpoint, headers=headers) as response: + status = response.status + print(f"\n📊 Response Status: {status}") + + if status == 200: + data = await response.json() + attendee_count = len(data) + print(f"✅ Successfully retrieved {attendee_count} attendees from production database!") + + if attendee_count > 0: + print(f"\n📋 Sample attendee data:") + sample = data[0] + print(f" - Discord ID: {sample.get('discord_id', 'N/A')}") + print(f" - User ID: {sample.get('user_id', 'N/A')}") + print(f" - Name: {sample.get('name', 'N/A')}") + print(f" - Email: {sample.get('email', 'N/A')}") + + return True + + elif status == 401: + print("❌ Authentication failed - SESSION_COOKIE may be invalid or expired") + text = await response.text() + print(f" Response: {text}") + return False + + elif status == 404: + print("⚠️ Event not found or no attendees with Discord IDs") + return True # This is still a valid response + + else: + text = await response.text() + print(f"❌ Request failed with status {status}") + print(f" Response: {text}") + return False + + except aiohttp.ClientError as e: + print(f"❌ Network error: {e}") + return False + except Exception as e: + print(f"❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + +async def main(): + print("=" * 60) + print("Discord Bot Production Database Connection Test") + print("=" * 60) + + # Test 1: Basic API connectivity + api_ok = await test_api_connection() + if not api_ok: + print("\n❌ Cannot proceed - API is not reachable") + sys.exit(1) + + # Test 2: Attendees endpoint (the one assign_hacker_roles uses) + db_ok = await test_attendees_endpoint() + + print("\n" + "=" * 60) + if db_ok: + print("✅ All tests passed! Your bot can access the production database.") + print(" The assign_hacker_roles command should work correctly.") + else: + print("❌ Tests failed. Please check:") + print(" 1. SESSION_COOKIE is valid and not expired") + print(" 2. EVENT_ID is correct") + print(" 3. Your session has proper permissions") + sys.exit(1) + print("=" * 60) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/apps/discord-bot/utils/checks.py b/apps/discord-bot/utils/checks.py index f4fde44f..64b17c31 100644 --- a/apps/discord-bot/utils/checks.py +++ b/apps/discord-bot/utils/checks.py @@ -1,8 +1,9 @@ import os from discord import app_commands, Interaction, Permissions +import discord import json from typing import Callable, Coroutine, Any -from utils.roles_config import get_acceptable_roles +from utils.roles_config import get_acceptable_roles, RoleNames def has_bot_full_access() -> Callable[[Interaction], Coroutine[Any, Any, bool]]: """ @@ -47,3 +48,92 @@ def is_mod_slash() -> Callable[[Interaction], Coroutine[Any, Any, bool]]: """ # Reuse the same logic as has_bot_full_access return has_bot_full_access() + +def requires_admin() -> Callable[[Interaction], Coroutine[Any, Any, bool]]: + """ + Check if the user has the Admin role for slash commands. + This is a stricter check than has_bot_full_access() and requires the Admin role specifically. + + Returns: + bool: True if the user has the Admin role, False otherwise + """ + async def predicate(interaction: Interaction): + # Ensure interaction is in a guild and a user exists + if not interaction.guild or not interaction.user: + return False + + member = interaction.guild.get_member(interaction.user.id) + if not member: + return False + + # Check if user has Admin role + admin_role = discord.utils.get(member.roles, name=RoleNames.ADMIN) + if admin_role: + return True + + return False + + return app_commands.check(predicate) + +def has_bot_full_access_or_hacker() -> Callable[[Interaction], Coroutine[Any, Any, bool]]: + """ + Check if the user has bot full access (moderator, mentor, bot, staff, admin) OR Hacker (XI) role. + This allows Hacker (XI) to use specific commands like create_vc, add_to_thread, grant_vc_access + while still restricting them from other moderator-only commands. + + Returns: + bool: True if the user has bot full access or Hacker (XI) role, False otherwise + """ + async def predicate(interaction: Interaction): + # Ensure interaction is in a guild and a user exists + if not interaction.guild or not interaction.user: + return False + + member = interaction.guild.get_member(interaction.user.id) + if not member: + return False + + # First check if user has bot full access (moderator, mentor, bot, staff, admin) + acceptable_roles = get_acceptable_roles() + if any(role.name in acceptable_roles for role in member.roles): + return True + + # Then check if user has Hacker (XI) role + hacker_role = discord.utils.get(member.roles, name=RoleNames.HACKER_XI) + if hacker_role: + return True + + return False + + return app_commands.check(predicate) + +def requires_admin_or_moderator() -> Callable[[Interaction], Coroutine[Any, Any, bool]]: + """ + Check if the user has Admin or Moderator role for dangerous commands. + This is a stricter check than has_bot_full_access() and only allows Admin and Moderator roles. + + Returns: + bool: True if the user has Admin or Moderator role, False otherwise + """ + async def predicate(interaction: Interaction): + # Ensure interaction is in a guild and a user exists + if not interaction.guild or not interaction.user: + return False + + member = interaction.guild.get_member(interaction.user.id) + if not member: + return False + + # Check if user has Admin role + admin_role = discord.utils.get(member.roles, name=RoleNames.ADMIN) + if admin_role: + return True + + # Check if user has Moderator role + moderator_role = discord.utils.get(member.roles, name=RoleNames.MODERATOR) + if moderator_role: + return True + + return False + + return app_commands.check(predicate) diff --git a/apps/discord-bot/utils/role_assignment.py b/apps/discord-bot/utils/role_assignment.py index afc92e5f..6ec28bbb 100644 --- a/apps/discord-bot/utils/role_assignment.py +++ b/apps/discord-bot/utils/role_assignment.py @@ -1,4 +1,5 @@ import logging +import asyncio import aiohttp from typing import List, Tuple, Optional @@ -106,45 +107,73 @@ async def assign_role_via_webhook( return ("failed", str(e)) -async def assign_roles_to_attendees(webhook_url: str, attendees: List[Tuple[str, str, str, Optional[str]]], role_name: str, guild_id: str) -> Tuple[int, int, int, List[str]]: - """Assign roles to attendees via webhook +async def assign_roles_to_attendees(webhook_url: str, attendees: List[Tuple[str, str, str, Optional[str]]], role_name: str, guild_id: str, chunk_size: int = 20, progress_callback=None, test_mode: bool = False) -> Tuple[int, int, int, List[str]]: + """Assign roles to attendees via webhook in chunks Args: webhook_url: URL of webhook to send requests to attendees: List of attendees to assign roles to role_name: Name of role to assign guild_id: Discord guild ID + chunk_size: Number of users to process at a time (default: 20) + progress_callback: Optional async function to call with progress updates (current, total) + test_mode: If True, stop on first error (default: False) Returns: Tuple of (newly_assigned: int, already_had: int, failed: int, errors: List[str]) """ - newly_assigned = 0 already_had = 0 failed = 0 errors = [] + total_attendees = len(attendees) + # Create a single session to reuse for all requests (better performance) async with aiohttp.ClientSession() as session: - for discord_id, user_id, name, email in attendees: - status, error_msg = await assign_role_via_webhook(webhook_url, discord_id, role_name, guild_id, session) # Pass session + # Process attendees in chunks + for chunk_start in range(0, total_attendees, chunk_size): + chunk_end = min(chunk_start + chunk_size, total_attendees) + chunk = attendees[chunk_start:chunk_end] + + # Process this chunk + for discord_id, user_id, name, email in chunk: + status, error_msg = await assign_role_via_webhook(webhook_url, discord_id, role_name, guild_id, session) + + if status == "newly_assigned": + newly_assigned += 1 + elif status == "already_had": + already_had += 1 + elif status == "failed": + failed += 1 + error_detail = f"User {name} (Discord ID: {discord_id}, User ID: {user_id})" + if error_msg: + error_detail += f": {error_msg}" + errors.append(error_detail.strip()) + + # In test mode, stop on first error + if test_mode: + logger.warning(f"Test mode: Stopping on first error - {error_detail}") + return (newly_assigned, already_had, failed, errors) + else: # Handle "unknown" status + failed += 1 + error_detail = f"User {name} (Discord ID: {discord_id}, User ID: {user_id}): Unknown status from webhook" + if error_msg: + error_detail += f" - {error_msg}" + errors.append(error_detail.strip()) + + # In test mode, stop on first error + if test_mode: + logger.warning(f"Test mode: Stopping on first error - {error_detail}") + return (newly_assigned, already_had, failed, errors) - if status == "newly_assigned": - newly_assigned += 1 - elif status == "already_had": - already_had += 1 - elif status == "failed": - failed += 1 - error_detail = f"User {name} (Discord ID: {discord_id}, User ID: {user_id})" - if error_msg: - error_detail += f": {error_msg}" - errors.append(error_detail.strip()) - else: # Handle "unknown" status - failed += 1 - error_detail = f"User {name} (Discord ID: {discord_id}, User ID: {user_id}): Unknown status from webhook" - if error_msg: - error_detail += f" - {error_msg}" - errors.append(error_detail.strip()) + # Call progress callback if provided + if progress_callback: + await progress_callback(chunk_end, total_attendees) + + # Small delay between chunks to avoid rate limits (except after last chunk) + if chunk_end < total_attendees: + await asyncio.sleep(0.5) # 500ms delay between chunks return (newly_assigned, already_had, failed, errors) @@ -166,6 +195,8 @@ def format_assignment_summary( errors: List of error messages max_errors_displayed: Maximum number of errors to show """ + DISCORD_MAX_LENGTH = 2000 + message = f"**Role Assignment Complete**\n\n" message += f"**Summary:**\n" message += f"- Total attendees: {total_attendees}\n" @@ -180,9 +211,33 @@ def format_assignment_summary( if errors: message += f"\n**Errors ({len(errors)}):**\n" - for error in errors[:max_errors_displayed]: + + # Calculate how many errors we can fit + base_length = len(message) + remaining_chars = DISCORD_MAX_LENGTH - base_length - 50 # Reserve 50 chars for "... and X more" + + errors_to_show = [] + current_length = 0 + + for i, error in enumerate(errors[:max_errors_displayed]): + error_line = f"- {error}\n" + if current_length + len(error_line) > remaining_chars: + break + errors_to_show.append(error) + current_length += len(error_line) + + for error in errors_to_show: + # Truncate individual error messages if they're too long + if len(error) > 150: + error = error[:147] + "..." message += f"- {error}\n" - if len(errors) > max_errors_displayed: - message += f"- ... and {len(errors) - max_errors_displayed} more errors\n" + + if len(errors) > len(errors_to_show): + remaining = len(errors) - len(errors_to_show) + message += f"- ... and {remaining} more errors\n" + + # Final safety check - truncate if still too long + if len(message) > DISCORD_MAX_LENGTH: + message = message[:DISCORD_MAX_LENGTH - 3] + "..." return message \ No newline at end of file diff --git a/apps/discord-bot/utils/roles_config.py b/apps/discord-bot/utils/roles_config.py index 09a9b16d..d6aca818 100644 --- a/apps/discord-bot/utils/roles_config.py +++ b/apps/discord-bot/utils/roles_config.py @@ -18,10 +18,10 @@ from typing import Optional # Roles that are allowed to use all bot commands -ACCEPTABLE_ROLES: list[str] = ["Moderator", "Mentor", "Bot", "Staff (XI), Admin,"] +ACCEPTABLE_ROLES: list[str] = ["Moderator", "Mentor (XI)", "Bot", "Staff (XI), Admin,"] # Roles that can be set as available mentors (can be different from ACCEPTABLE_ROLES) -ACCEPTABLE_MENTOR_ROLES: list[str] = ["Mentor"] +ACCEPTABLE_MENTOR_ROLES: list[str] = ["Mentor (XI)"] # Optional: Map role names to role IDs for faster lookups # If a role ID is None fallback to search by name @@ -29,8 +29,10 @@ ROLE_IDS: dict[str, Optional[str]] = { "Moderator": None, "Mentor": None, + "Mentor (XI)": None, "Available Mentor": None, "Busy Mentor": None, + "Hacker (XI)": None, } # Role names used throughout the bot @@ -38,8 +40,11 @@ class RoleNames: """Centralized role name constants.""" MODERATOR = "Moderator" MENTOR = "Mentor" + MENTOR_XI = "Mentor (XI)" + ADMIN = "Admin" AVAILABLE_MENTOR = "Available Mentor" BUSY_MENTOR = "Busy Mentor" + HACKER_XI = "Hacker (XI)" def get_role_id(role_name: str) -> Optional[str]: diff --git a/apps/web/.env.example b/apps/web/.env.example index 031a8903..32b0bf09 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,4 @@ -VITE_BASE_API_URL=http://localhost:8080 +VITE_BASE_API_URL=https://api.swamphacks.com VITE_DISCORD_OAUTH_CLIENT_ID= diff --git a/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx index 818d2617..69d5b4d3 100644 --- a/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx +++ b/apps/web/src/features/EventOverview/components/AttendeeOverview.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; import { EventAttendanceWithdrawalModal } from "@/features/Event/components/EventAttendanceWithdrawalModal"; import { generateIdentifyIntent } from "@/lib/qr-intents/generate"; -import { DialogTrigger, Heading } from "react-aria-components"; +import { DialogTrigger, Heading, Link } from "react-aria-components"; import QRCode from "react-qr-code"; interface Props { @@ -12,35 +12,164 @@ interface Props { export default function AttendeeOverview({ userId, eventId }: Props) { const identificationIntentString = generateIdentifyIntent(userId); + const hackerGuideUrl = "https://swamphack.notion.site/sh-xi-hacker-guide"; - // Used to get the right colors for QR Code - const styles = getComputedStyle(document.documentElement); - const bg = styles.getPropertyValue("--surface").trim(); - const fg = styles.getPropertyValue("--text-main").trim(); + const allowWithdrawal = false; return ( -

- +
+ Overview - - - - -
-

Can't make it to the event?

- - - - +
+ {/* Left Column: QR ID Card */} +
+ +
+
+
+ + Attendee Pass + +
+ + {/* HIGH CONTRAST QR WRAPPER */} + {/* We force bg-white and text-black here regardless of theme for scan reliability */} +
+ +
+ +
+

+ Personal QR Code +

+

+ ID: {userId} +

+
+
+
+
+
+ + {/* Right Column: Info & Links */} +
+ {/* QR Explanation */} +
+

+ Your Identifier +

+
+

+ This QR code is your unique digital badge. It identifies you to + our staff and is used for check-ins, swag pickups, and meal + redemptions! +

+
+
+ +
+ + {/* Unified Style Resource Links */} +
+ +
+ {/* Consistent Icon Style */} +
+ + + + +
+ +
+

+ Official Hacker Guide + + + + + +

+

+ Have questions? Look here! +

+
+
+ +
+ Open Guide + + + +
+ +
+ + {/* Management / Withdrawal */} + {allowWithdrawal && ( +
+
+
+

+ Plans changed? +

+

+ Withdrawal is permanent for this event. +

+
+ + + + +
+
+ )} +
); diff --git a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx index f53450aa..c44d81db 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableCard.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableCard.tsx @@ -9,6 +9,7 @@ export interface RedeemableCardProps { maxUserAmount: number; totalRedeemed: number; eventId: string; + eventRole: string | undefined; } export function RedeemableCard({ @@ -18,6 +19,7 @@ export function RedeemableCard({ maxUserAmount, totalRedeemed, eventId, + eventRole, }: RedeemableCardProps) { const remaining = totalStock - (totalRedeemed as number); const percentageRemaining = @@ -63,6 +65,7 @@ export function RedeemableCard({ maxUserAmount={maxUserAmount} totalRedeemed={totalRedeemed as number} eventId={eventId} + eventRole={eventRole} /> diff --git a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx index 5986c132..cda1b839 100644 --- a/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx +++ b/apps/web/src/features/Redeemables/components/RedeemableDetailsModal.tsx @@ -11,6 +11,7 @@ import { useRedeemRedeemable, getUserByRFID, useUpdateRedeemable, + useGetCheckedInStatus, } from "../hooks/useRedeemables"; import { DeleteRedeemableModal } from "./DeleteRedeemableModal"; import { Scanner, type IDetectedBarcode } from "@yudiel/react-qr-scanner"; @@ -26,6 +27,7 @@ export interface RedeemableDetailsModalProps { maxUserAmount: number; totalRedeemed: number; eventId: string; + eventRole: string | undefined; } type ModalMode = "details" | "qr" | "edit"; @@ -37,6 +39,7 @@ export function RedeemableDetailsModal({ maxUserAmount, totalRedeemed, eventId, + eventRole, }: RedeemableDetailsModalProps) { const state = useContext(OverlayTriggerStateContext)!; const remaining = totalStock - totalRedeemed; @@ -51,6 +54,8 @@ export function RedeemableDetailsModal({ eventId, id, ); + const { mutateAsync: getCheckedInStatus } = useGetCheckedInStatus(eventId); + const [formData, setFormData] = useState({ name: name, totalStock: totalStock, @@ -120,6 +125,16 @@ export function RedeemableDetailsModal({ setIsScanning(true); try { + // check if they are checked in + const checkedInStatus = await getCheckedInStatus(res.value.user_id); + if (checkedInStatus.checked_in_status != "true") { + showToast({ + title: "Error", + message: "Failed to Redeem with QR. User is not checked in", + type: "error", + }); + return; + } await redeemRedeemable(res.value.user_id); showToast({ @@ -128,7 +143,6 @@ export function RedeemableDetailsModal({ type: "success", }); - // 4. Unlock after your desired delay setTimeout(() => { setIsScanning(false); }, 5000); @@ -335,12 +349,14 @@ export function RedeemableDetailsModal({ > Back to Details - - - - + {eventRole === "admin" && ( + + + + + )}
diff --git a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts index bc2ea870..91fdce41 100644 --- a/apps/web/src/features/Redeemables/hooks/useRedeemables.ts +++ b/apps/web/src/features/Redeemables/hooks/useRedeemables.ts @@ -95,3 +95,18 @@ export function useUpdateRedeemable(eventId: string, redeemableId: string) { }, }); } + +interface CheckInStatusResponse { + checked_in_status: string; +} + +export function useGetCheckedInStatus(eventId: string) { + return useMutation({ + mutationFn: async (userId: string) => { + const response = await api + .get(`events/${eventId}/users/${userId}/checked-in-status`) + .json(); + return response; + }, + }); +} diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx index a31a6b61..11efa382 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_attendee/schedule.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Heading } from "react-aria-components"; +import { Heading, Link } from "react-aria-components"; export const Route = createFileRoute( "/_protected/events/$eventId/dashboard/_attendee/schedule", @@ -8,13 +8,89 @@ export const Route = createFileRoute( }); function RouteComponent() { + const notionUrl = + "https://swamphack.notion.site/29a3b41de22f8061a741c84448a3f5ce?pvs=25#2ee3b41de22f80cbb2cee020b7ad4be3"; + return ( -
- +
+ Event Schedule -

To be announced...

+
+ +
+ {/* Themed Icon Container */} +
+ + + + + + +
+ +
+
+

+ View Full Schedule +

+ + + + + +
+

+ Find the workshop timings, meal breaks, and hacker activities on + our live Notion page. +

+
+
+ + {/* Themed Action Badge */} +
+ Open Notion + + + +
+ +
); } diff --git a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx index eeb679bd..1070852e 100644 --- a/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx +++ b/apps/web/src/routes/_protected/events/$eventId/dashboard/_staff/redeemables.tsx @@ -13,6 +13,7 @@ export const Route = createFileRoute( function RouteComponent() { const { eventId } = Route.useParams(); + const { eventRole } = Route.useRouteContext(); const { data: redeemables, isLoading, isError } = useRedeemables(eventId); if (isLoading) { @@ -69,6 +70,7 @@ function RouteComponent() { : redeemable.total_redeemed } eventId={eventId} + eventRole={eventRole} /> ))}
diff --git a/infra/docker-compose.web.yml b/infra/docker-compose.web.yml index cad2b2bd..9d96907e 100644 --- a/infra/docker-compose.web.yml +++ b/infra/docker-compose.web.yml @@ -16,6 +16,12 @@ services: expose: - "80" # accessible to Caddy via Docker network + discord: + image: ghcr.io/swamphacks/core-discord:latest + restart: always + env_file: + - ./secrets/.env.discord + caddy: image: ghcr.io/caddybuilds/caddy-cloudflare:latest container_name: caddy