diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index fd15a735..58ebbe2f 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -92,9 +92,10 @@ func main() { applicationService := services.NewApplicationService(applicationRepo, userRepo, eventService, emailService, txm, r2Client, &cfg.CoreBuckets, nil, logger) teamService := services.NewTeamService(teamRepo, teamMemberRepo, teamJoinRequestRepo, eventRepo, txm, logger) batService := services.NewBatService(applicationRepo, eventRepo, userRepo, batRunsRepo, emailService, txm, taskQueueClient, nil, logger) + discordService := services.NewDiscordService(eventRepo, logger) // Injections into handlers - apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, cfg, logger) + apiHandlers := handlers.NewHandlers(authService, userService, eventInterestService, eventService, emailService, applicationService, teamService, batService, discordService, cfg, logger) api := api.NewAPI(&logger, apiHandlers, mw) diff --git a/apps/api/internal/api/api.go b/apps/api/internal/api/api.go index 8c3611cf..5fbbd5e2 100644 --- a/apps/api/internal/api/api.go +++ b/apps/api/internal/api/api.go @@ -120,6 +120,12 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Post("/join/{requestId}/reject", api.Handlers.Teams.RejectTeamJoinRequest) }) + // --- Discord routes (for Discord bot) --- + api.Router.Route("/discord", func(r chi.Router) { + r.Use(mw.Auth.RequireAuth) + r.Get("/event/{event_id}/attendees", api.Handlers.Discord.GetEventAttendeesWithDiscord) + }) + // --- Event routes --- api.Router.Route("/events", func(r chi.Router) { // Superuser-only @@ -136,6 +142,7 @@ func (api *API) setupRoutes(mw *mw.Middleware) { r.Get("/", api.Handlers.Event.GetEventByID) r.Get("/role", api.Handlers.Event.GetEventRole) + r.Get("/discord/{discordId}", api.Handlers.Discord.GetUserEventRoleByDiscordIDAndEventId) r.With(ensureEventStaff).Get("/overview", api.Handlers.Event.GetEventOverview) diff --git a/apps/api/internal/api/handlers/discord.go b/apps/api/internal/api/handlers/discord.go new file mode 100644 index 00000000..6a045599 --- /dev/null +++ b/apps/api/internal/api/handlers/discord.go @@ -0,0 +1,111 @@ +package handlers +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/rs/zerolog" + res "github.com/swamphacks/core/apps/api/internal/api/response" + "github.com/swamphacks/core/apps/api/internal/services" +) + +type DiscordHandler struct { + discordService *services.DiscordService + logger zerolog.Logger +} + +func NewDiscordHandler(discordService *services.DiscordService, logger zerolog.Logger) *DiscordHandler { + return &DiscordHandler{ + discordService: discordService, + logger: logger.With().Str("handler", "DiscordHandler").Str("component", "discord").Logger(), + } +} + +// GetEventAttendeesWithDiscord +// +// @Summary Get event attendees with Discord IDs +// @Description Get all attendees for an event who have Discord accounts linked +// @Tags Discord +// @Param event_id path string true "Event ID (UUID)" +// @Success 200 {array} sqlc.GetEventAttendeesWithDiscordRow "List of attendees with Discord IDs" +// @Failure 400 {object} response.ErrorResponse "Invalid event ID" +// @Failure 500 {object} response.ErrorResponse "Internal server error" +// @Router /discord/event/{event_id}/attendees [get] +func (h *DiscordHandler) GetEventAttendeesWithDiscord(w http.ResponseWriter, r *http.Request) { + eventIDStr := chi.URLParam(r, "event_id") + if eventIDStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("bad_request", "event_id is required")) + return + } + + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("bad_request", "invalid event ID format")) + return + } + + attendees, err := h.discordService.GetEventAttendeesWithDiscord(r.Context(), eventID) + if err != nil { + h.logger.Err(err).Msg("failed to get event attendees with discord") + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_error", "Failed to get attendees")) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(attendees); err != nil { + h.logger.Err(err).Msg("failed to encode response") + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_error", "Failed to encode response")) + return + } +} + +// GetUserEventRoleByDiscordIDAndEventId +// +// @Summary Get user event role by Discord ID and Event ID +// @Description Get the event role for a user based on their Discord account ID and a specific event ID +// @Tags Discord +// @Param eventId path string true "Event ID (UUID)" +// @Param discordId path string true "Discord account ID" +// @Success 200 {object} map[string]interface{} "role" +// @Failure 400 {object} response.ErrorResponse "Invalid event ID or discord ID" +// @Failure 404 {object} response.ErrorResponse "User or role not found" +// @Failure 500 {object} response.ErrorResponse "Internal server error" +// @Router /events/{eventId}/discord/{discordId} [get] +func (h *DiscordHandler) GetUserEventRoleByDiscordIDAndEventId(w http.ResponseWriter, r *http.Request) { + eventIDStr := chi.URLParam(r, "eventId") + if eventIDStr == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("bad_request", "eventId is required")) + return + } + + eventID, err := uuid.Parse(eventIDStr) + if err != nil { + res.SendError(w, http.StatusBadRequest, res.NewError("bad_request", "invalid event ID format")) + return + } + + discordID := chi.URLParam(r, "discordId") + if discordID == "" { + res.SendError(w, http.StatusBadRequest, res.NewError("bad_request", "discordId is required")) + return + } + + role, err := h.discordService.GetUserEventRoleByDiscordIDAndEventId(r.Context(), discordID, eventID) + if err != nil { + if err == services.ErrNoEventRole { + res.SendError(w, http.StatusNotFound, res.NewError("not_found", err.Error())) + return + } + h.logger.Err(err).Msg("failed to get user event role") + res.SendError(w, http.StatusInternalServerError, res.NewError("internal_error", "Failed to get user role")) + return + } + + response := map[string]interface{}{ + "role": role, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} \ No newline at end of file diff --git a/apps/api/internal/api/handlers/handlers.go b/apps/api/internal/api/handlers/handlers.go index c9578829..a04e7569 100644 --- a/apps/api/internal/api/handlers/handlers.go +++ b/apps/api/internal/api/handlers/handlers.go @@ -16,6 +16,7 @@ type Handlers struct { Teams *TeamHandler Admission *AdmissionHandler Bat *BatHandler + Discord *DiscordHandler } func NewHandlers( @@ -27,6 +28,7 @@ func NewHandlers( appService *services.ApplicationService, teamService *services.TeamService, batService *services.BatService, + discordService *services.DiscordService, cfg *config.Config, logger zerolog.Logger, ) *Handlers { @@ -40,5 +42,6 @@ func NewHandlers( Teams: NewTeamHandler(teamService, logger), Admission: NewAdmissionHandler(batService, logger), Bat: NewBatHandler(batService, logger), + Discord: NewDiscordHandler(discordService, logger), } } diff --git a/apps/api/internal/db/queries/accounts.sql b/apps/api/internal/db/queries/accounts.sql index 1ce037d1..14cd34bf 100644 --- a/apps/api/internal/db/queries/accounts.sql +++ b/apps/api/internal/db/queries/accounts.sql @@ -31,3 +31,8 @@ WHERE provider_id = $1 AND account_id = $2; -- name: DeleteAccount :exec DELETE FROM auth.accounts WHERE provider_id = $1 AND account_id = $2; + +-- name: GetUserIDByDiscordAccountID :one +SELECT user_id +FROM auth.accounts +WHERE provider_id = 'discord' AND account_id = $1; diff --git a/apps/api/internal/db/queries/event_roles.sql b/apps/api/internal/db/queries/event_roles.sql index 80bc95f3..92dec3de 100644 --- a/apps/api/internal/db/queries/event_roles.sql +++ b/apps/api/internal/db/queries/event_roles.sql @@ -26,6 +26,26 @@ UPDATE event_roles SET role = $3 WHERE event_id = $1 AND user_id = $2; +-- name: GetEventAttendeesWithDiscord :many +SELECT + a.account_id as discord_id, + u.id as user_id, + u.name, + u.email +FROM auth.users u +JOIN event_roles er ON u.id = er.user_id +JOIN auth.accounts a ON u.id = a.user_id +WHERE er.event_id = $1 + AND er.role = 'attendee' + AND a.provider_id = 'discord'; + +-- name: GetEventRoleByDiscordIDAndEventId :one +SELECT er.event_id, er.role +FROM event_roles er +JOIN auth.accounts a ON er.user_id = a.user_id +WHERE a.provider_id = 'discord' + AND a.account_id = $1 + AND er.event_id = $2; -- name: UpdateEventRoleByIds :exec UPDATE event_roles SET diff --git a/apps/api/internal/db/repository/accounts.go b/apps/api/internal/db/repository/accounts.go index 00964ed4..2a5a9fbb 100644 --- a/apps/api/internal/db/repository/accounts.go +++ b/apps/api/internal/db/repository/accounts.go @@ -2,12 +2,18 @@ package repository import ( "context" + "errors" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/swamphacks/core/apps/api/internal/db" "github.com/swamphacks/core/apps/api/internal/db/sqlc" ) +var ( + ErrAccountNotFound = errors.New("account not found") +) + type AccountRepository struct { db *db.DB } @@ -38,3 +44,14 @@ func (r *AccountRepository) GetByProviderAndAccountID(ctx context.Context, param account, err := r.db.Query.GetByProviderAndAccountID(ctx, params) return &account, err } + +func (r *AccountRepository) GetUserIDByDiscordAccountID(ctx context.Context, discordAccountID string) (*uuid.UUID, error) { + userID, err := r.db.Query.GetUserIDByDiscordAccountID(ctx, discordAccountID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAccountNotFound + } + return nil, err + } + return &userID, nil +} \ No newline at end of file diff --git a/apps/api/internal/db/repository/events.go b/apps/api/internal/db/repository/events.go index f759f901..57f99212 100644 --- a/apps/api/internal/db/repository/events.go +++ b/apps/api/internal/db/repository/events.go @@ -165,3 +165,28 @@ func (r *EventRepository) GetApplicationStatuses(ctx context.Context, eventId uu func (r *EventRepository) GetSubmissionTimes(ctx context.Context, eventId uuid.UUID) ([]sqlc.GetSubmissionTimesRow, error) { return r.db.Query.GetSubmissionTimes(ctx, eventId) } + +func (r *EventRepository) GetEventAttendeesWithDiscord(ctx context.Context, eventId uuid.UUID) (*[]sqlc.GetEventAttendeesWithDiscordRow, error) { + attendees, err := r.db.Query.GetEventAttendeesWithDiscord(ctx, eventId) + if err != nil { + return nil, err + } + return &attendees, nil +} + +func (r *EventRepository) GetEventRoleByDiscordIDAndEventId(ctx context.Context, discordID string, eventID uuid.UUID) (*sqlc.GetEventRoleByDiscordIDAndEventIdRow, error) { + params := sqlc.GetEventRoleByDiscordIDAndEventIdParams{ + AccountID: discordID, + EventID: eventID, + } + + eventRole, err := r.db.Query.GetEventRoleByDiscordIDAndEventId(ctx, params) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrEventRoleNotFound + } + return nil, err + } + + return &eventRole, nil +} \ No newline at end of file diff --git a/apps/api/internal/db/sqlc/accounts.sql.go b/apps/api/internal/db/sqlc/accounts.sql.go index 628f20b2..bba86324 100644 --- a/apps/api/internal/db/sqlc/accounts.sql.go +++ b/apps/api/internal/db/sqlc/accounts.sql.go @@ -155,6 +155,19 @@ func (q *Queries) GetByUserID(ctx context.Context, userID uuid.UUID) ([]AuthAcco return items, nil } +const getUserIDByDiscordAccountID = `-- name: GetUserIDByDiscordAccountID :one +SELECT user_id +FROM auth.accounts +WHERE provider_id = 'discord' AND account_id = $1 +` + +func (q *Queries) GetUserIDByDiscordAccountID(ctx context.Context, accountID string) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getUserIDByDiscordAccountID, accountID) + var user_id uuid.UUID + err := row.Scan(&user_id) + return user_id, err +} + const updateTokens = `-- name: UpdateTokens :exec UPDATE auth.accounts SET access_token = $3, diff --git a/apps/api/internal/db/sqlc/event_roles.sql.go b/apps/api/internal/db/sqlc/event_roles.sql.go index bc45986e..9c83ebd2 100644 --- a/apps/api/internal/db/sqlc/event_roles.sql.go +++ b/apps/api/internal/db/sqlc/event_roles.sql.go @@ -42,6 +42,78 @@ func (q *Queries) GetAttendeeCountByEventId(ctx context.Context, eventID uuid.UU return count, err } +const getEventAttendeesWithDiscord = `-- name: GetEventAttendeesWithDiscord :many +SELECT + a.account_id as discord_id, + u.id as user_id, + u.name, + u.email +FROM auth.users u +JOIN event_roles er ON u.id = er.user_id +JOIN auth.accounts a ON u.id = a.user_id +WHERE er.event_id = $1 + AND er.role = 'attendee' + AND a.provider_id = 'discord' +` + +type GetEventAttendeesWithDiscordRow struct { + DiscordID string `json:"discord_id"` + UserID uuid.UUID `json:"user_id"` + Name string `json:"name"` + Email *string `json:"email"` +} + +func (q *Queries) GetEventAttendeesWithDiscord(ctx context.Context, eventID uuid.UUID) ([]GetEventAttendeesWithDiscordRow, error) { + rows, err := q.db.Query(ctx, getEventAttendeesWithDiscord, eventID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetEventAttendeesWithDiscordRow{} + for rows.Next() { + var i GetEventAttendeesWithDiscordRow + if err := rows.Scan( + &i.DiscordID, + &i.UserID, + &i.Name, + &i.Email, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getEventRoleByDiscordIDAndEventId = `-- name: GetEventRoleByDiscordIDAndEventId :one +SELECT er.event_id, er.role +FROM event_roles er +JOIN auth.accounts a ON er.user_id = a.user_id +WHERE a.provider_id = 'discord' + AND a.account_id = $1 + AND er.event_id = $2 +` + +type GetEventRoleByDiscordIDAndEventIdParams struct { + AccountID string `json:"account_id"` + EventID uuid.UUID `json:"event_id"` +} + +type GetEventRoleByDiscordIDAndEventIdRow struct { + EventID uuid.UUID `json:"event_id"` + Role EventRoleType `json:"role"` +} + +func (q *Queries) GetEventRoleByDiscordIDAndEventId(ctx context.Context, arg GetEventRoleByDiscordIDAndEventIdParams) (GetEventRoleByDiscordIDAndEventIdRow, error) { + row := q.db.QueryRow(ctx, getEventRoleByDiscordIDAndEventId, arg.AccountID, arg.EventID) + var i GetEventRoleByDiscordIDAndEventIdRow + err := row.Scan(&i.EventID, &i.Role) + return i, err +} + const getEventStaff = `-- name: GetEventStaff :many SELECT u.id, u.name, u.email, u.email_verified, u.onboarded, u.image, u.created_at, u.updated_at, u.role, u.preferred_email, u.email_consent, er.role AS event_role FROM auth.users u diff --git a/apps/api/internal/services/discord.go b/apps/api/internal/services/discord.go new file mode 100644 index 00000000..70ee70dd --- /dev/null +++ b/apps/api/internal/services/discord.go @@ -0,0 +1,52 @@ +package services + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/swamphacks/core/apps/api/internal/db/repository" + "github.com/swamphacks/core/apps/api/internal/db/sqlc" +) + +var ( + ErrNoEventRole = errors.New("user has no event role") +) + +type DiscordService struct { + eventRepo *repository.EventRepository + logger zerolog.Logger +} + +func NewDiscordService( + eventRepo *repository.EventRepository, + logger zerolog.Logger, +) *DiscordService { + return &DiscordService{ + eventRepo: eventRepo, + logger: logger.With().Str("service", "DiscordService").Str("component", "discord").Logger(), + } +} + +func (s *DiscordService) GetUserEventRoleByDiscordIDAndEventId(ctx context.Context, discordID string, eventID uuid.UUID) (*sqlc.EventRoleType, error) { + eventRole, err := s.eventRepo.GetEventRoleByDiscordIDAndEventId(ctx, discordID, eventID) + if err != nil { + if err == repository.ErrEventRoleNotFound { + return nil, ErrNoEventRole + } + s.logger.Err(err).Msg("failed to get event role by discord ID and event ID") + return nil, err + } + + return &eventRole.Role, nil +} + +func (s *DiscordService) GetEventAttendeesWithDiscord(ctx context.Context, eventID uuid.UUID) (*[]sqlc.GetEventAttendeesWithDiscordRow, error) { + attendees, err := s.eventRepo.GetEventAttendeesWithDiscord(ctx, eventID) + if err != nil { + s.logger.Err(err).Msg("failed to get event attendees with discord") + return nil, err + } + return attendees, nil +} \ No newline at end of file diff --git a/apps/discord-bot/.env.example b/apps/discord-bot/.env.example index 8141df0d..d8410149 100644 --- a/apps/discord-bot/.env.example +++ b/apps/discord-bot/.env.example @@ -1 +1,6 @@ -DISCORD_TOKEN= \ No newline at end of file +DISCORD_TOKEN= +API_KEY= +API_URL= +SESSION_COOKIE= +WEBHOOK_URL= +WEBHOOK_PORT= \ No newline at end of file diff --git a/apps/discord-bot/cogs/general.py b/apps/discord-bot/cogs/general.py index 30d112ac..d2f5e35c 100644 --- a/apps/discord-bot/cogs/general.py +++ b/apps/discord-bot/cogs/general.py @@ -1,10 +1,13 @@ from discord.ext import commands from discord import app_commands import discord -from typing import Literal +import aiohttp +import logging +from typing import Literal, Optional from utils.checks import is_mod_slash 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 class General(commands.Cog): """A cog containing general utility commands for the server @@ -22,7 +25,7 @@ def __init__(self, bot: commands.Bot) -> None: """ self.bot: commands.Bot = bot - def get_role(self, guild: discord.Guild, role_name: str) -> discord.Role: + def get_role(self, guild: discord.Guild, role_name: str) -> Optional[discord.Role]: """Helper to get a role by name from a guild.""" return discord.utils.get(guild.roles, name=role_name) @@ -280,7 +283,166 @@ async def grant_vc_access(self, interaction: discord.Interaction, user: discord. await interaction.response.send_message("I don't have permission to grant access to this voice channel.", ephemeral=True) except Exception as e: await interaction.response.send_message(f"An error occurred: {str(e)}", ephemeral=True) + + @app_commands.command( + name="assign_hacker_roles", + description="Assign hacker role to all attendees from API using webhook" + ) + @app_commands.describe( + event_id="UUID of event", + role="Discord role to assign to attendees" + ) + @is_mod_slash() + async def assign_hacker_roles(self, interaction: discord.Interaction, event_id: str, role: discord.Role) -> None: + """Assign role to all attendees from API using webhook + Args: + interaction: The interaction that triggered this command + event_id: UUID of event + role: Discord role to assign to attendees + """ + + await interaction.response.defer(ephemeral=True) + + try: + guild_id = interaction.guild.id if interaction.guild else None + if not guild_id: + await interaction.followup.send("Error: Could not determine guild.", ephemeral=True) + return + + api_url = os.getenv("API_URL", "http://localhost:8080") + session_cookie = os.getenv("SESSION_COOKIE") + if not session_cookie: + await interaction.followup.send("Error: SESSION_COOKIE is not set.", ephemeral=True) + return + + webhook_url = os.getenv("WEBHOOK_URL") + if not webhook_url: + await interaction.followup.send("Error: WEBHOOK_URL is not set.", ephemeral=True) + return + + attendees = await get_attendees_for_event(api_url, session_cookie, event_id) + if not attendees: + 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) + await interaction.followup.send(summary, ephemeral=True) + + except Exception as e: + await interaction.followup.send(f"An error occurred: {str(e)}", ephemeral=True) + + @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() + async def remove_role_from_all(self, interaction: discord.Interaction, role: discord.Role) -> None: + """Remove a specific role from all members in the server + """ + await interaction.response.defer(ephemeral=True) + try: + guild = interaction.guild + if not guild: + await interaction.followup.send("Error: Could not determine guild.", ephemeral=True) + return + + await interaction.followup.send( + f"Fetching all members and removing **{role.name}** role... This may take a moment.", + ephemeral=True + ) + + members_with_role = [member for member in guild.members if role in member.roles] + + if not members_with_role: + await interaction.followup.send( + f"No members found with the **{role.name}** role.", + ephemeral=True + ) + return + + removed = 0 + failed = 0 + errors = [] + + for member in members_with_role: + try: + await member.remove_roles(role, reason=f'Role removal via command by {interaction.user}') + removed += 1 + except discord.Forbidden: + failed += 1 + errors.append(f"Permission denied for {member.mention}") + except discord.HTTPException as e: + failed += 1 + errors.append(f"Error removing role from {member.mention}: {str(e)}") + except Exception as e: + failed += 1 + errors.append(f"Unexpected error for {member.mention}: {str(e)}") + + message = f"**Role Removal Complete**\n\n" + message += f"**Summary:**\n" + message += f"- Total members with **{role.name}** role: {len(members_with_role)}\n" + message += f"- Roles removed successfully: {removed}\n" + message += f"- Failed removals: {failed}\n" + + if errors: + message += f"\n**Errors ({len(errors)}):**\n" + for error in errors[:10]: + message += f"- {error}\n" + if len(errors) > 10: + message += f"- ... and {len(errors) - 10} more errors\n" + + await interaction.followup.send(message, ephemeral=True) + except Exception as e: + await interaction.followup.send( + f"An error occurred: {str(e)}", + ephemeral=True + ) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member) -> None: + """Automatically assign roles wheen a member joins the server""" + + logger = logging.getLogger(__name__) + guild_id = member.guild.id + if not guild_id: + return + + api_url = os.getenv("API_URL", "http://localhost:8080") + session_cookie = os.getenv("SESSION_COOKIE") + event_id = os.getenv("EVENT_ID") + + if not session_cookie: + logger.error("SESSION_COOKIE is not set") + return + + if not event_id: + logger.error("EVENT_ID is not set") + return + + try: + async with aiohttp.ClientSession() as session: + headers = {"Cookie": f"sh_session_id={session_cookie}"} + async with session.get( + f"{api_url}/events/{event_id}/discord/{member.id}", + headers=headers + ) as response: + if response.status == 200: + data = await response.json() + event_role = data.get("role") + discord_role_name = "Hacker" + if event_role == "attendee": + role = discord.utils.get(member.guild.roles, name=discord_role_name) + if role: + await member.add_roles(role, reason="Auto assigned: User has attendee role") + logger.info(f"Auto assigned {discord_role_name} role to {member.name} ({member.id})") + elif response.status == 404: + pass + else: + logger.error(f"Unexpected response status {response.status} when checking role for {member.name} ({member.id})") + + + except Exception as e: + logger.error(f"Error assigning roles: {str(e)}") async def setup(bot: commands.Bot) -> None: """Add the General cog to the bot diff --git a/apps/discord-bot/cogs/webhook.py b/apps/discord-bot/cogs/webhook.py new file mode 100644 index 00000000..13d03386 --- /dev/null +++ b/apps/discord-bot/cogs/webhook.py @@ -0,0 +1,108 @@ +from discord.ext import commands +import discord +import json +from aiohttp import web +from typing import Optional +import os +import logging + + +class Webhooks(commands.Cog): + """A cog that handles webhooks from the API to assign Discord roles""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the Webhooks cog + + Args: + bot: Discord bot instance + """ + self.bot: commands.Bot = bot + self.webhook_port: int = int(os.getenv('WEBHOOK_PORT', '8081')) + self.webhook_app: Optional[web.Application] = None + self.webhook_runner: Optional[web.AppRunner] = None + self.logger = logging.getLogger(__name__) + + async def setup_webhook_server(self) -> None: + """Set up the webhook HTTP server""" + self.webhook_app = web.Application() + self.webhook_app.router.add_post('/webhook', self.handle_webhook) + + aiohttp_access_logger = logging.getLogger('aiohttp.access') + aiohttp_access_logger.setLevel(logging.WARNING) + + self.webhook_runner = web.AppRunner(self.webhook_app) + await self.webhook_runner.setup() + + site = web.TCPSite(self.webhook_runner, '0.0.0.0', self.webhook_port) + await site.start() + print(f"Webhook server started on port {self.webhook_port}") + + async def handle_webhook(self, request: web.Request) -> web.Response: + """Handle incoming webhook requests + + Args: + request: The incoming HTTP request + + Returns: + HTTP response + """ + try: + data = await request.json() + action = data.get('action') + user_id = data.get('user_id') + role_name = data.get('role_name') + guild_id = data.get('guild_id') + + if action == 'assign_role' and user_id and role_name and guild_id: + # get the guild by id + guild = self.bot.get_guild(int(guild_id)) + if not guild: + return web.Response(text=f'Guild {guild_id} not found', status=404) + + # get the member by discord user id + try: + member = await guild.fetch_member(int(user_id)) + except discord.NotFound: + return web.Response(text=f'User {user_id} not found in server', status=404) + except discord.HTTPException as e: + return web.Response(text=f'Error fetching member: {str(e)}', status=500) + + # get the role by name + role = discord.utils.get(guild.roles, name=role_name) + if not role: + return web.Response(text=f'Role "{role_name}" not found', status=404) + + # check if user already has the role + if role in member.roles: + return web.Response(text='User already has the role', status=200) + + # assign the role + try: + await member.add_roles(role, reason='Role assignment via webhook') + return web.Response(text='Role assigned successfully', status=200) + except discord.Forbidden: + return web.Response(text='Bot lacks permission to assign roles', status=403) + except discord.HTTPException as e: + return web.Response(text=f'Error assigning role: {str(e)}', status=500) + else: + return web.Response(text='Invalid webhook payload', status=400) + + except json.JSONDecodeError: + return web.Response(text='Invalid JSON', status=400) + except Exception as e: + print(f"Error handling webhook: {e}") + return web.Response(text=f'Internal server error: {str(e)}', status=500) + + @commands.Cog.listener() + async def on_ready(self) -> None: + """Start webhook server when bot is ready""" + await self.setup_webhook_server() + + +async def setup(bot: commands.Bot) -> None: + """Setup function for the cog + + Args: + bot: The bot instance + """ + await bot.add_cog(Webhooks(bot)) diff --git a/apps/discord-bot/requirements.txt b/apps/discord-bot/requirements.txt index 95004ae6..3a60fe3a 100644 --- a/apps/discord-bot/requirements.txt +++ b/apps/discord-bot/requirements.txt @@ -1,2 +1,3 @@ discord.py -python-dotenv \ No newline at end of file +python-dotenv +aiohttp \ No newline at end of file diff --git a/apps/discord-bot/utils/role_assignment.py b/apps/discord-bot/utils/role_assignment.py new file mode 100644 index 00000000..afc92e5f --- /dev/null +++ b/apps/discord-bot/utils/role_assignment.py @@ -0,0 +1,188 @@ +import logging +import aiohttp +from typing import List, Tuple, Optional + +logger = logging.getLogger(__name__) + +async def get_attendees_for_event(api_url: str, session_cookie: str, event_id: str) -> List[Tuple[str, str, str, Optional[str]]]: + """Get attendees with Discord IDs for an event from API + + Args: + api_url: Base URL of the API + session_cookie: Session cookie for authentication + event_id: Event ID (UUID) + + Returns: + List of tuples (discord_id, user_id, name, email) + """ + + try: + async with aiohttp.ClientSession() as session: + headers = {"Cookie": f"sh_session_id={session_cookie}"} + async with session.get( + f"{api_url}/discord/event/{event_id}/attendees", + headers=headers + ) as response: + if response.status == 200: + data = await response.json() + attendees = [] + for attendee in data: + attendees.append(( + attendee.get("discord_id", ""), + attendee.get("user_id", ""), + attendee.get("name", ""), + attendee.get("email") + )) + return attendees + elif response.status == 404: + return [] + else: + text = await response.text() + logger.error(f"API error: {response.status} - {text}") + raise Exception(f"API error: {response.status}") + except Exception as e: + logger.error(f"Error fetching attendees from API: {e}") + raise + +async def assign_role_via_webhook( + webhook_url: str, + discord_id: str, + role_name: str, + guild_id: str, + session: Optional[aiohttp.ClientSession] = None +) -> Tuple[str, Optional[str]]: + """Assign a role to a user via webhook + + Args: + webhook_url: URL of webhook to send requests to + discord_id: Discord ID of user to assign role to + role_name: Name of role to assign + guild_id: Discord guild ID + session: Optional aiohttp session to reuse (creates new one if not provided) + + Returns: + Tuple of (status: str, error_message: Optional[str]) + status can be: "newly_assigned", "already_had", or "failed" + """ + + try: + payload = { + "action": "assign_role", + "user_id": str(discord_id), + "role_name": role_name, + "guild_id": str(guild_id) + } + + # Use provided session or create a new one + if session: + async with session.post(webhook_url, json=payload) as response: + if response.status == 200: + text = await response.text() + if "already has the role" in text.lower(): + return ("already_had", None) + elif "assigned successfully" in text.lower(): + return ("newly_assigned", None) + else: + return ("unknown", text) + else: + text = await response.text() + return ("failed", text) + else: + async with aiohttp.ClientSession() as new_session: + async with new_session.post(webhook_url, json=payload) as response: + if response.status == 200: + text = await response.text() + if "already has the role" in text.lower(): + return ("already_had", None) + elif "assigned successfully" in text.lower(): + return ("newly_assigned", None) + else: + return ("unknown", text) + else: + text = await response.text() + return ("failed", text) + except Exception as e: + logger.error(f"Error assigning role via webhook: {e}") + 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 + + 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 + + Returns: + Tuple of (newly_assigned: int, already_had: int, failed: int, errors: List[str]) + """ + + newly_assigned = 0 + already_had = 0 + failed = 0 + errors = [] + + # 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 + + 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()) + + return (newly_assigned, already_had, failed, errors) + +def format_assignment_summary( + total_attendees: int, + newly_assigned: int, + already_had: int, + failed: int, + errors: List[str], + max_errors_displayed: int = 20 +) -> str: + """Format assignment summary for logging + + Args: + total_attendees: Total number of attendees queried + newly_assigned: Number of roles newly assigned + already_had: Number of users who already had the role + failed: Number of failed assignments + errors: List of error messages + max_errors_displayed: Maximum number of errors to show + """ + message = f"**Role Assignment Complete**\n\n" + message += f"**Summary:**\n" + message += f"- Total attendees: {total_attendees}\n" + message += f"- Newly assigned: {newly_assigned}\n" + message += f"- Already had role: {already_had}\n" + message += f"- Failed assignments: {failed}\n" + + synced = newly_assigned + already_had + sync_percentage = (synced / total_attendees * 100) if total_attendees > 0 else 0 + message += f"\n**Sync Status:**\n" + message += f"- Up to date: {synced}/{total_attendees} ({sync_percentage:.1f}%)\n" + + if errors: + message += f"\n**Errors ({len(errors)}):**\n" + for error in errors[:max_errors_displayed]: + message += f"- {error}\n" + if len(errors) > max_errors_displayed: + message += f"- ... and {len(errors) - max_errors_displayed} more errors\n" + + return message \ No newline at end of file