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!
+