diff --git a/src/bot.py b/src/bot.py index e7867bd..7c86593 100644 --- a/src/bot.py +++ b/src/bot.py @@ -11,7 +11,7 @@ from datetime import datetime, UTC from data_access.db_manager import db_manager from data_access.user_stats_dao import daily_reset -from data_access.queue_history_dao import set_time_finished +from data_access.queue_history_dao import set_time_finished, get_students_not_finished from data_access.config_dao import auto_queue_scheduler from data_access.server_info_dao import get_id @@ -38,7 +38,7 @@ def __init__(self): self.queue_status_message_id: int | None = None self.help_queue_count_message_id: int | None = None self._player_task: Optional[asyncio.Task] = None - self.help_map: map[str, tuple[int, int]] = {} + self.help_map: map[str, list[tuple[int, int]]] = {} async def setup_hook(self): """ @@ -174,8 +174,10 @@ async def _build_ta_status_message(self, guild: discord.Guild) -> str: status = "OPEN" if self.queue.is_open else "CLOSED" queue_text = await self.queue.view() wait_text = await self._get_wait_time(guild) + students_being_helped: str = await self._get_active_students() + currently_helped = f"\n\n**Currently Being Helped:**\n{students_being_helped}" if students_being_helped else "" - return f"**Help Queue Status: {status}{wait_text}**\n{queue_text}" + return f"**Help Queue Status: {status}{wait_text}**\n{queue_text}{currently_helped}" async def _get_ta_status_message(self, guild: discord.Guild) -> discord.Message | None: ta_channel = await self._get_ta_channel(guild) @@ -189,6 +191,7 @@ async def _get_ta_status_message(self, guild: discord.Guild) -> discord.Message self.queue_status_message_id = None async for message in ta_channel.history(limit=50): + message: discord.Message if message.author == self.user and message.content.startswith("**Help Queue Status:"): self.queue_status_message_id = message.id return message @@ -231,6 +234,15 @@ async def update_student_status_message(self, guild: discord.Guild) -> None: await student_status_message.edit(content=await self._build_student_status_message(guild)) + async def _get_active_students(self) -> str | None: + students: list[tuple[str, str, str]] = await get_students_not_finished() + if len(students) == 0: + return None + str_builder = "" + for student in students: + str_builder += f"- {student["TA_name"]} is helping {student["student_discord_name"]} with {student["question"]}\n" + return str_builder + async def _refresh_queue_status_messages(self) -> None: while not self.is_closed(): try: diff --git a/src/data_access/config_dao.py b/src/data_access/config_dao.py index d913f15..587a85a 100644 --- a/src/data_access/config_dao.py +++ b/src/data_access/config_dao.py @@ -13,7 +13,7 @@ from ui.helpers.constants import Channels, Config -async def _get_queue_times() -> tuple[int, int, int, int]: +async def _get_queue_times() -> tuple[datetime, datetime]: """Get the configured queue open and close times. Returns: @@ -23,11 +23,15 @@ async def _get_queue_times() -> tuple[int, int, int, int]: conn: aiomysql.Connection async with conn.cursor(DictCursor) as cursor: cursor: DictCursor - await cursor.execute("SELECT open_hour, open_minute, close_hour, close_minute FROM config WHERE name = %s", (Config.QUEUE_SCHEDULE,)) + await cursor.execute("SELECT value FROM config WHERE name = %s", (Config.QUEUE_SCHEDULE,)) row = await cursor.fetchone() if row: - return int(row["open_hour"]), int(row["open_minute"]), int(row["close_hour"]), int(row["close_minute"]) - return 8, 0, 20, 0 # Default to 8:00am-8:00pm + value: str = row["value"] + open_time, close_time = value.split(",") + open_time = datetime.fromisoformat(open_time) + close_time = datetime.fromisoformat(close_time) + return open_time, close_time + return datetime(2000, 1, 1, hour=8, minute=0), datetime(2000, 1, 1, hour=20, minute=0) # Default to 8:00am-8:00pm async def set_queue_times(open_hour: int, open_minute: int, close_hour: int, close_minute: int) -> None: @@ -39,47 +43,201 @@ async def set_queue_times(open_hour: int, open_minute: int, close_hour: int, clo close_hour: Hour to close (0-23) close_minute: Minute to close (0-59) """ - if not (0 <= open_hour <= 23 and 0 <= open_minute <= 59 and 0 <= close_hour <= 23 and 0 <= close_minute <= 59): - raise ValueError("Hours must be 0-23 and minutes must be 0-59") + open_time = datetime(2000, 1, 1, hour=open_hour, minute=open_minute) + close_time = datetime(2000, 1, 1, hour=close_hour, minute=close_minute) async with db_manager.get_conn() as conn: conn: aiomysql.Connection async with conn.cursor() as cursor: cursor: aiomysql.Cursor await cursor.execute( - "UPDATE config SET open_hour = %s, open_minute = %s, close_hour = %s, close_minute = %s WHERE name = %s", - (open_hour, open_minute, close_hour, close_minute, Config.QUEUE_SCHEDULE) + "UPDATE config SET value = %s WHERE name = %s", + (f"{open_time.isoformat()},{close_time.isoformat()}", Config.QUEUE_SCHEDULE) ) +async def set_ta_meeting(day: str, ta_meeting_hour: int, ta_meeting_minute: int): + ta_meeting_time = datetime(2000, 1, 1, hour=ta_meeting_hour, minute=ta_meeting_minute) + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor() as cursor: + cursor: aiomysql.Cursor + await cursor.execute("UPDATE config SET value = %s WHERE name = %s", (f"{day},{ta_meeting_time.isoformat()}", Config.TA_MEETING)) + +async def _get_ta_meeting() -> tuple[str, datetime]: + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute("SELECT value FROM config WHERE name = %s", (Config.TA_MEETING,)) + row = await cursor.fetchone() + if row: + value: str = row["value"] + day, ta_meeting_time = value.split(",") + ta_meeting_time = datetime.fromisoformat(ta_meeting_time) + return day, ta_meeting_time + + # if no TA meeting has been configured, set the queue closing time to one hour before opening time, effectively causing no effect on queue hours + open_time, _ = await _get_queue_times() + meeting_time = datetime(2000, 1, 1, open_time.hour-1, open_time.minute) + return "MON", meeting_time + +async def set_devotional_hours(day, devotional_hour, devotional_minute): + devotional_time = datetime(2000, 1, 1, hour=devotional_hour, minute=devotional_minute) + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor() as cursor: + cursor: aiomysql.Cursor + await cursor.execute("UPDATE config SET value = %s WHERE name = %s", (f"{day},{devotional_time.isoformat()}", Config.DEVOTIONAL)) + +async def _get_devotional_hours() -> tuple[str, datetime]: + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute("SELECT value FROM config WHERE name = %s", (Config.DEVOTIONAL,)) + row = await cursor.fetchone() + if row: + value: str = row["value"] + day, devotional_time = value.split(",") + devotional_time = datetime.fromisoformat(devotional_time) + return day, devotional_time + + # if no devotional has been configured, set to tuesday at 11am + return "TUE", datetime(2000, 1, 1, hour=11, minute=0) + +async def set_saturday_hours(open_hour: int, open_minute: int, close_hour: int, close_minute: int) -> None: + open_at: datetime = datetime(2000, 1, 1, hour=open_hour, minute = open_minute) + close_at: datetime = datetime(2000, 1, 1, hour=close_hour, minute=close_minute) + + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute( + """ + INSERT INTO config (name, value) + VALUES (%s, %s) AS new + ON DUPLICATE KEY UPDATE value = new.value + """, + (Config.SATURDAY_HOURS, f"{open_at.isoformat()},{close_at.isoformat()}") + ) + + +async def remove_saturday_hours() -> None: + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute("DELETE FROM config WHERE name = %s", (Config.SATURDAY_HOURS,)) + + +async def _get_saturday_hours() -> tuple[datetime, datetime] | None: + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute("SELECT value FROM config WHERE name = %s", (Config.SATURDAY_HOURS,)) + row = await cursor.fetchone() + if row: + value: str = row["value"] + open, close = value.split(",") + open = datetime.fromisoformat(open) + close = datetime.fromisoformat(close) + return open, close + + return None + + +async def get_config_data(): + """ + Fetch all config table rows and return a readable string. + """ + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor() as cursor: + cursor: aiomysql.Cursor + await cursor.execute("SELECT name, value FROM config ORDER BY name") + rows = await cursor.fetchall() + + # Build readable output + lines = [] + for name, raw_value in rows: + raw_value: str + parts = raw_value.split(",") + + formatted_parts = [] + for part in parts: + formatted_parts.append(_try_format_datetime(part)) + + formatted_value = ", ".join(formatted_parts) + lines.append(f"{name} = {formatted_value}") + + return "\n".join(lines) + +def _try_format_datetime(value: str): + """ + Try to parse a datetime string and return HH:MM. + If parsing fails, return the original string. + """ + + try: + dt = datetime.fromisoformat(value) + return dt.strftime("%H:%M") + except ValueError: + return value # Not a datetime + + +def _should_open(current_time: datetime, open_time: datetime, meeting_time: datetime, meeting_day: str, devo_time: datetime, devo_day: str) -> bool: + begin_queue_hours: bool = current_time.hour == open_time.hour and current_time.minute == open_time.minute + finish_ta_meeting: bool = current_time.hour == meeting_time.hour+1 and current_time.minute == meeting_time.minute and current_time.weekday() == day_to_int(meeting_day) + finish_devo: bool = current_time.hour == devo_time.hour+1 and current_time.minute == devo_time.minute and current_time.weekday() == day_to_int(devo_day) + return begin_queue_hours or finish_ta_meeting or finish_devo + +def _should_close(current_time: datetime, close_time: datetime, meeting_time: datetime, meeting_day: str, devo_time: datetime, devo_day: str) -> bool: + finish_queue_hours: bool = current_time.hour == close_time.hour and current_time.minute == close_time.minute + begin_ta_meeting: bool = current_time.hour == meeting_time.hour and current_time.minute == meeting_time.minute and current_time.weekday() == day_to_int(meeting_day) + begin_devo: bool = current_time.hour == devo_time.hour and current_time.minute == devo_time .minute and current_time.weekday() == day_to_int(devo_day) + return finish_queue_hours or begin_ta_meeting or begin_devo + +def day_to_int(day: str) -> int: + days = ("MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN") + return days.index(day) # Queue auto-open/close scheduled tasks -@tasks.loop(minutes=1) +@tasks.loop(minutes=0.9) async def auto_queue_scheduler(bot_client: discord.Client) -> None: """Check if queue should be auto-opened or auto-closed every minute on weekdays only.""" - open_hour, open_minute, close_hour, close_minute = await _get_queue_times() + open_time, close_time = await _get_queue_times() + meeting_day, meeting_time = await _get_ta_meeting() + devo_day, devo_time = await _get_devotional_hours() denver_tz = ZoneInfo("America/Denver") current_time = datetime.now(denver_tz) # Only run on weekdays (Monday-Friday; 5=Saturday, 6=Sunday) - if current_time.weekday() >= 5: + if current_time.weekday() == 6: return + elif current_time.weekday() == 5: + try: + open_time, close_time = await _get_saturday_hours() + except TypeError: + return message: str | None = None # Check if we should open (at the configured open time) - if current_time.hour == open_hour and current_time.minute == open_minute and not bot_client.queue.is_open: + if _should_open(current_time, open_time, meeting_time, meeting_day, devo_time, devo_day) and not bot_client.queue.is_open: bot_client.queue.is_open = True message = f"Queue auto-opened at {current_time.strftime('%H:%M')}" # Check if we should close (at the configured close time) - elif current_time.hour == close_hour and current_time.minute == close_minute and bot_client.queue.is_open: + elif _should_close(current_time, close_time, meeting_time, meeting_day, devo_time, devo_day) and bot_client.queue.is_open: bot_client.queue.is_open = False message = f"Queue auto-closed at {current_time.strftime('%H:%M')}" # Get TA text channel ta_channel = None - for guild in bot_client.guilds: - channel_id = await get_id(Channels.TA_TEXT_CHANNEL_NAME, guild.id) - ta_channel = get(guild.text_channels, id=channel_id) - if message: + if message: + for guild in bot_client.guilds: + channel_id = await get_id(Channels.TA_TEXT_CHANNEL_NAME, guild.id) + ta_channel = get(guild.text_channels, id=channel_id) if ta_channel: await ta_channel.send(message, delete_after=30) else: diff --git a/src/data_access/db_manager.py b/src/data_access/db_manager.py index 6d6c89d..d1a59db 100644 --- a/src/data_access/db_manager.py +++ b/src/data_access/db_manager.py @@ -1,5 +1,6 @@ import aiomysql import warnings +from datetime import datetime from ui.helpers.constants import Config from os import getenv @@ -95,13 +96,11 @@ async def _initialize_database(self): ) await cursor.execute( + # make config table a key-value pair where value is text that is parsed by the accessing object according to the expected format of the data for that particular configuration """ CREATE TABLE IF NOT EXISTS config ( name VARCHAR(100) PRIMARY KEY, - open_hour INT DEFAULT 8, - open_minute INT DEFAULT 0, - close_hour INT DEFAULT 20, - close_minute INT DEFAULT 0 + value TEXT NOT NULL ) """ ) @@ -134,9 +133,15 @@ async def _initialize_database(self): """ ) - # Ensure config has a row for default opening/closing - await cursor.execute("SELECT COUNT(*) FROM config") - if (await cursor.fetchone())[0] == 0: - await cursor.execute("INSERT INTO config (name, open_hour, open_minute, close_hour, close_minute) VALUES (%s, 8, 0, 20, 0)", (Config.QUEUE_SCHEDULE,)) + await cursor.execute("TRUNCATE TABLE config") + await default_configuration(cursor) db_manager = _DBManager() + + +async def default_configuration(cursor: aiomysql.Cursor): + await cursor.execute("SELECT COUNT(*) FROM config") + if (await cursor.fetchone())[0] == 0: + await cursor.execute("INSERT INTO config (name, value) VALUES (%s, %s)", (Config.QUEUE_SCHEDULE, f"{datetime(2000, 1, 1, hour=8, minute=0).isoformat()},{datetime(2000, 1, 1, hour=20, minute=0).isoformat()}")) + await cursor.execute("INSERT INTO config (name, value) VALUES (%s, %s)", (Config.TA_MEETING, f"MON,{datetime(2000, 1, 1, hour = 7, minute = 0).isoformat()}")) + await cursor.execute("INSERT INTO config (name, value) VALUES (%s, %s)", (Config.DEVOTIONAL, f"TUE,{datetime(200, 1, 1, hour=11, minute=0)}")) diff --git a/src/data_access/queue_history_dao.py b/src/data_access/queue_history_dao.py index 0993ac3..5ed3a02 100644 --- a/src/data_access/queue_history_dao.py +++ b/src/data_access/queue_history_dao.py @@ -119,6 +119,18 @@ async def get_queue_history() -> list: cursor: DictCursor await cursor.execute("SELECT * FROM queue_history") return [row for row in await cursor.fetchall()] + + +async def get_students_not_finished() -> list[tuple[str, str, str]]: + """Gets all students without a time_finished in their queue history + Returns: + A list of dictionary tuples with keys 'student_discord_name', 'TA_name', and 'question'""" + async with db_manager.get_conn() as conn: + conn: aiomysql.Connection + async with conn.cursor(DictCursor) as cursor: + cursor: DictCursor + await cursor.execute("SELECT student_discord_name, TA_name, question FROM queue_history WHERE time_finished IS NULL") + return [row for row in await cursor.fetchall()] def _to_denver_time(time: datetime) -> datetime | None: diff --git a/src/ui/helpers/constants.py b/src/ui/helpers/constants.py index e10c7a7..f7b4bd2 100644 --- a/src/ui/helpers/constants.py +++ b/src/ui/helpers/constants.py @@ -34,4 +34,7 @@ class Roles: class Config: QUEUE_SCHEDULE = "daily_queue_hours" + TA_MEETING = "ta_meeting_hours" + DEVOTIONAL = "devotional_hours" + SATURDAY_HOURS = "saturday_hours" \ No newline at end of file diff --git a/src/ui/helpers/discord_helpers.py b/src/ui/helpers/discord_helpers.py index d67d8c1..8dcc998 100644 --- a/src/ui/helpers/discord_helpers.py +++ b/src/ui/helpers/discord_helpers.py @@ -1,18 +1,20 @@ import discord -from discord.utils import get as discord_get +from discord.utils import get as get from typing import Optional from records import QueueEntry +from data_access.server_info_dao import get_id from ui.helpers.constants import Channels, Messages -def get_channel(interaction: discord.Interaction, channel_name: str) -> Optional[discord.abc.GuildChannel]: - return discord_get(interaction.guild.channels, name=channel_name) +async def get_next_available_breakout(interaction: discord.Interaction): + breakout_ids = [] + for name in Channels.BREAKOUT_NAMES: + breakout_ids.append(await get_id(name, interaction.guild.id)) -def get_role(interaction: discord.Interaction, role_name: str) -> Optional[discord.Role]: - return discord_get(interaction.guild.roles, name=role_name) + if (channel := interaction.user.voice.channel) and channel.id in breakout_ids: + return interaction.user.voice.channel -def get_next_available_breakout(interaction: discord.Interaction): for vc in interaction.guild.voice_channels: - if vc.name in Channels.BREAKOUT_NAMES and vc.members == []: + if vc.id in breakout_ids and vc.members == []: return vc return None @@ -23,7 +25,7 @@ def count_tas_in_voice_channel(voice_channel: Optional[discord.VoiceChannel], ta if voice_channel is None: return 0 - ta_role = discord_get(voice_channel.guild.roles, name=ta_role_name) + ta_role = get(voice_channel.guild.roles, name=ta_role_name) if ta_role is None: return 0 @@ -39,7 +41,7 @@ def count_total_tas_in_voice(interaction: Optional[discord.Interaction] = None, return 0 guild = guild or interaction.guild - ta_role = discord_get(guild.roles, name=ta_role_name) + ta_role = get(guild.roles, name=ta_role_name) if ta_role is None: return 0 @@ -72,23 +74,24 @@ async def update_queue_messages(client: discord.Client, guild: discord.Guild) -> async def move_to_breakout(interaction: discord.Interaction, entry: QueueEntry): student: discord.Member = interaction.guild.get_member(entry.user_id) if student is None: - student: discord.User = await interaction.client.fetch_user(entry.user_id) + student = await interaction.client.fetch_user(entry.user_id) ta: discord.Member = interaction.guild.get_member(interaction.user.id) if ta is None: ta: discord.Member = interaction.user if entry.in_person: try: - await ta.move_to(get_channel(interaction, Channels.IN_PERSON_CHANNEL_NAME)) + if ta.voice.channel.id not in [await get_id(breakout_name, interaction.guild.id) for breakout_name in Channels.BREAKOUT_NAMES]: + channel_id = await get_id(Channels.IN_PERSON_CHANNEL_NAME, interaction.guild.id) + in_person_channel = get(interaction.guild.voice_channels, id=channel_id) + await ta.move_to(in_person_channel) except Exception: - await ta.send("Because you weren't in the Online TAs voice channel, you need to join the In Person with Student channel manually. Please do so now.") + await ta.send(f"Because you weren't in the Online TAs voice channel, you need to join the {in_person_channel.mention} channel manually. Please do so now.") else: - breakout_channel: discord.VoiceChannel = get_next_available_breakout(interaction) + breakout_channel: discord.VoiceChannel = await get_next_available_breakout(interaction) if breakout_channel is None: - interaction.response.send_message( + interaction.followup.send( "No breakout rooms available at this time. Tough luck.", - ephemeral=True, - delete_after=Messages.SHORT_TIMEOUT ) try: diff --git a/src/ui/helpers/queue_helpers.py b/src/ui/helpers/queue_helpers.py index 66517a2..ca3ed9c 100644 --- a/src/ui/helpers/queue_helpers.py +++ b/src/ui/helpers/queue_helpers.py @@ -1,4 +1,5 @@ import discord +import re async def already_in_queue(interaction: discord.Interaction)->bool: return await interaction.client.queue.is_in_queue(interaction.user.id) @@ -23,3 +24,6 @@ async def can_join_queue(interaction: discord.Interaction) -> bool: return False return True + +def sanitize_details(details: str): + return re.sub(r"[\r\n]+", " ", details).strip() \ No newline at end of file diff --git a/src/ui/modals.py b/src/ui/modals.py index 3f72f9d..7a936c7 100644 --- a/src/ui/modals.py +++ b/src/ui/modals.py @@ -2,10 +2,11 @@ from discord.utils import get from data_access.user_stats_dao import get_times_helped_today from data_access.bot_incidents_dao import record_bot_issue -from data_access.config_dao import set_queue_times +from data_access.config_dao import set_queue_times, set_ta_meeting, set_devotional_hours, set_saturday_hours from data_access.server_info_dao import get_id -from ui.helpers.discord_helpers import get_channel, get_role, update_queue_messages, notify_next_if_changed -from ui.helpers.constants import Channels, Messages +from ui.helpers.constants import Channels, Messages, Roles +from ui.helpers.discord_helpers import update_queue_messages, notify_next_if_changed +from ui.helpers.queue_helpers import sanitize_details class HelpModal(discord.ui.Modal, title="Request Help"): @@ -54,7 +55,7 @@ async def on_submit(self, interaction: discord.Interaction): await interaction.client.queue_handler( interaction, - self.question.value, + sanitize_details(self.question.value), False, value == "p", student_name @@ -76,7 +77,7 @@ async def on_submit(self, interaction: discord.Interaction): channel_id = await get_id(Channels.TA_TEXT_CHANNEL_NAME, interaction.guild.id) ta_channel: discord.TextChannel = get(interaction.guild.channels, id=channel_id) await ta_channel.send( - f"{interaction.user.display_name} ({student_name}) has joined the help queue - {mode} - {self.question.value} " + f"{interaction.user.display_name} ({student_name}) has joined the help queue - {mode} - {sanitize_details(self.question.value)} " f"(helped {times_helped} time{'s' if times_helped != 1 else ''} today)", delete_after=30 ) @@ -109,7 +110,7 @@ async def on_submit(self, interaction: discord.Interaction): await interaction.client.queue_handler( interaction, - self.phase.value, + sanitize_details(self.phase.value), True, self.in_person.value.lower() == "p", student_name @@ -118,8 +119,10 @@ async def on_submit(self, interaction: discord.Interaction): mode = "In-person" if value == "p" else "Online" pos = await interaction.client.queue.get_position(interaction.user.id) + waiting_room_id = await get_id(Channels.WAITING_ROOM_NAME, interaction.guild.id) + waiting_room = get(interaction.guild.voice_channels, id=waiting_room_id) msg = await interaction.followup.send( - f"You are #{pos} in the queue.{f" Please join the {get_channel(interaction, "Waiting Room").mention} voice channel." if not value=="p" else ""}", + f"You are #{pos} in the queue.{f" Please join the {waiting_room.mention} voice channel." if not value=="p" else ""}", ephemeral=True, wait=True ) @@ -127,7 +130,7 @@ async def on_submit(self, interaction: discord.Interaction): channel_id = await get_id(Channels.TA_TEXT_CHANNEL_NAME, interaction.guild.id) ta_channel: discord.TextChannel = get(interaction.guild.text_channels, id=channel_id) await ta_channel.send( - f"{interaction.user.display_name} ({student_name}) has requested a passoff - {mode} - {self.phase.value}", + f"{interaction.user.display_name} ({student_name}) has requested a passoff - {mode} - {sanitize_details(self.phase.value)}", delete_after=30 ) await msg.delete(delay=60) @@ -216,38 +219,24 @@ async def on_submit(self, interaction: discord.Interaction): class EditQueueHoursModal(discord.ui.Modal, title="Edit Queue Hours"): - open_hour = discord.ui.TextInput( - label="Queue Open Hour (0-23)", - placeholder="8", - min_length=1, - max_length=2 - ) - open_minute = discord.ui.TextInput( - label="Queue Open Minute (0-59)", - placeholder="00", - min_length=1, - max_length=2 + open = discord.ui.TextInput( + label="Weekday Open Time (24-hour time)", + placeholder="08:00", + min_length=3, + max_length=5 ) - close_hour = discord.ui.TextInput( - label="Queue Close Hour (0-23)", - placeholder="20", - min_length=1, - max_length=2 - ) - close_minute = discord.ui.TextInput( - label="Queue Close Minute (0-59)", - placeholder="00", - min_length=1, - max_length=2 + close = discord.ui.TextInput( + label="Weekday Close Time (24-hour time)", + placeholder="20:00", + min_length=3, + max_length=5 ) async def on_submit(self, interaction: discord.Interaction): await interaction.response.defer(thinking=True) try: - open_h = int(self.open_hour.value) - open_m = int(self.open_minute.value) - close_h = int(self.close_hour.value) - close_m = int(self.close_minute.value) + open_h, open_m = [int(time) for time in self.open.value.split(":")] + close_h, close_m = [int(time) for time in self.close.value.split(":")] if not (0 <= open_h <= 23 and 0 <= open_m <= 59 and 0 <= close_h <= 23 and 0 <= close_m <= 59): msg = await interaction.followup.send( @@ -259,15 +248,132 @@ async def on_submit(self, interaction: discord.Interaction): return await set_queue_times(open_h, open_m, close_h, close_m) - ta_role = get_role(interaction, "TA") + ta_role_id = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role = get(interaction.guild.roles, id=ta_role_id) await interaction.followup.send( f"{ta_role.mention} ANNOUNCEMENT: Queue hours updated: Opens at {open_h:02d}:{open_m:02d}, closes at {close_h:02d}:{close_m:02d}", ephemeral=False ) except ValueError: msg = await interaction.followup.send( - "Please enter valid integers for hours and minutes.", + "Input should be in the following format: `##:##`. Please enter valid integers for hours and minutes.", ephemeral=True, wait=True ) await msg.delete(delay=Messages.SHORT_TIMEOUT) + +class EditMeetingHoursModal(discord.ui.Modal, title="Edit TA Meeting Hours"): + day = discord.ui.TextInput(label="Day of Meeting", placeholder="Mon/Tue/Wed/Thu/Fri", min_length=3, max_length=3, required=True) + time = discord.ui.TextInput(label="Meeting Time (24-hour time)", placeholder="12:00", required=True, min_length=3, max_length=5) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer(thinking=True) + try: + day = self.day.value.upper() + hour, minute = [int(value) for value in self.time.value.split(":")] + + if not (0 <= hour <= 23 and 0 <= minute <= 59 and day in ("MON", "TUE", "WED", "THU", "FRI")): + msg = await interaction.followup.send( + "Day must be the first three letters of a weekday. Hour must be 0-23 and minute must be 0-59.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) + return + + await set_ta_meeting(day, hour, minute) + ta_id = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role = get(interaction.guild.roles, id=ta_id) + await interaction.followup.send( + f"{ta_role.mention} ANNOUNCEMENT: TA meeting time updated: {hour:02d}:{minute:02d}", + ephemeral=False + ) + except ValueError: + msg = await interaction.followup.send( + "Input should be in the following format: `##:##`. Please enter valid integers for hours and minutes.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) + + + +class EditDevotionalTimeModal(discord.ui.Modal, title="Edit Devotional Time"): + day = discord.ui.TextInput(label="Day of Devotional", placeholder="Mon/Tue/Wed/Thu/Fri", min_length=3, max_length=3, required=True) + time = discord.ui.TextInput(label="Devotional Time (24-hour time)", placeholder="11:00", required=True, min_length=3, max_length=5) + + async def on_submit(self, interaction: discord.Interaction): + await interaction.response.defer(thinking=True) + try: + day = self.day.value.upper() + hour, minute = [int(value) for value in self.time.value.split(":")] + + if not (0 <= hour <= 23 and 0 <= minute <= 59 and day in ("MON", "TUE", "WED", "THU", "FRI")): + msg = await interaction.followup.send( + "Day must be the first three letters of a weekday. Hour must be 0-23 and minute must be 0-59.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) + return + + await set_devotional_hours(day, hour, minute) + ta_id = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role = get(interaction.guild.roles, id=ta_id) + await interaction.followup.send( + f"{ta_role.mention} ANNOUNCEMENT: Devotional time updated: {hour:02d}:{minute:02d}", + ephemeral=False + ) + except ValueError: + msg = await interaction.followup.send( + "Input should be in the following format: `##:##`. Please enter valid integers for hours and minutes.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) + + + +class EditSaturdayHoursModal(discord.ui.Modal, title="Edit Saturday Hours"): + open = discord.ui.TextInput( + label="Saturday Open Time (24-hour time)", + placeholder="08:00", + min_length=3, + max_length=5 + ) + close = discord.ui.TextInput( + label="Saturday Close Time (24-hour time)", + placeholder="20:00", + min_length=3, + max_length=5 + ) + + async def on_submit(self, interaction: discord.Interaction): + response: discord.InteractionCallbackResponse = await interaction.response.defer(thinking=True, ephemeral=True) + try: + open_h, open_m = [int(time) for time in self.open.value.split(":")] + close_h, close_m = [int(time) for time in self.close.value.split(":")] + + if not (0 <= open_h <= 23 and 0 <= open_m <= 59 and 0 <= open_h <= 23 and 0 <= open_m <= 59): + msg = await interaction.followup.send( + "Hour must be 0-23 and minute must be 0-59.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) + return + + await set_saturday_hours(open_h, open_m, close_h, close_m) + ta_id = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role = get(interaction.guild.roles, id=ta_id) + await response.resource.delete() + await interaction.channel.send( + f"{ta_role.mention} ANNOUNCEMENT: Saturday hours updated: {open_h:02d}:{open_m:02d} to {close_h:02d}:{close_m:02d}" + ) + except ValueError: + msg: discord.WebhookMessage = await interaction.followup.send( + "Input should be in the following format: `##:##`. Please enter valid integers for hours and minutes.", + ephemeral=True, + wait=True + ) + await msg.delete(delay=Messages.SHORT_TIMEOUT) \ No newline at end of file diff --git a/src/ui/views/ta_view.py b/src/ui/views/ta_view.py index 916cc62..b51934e 100644 --- a/src/ui/views/ta_view.py +++ b/src/ui/views/ta_view.py @@ -6,8 +6,9 @@ from data_access.bot_incidents_dao import get_last_incident_info from data_access.user_stats_dao import increment_help, get_student_info from data_access.server_info_dao import get_id +from data_access.config_dao import remove_saturday_hours, get_config_data from records import QueueEntry -from ui.modals import ClearConfirmModal, RemoveConfirmModal, EditQueueHoursModal +from ui.modals import ClearConfirmModal, RemoveConfirmModal, EditQueueHoursModal, EditMeetingHoursModal, EditDevotionalTimeModal, EditSaturdayHoursModal from ui.helpers.constants import Channels, Messages, Roles from ui.helpers.utils import fixed_width from ui.helpers.discord_helpers import move_to_breakout, notify_next_if_changed, update_queue_messages @@ -96,35 +97,38 @@ class TAQueueControls1(discord.ui.ActionRow[discord.ui.LayoutView]): view: "TAView" @discord.ui.button(label="Next Student", style=discord.ButtonStyle.blurple, custom_id="next", emoji="➡️") async def next(self, interaction: discord.Interaction, button): - await help_next_student(interaction) + await confirm_help(interaction) @discord.ui.button(label="Next Student (Online)", style=discord.ButtonStyle.blurple, custom_id="next_online", emoji="💻") async def next_online(self, interaction: discord.Interaction, button: discord.ui.Button): - await help_next_student(interaction, online_only=True, error_msg="No online students in the queue.") + await confirm_help(interaction, online_only=True, error_msg="No online students in the queue.") class TAQueueControls2(discord.ui.ActionRow[discord.ui.LayoutView]): @discord.ui.button(label="Next Passoff", style=discord.ButtonStyle.blurple, custom_id="next_passoff", emoji="✅") async def next_passoff(self, interaction: discord.Interaction, button: discord.ui.Button): - await help_next_student(interaction, passoff_only=True, error_msg="No students awaiting passoff.") + await confirm_help(interaction, passoff_only=True, error_msg="No students awaiting passoff.") @discord.ui.button(label="Next Passoff (Online)", style=discord.ButtonStyle.blurple, custom_id="next_online_passoff", emoji="☑️") async def next_online_passoff(self, interaction: discord.Interaction, button: discord.ui.Button): - await help_next_student(interaction, passoff_only=True, online_only=True, error_msg="No online students awaiting passoff.") + await confirm_help(interaction, passoff_only=True, online_only=True, error_msg="No online students awaiting passoff.") -async def help_next_student(interaction: discord.Interaction, passoff_only: bool = False, online_only: bool = False, error_msg: str = "Queue is empty."): - msg: discord.InteractionCallbackResponse = await interaction.response.defer(thinking=True, ephemeral=True) +async def confirm_help(interaction: discord.Interaction, passoff_only: bool = False, online_only: bool = False, error_msg: str = "Queue is empty."): if (interaction.user.name in interaction.client.help_map.keys()): - msg = await interaction.followup.send( - "You are currently helping a student! Use \"Finish Helping Student\" to be able to help more students!", - ephemeral=True, wait=True + await interaction.response.send_message( + "You are already helping at least one student. Are you sure you want to help another one at the same time?", + view=MultiHelpView(passoff_only, online_only, error_msg), ephemeral=True, delete_after=Messages.DEFAULT_TIMEOUT ) - await msg.delete(delay=Messages.SHORT_TIMEOUT) return + await help_next_student(interaction, passoff_only, online_only, error_msg) + + +async def help_next_student(interaction: discord.Interaction, passoff_only: bool = False, online_only: bool = False, error_msg: str = "Queue is empty."): + msg: discord.InteractionCallbackResponse = await interaction.response.defer(thinking=True, ephemeral=True) front_before = await interaction.client.queue.get_front() entry: Optional[QueueEntry] = await interaction.client.queue.next(passoff_only=passoff_only, online_only=online_only) @@ -141,9 +145,14 @@ async def help_next_student(interaction: discord.Interaction, passoff_only: bool await msg.resource.delete() + async def dequeue_student(interaction: discord.Interaction, front_before: Optional[QueueEntry], entry: QueueEntry): student = await interaction.guild.fetch_member(entry.user_id) - interaction.client.help_map[interaction.user.name] = (await add_queue_history_item(entry, student.display_name, interaction.user.name), entry.user_id) + new_entry = (await add_queue_history_item(entry, student.display_name, interaction.user.name), entry.user_id) + if interaction.user.name in interaction.client.help_map: + interaction.client.help_map[interaction.user.name].append(new_entry) + else: + interaction.client.help_map[interaction.user.name] = [new_entry] await move_to_breakout(interaction, entry) @@ -156,6 +165,33 @@ async def dequeue_student(interaction: discord.Interaction, front_before: Option await notify_next_if_changed(interaction.client, front_before) await update_queue_messages(interaction.client, interaction.guild) + + +class MultiHelpView(discord.ui.View): + def __init__(self, passoff_only: bool, online_only: bool, error_msg: str): + super().__init__(timeout=30) + self.passoff_only = passoff_only + self.online_only = online_only + self.error_msg = error_msg + + @discord.ui.button(label="Continue", style=discord.ButtonStyle.green) + async def continue_button(self, interaction: discord.Interaction, button: discord.ui.Button): + await help_next_student(interaction, self.passoff_only, self.online_only, self.error_msg) + await self.disable(interaction) + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.red) + async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button): + msg: discord.InteractionCallbackResponse = await interaction.response.defer(thinking=True, ephemeral=True) + await self.disable(interaction) + await msg.resource.delete() + + async def disable(self, interaction: discord.Interaction): + for button in self.children: + button: discord.ui.Button + button.disabled = True + await interaction.followup.edit_message(interaction.message.id, content="Done!", view=self) + + class TAQueueControls3(discord.ui.ActionRow[discord.ui.LayoutView]): @discord.ui.button(label="Finish Helping Student", style=discord.ButtonStyle.green, custom_id="finish", emoji="🔚") async def finish_button(self, interaction: discord.Interaction, button): @@ -166,33 +202,30 @@ async def finish_button(self, interaction: discord.Interaction, button): try: ta_voice_state: discord.VoiceState = await interaction.user.fetch_voice() voice_channel: discord.VoiceChannel = ta_voice_state.channel + ta_role_id: int = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role: discord.Role = get(interaction.guild.roles, id=ta_role_id) + for member in voice_channel.members: + if ta_role in member.roles: + continue + else: + await member.move_to(None) + await interaction.user.move_to(online_ta_vc) except discord.NotFound: - msg = await interaction.followup.send("You must be in a voice channel to use this command.", ephemeral=True, wait=True) + msg = await interaction.followup.send(f"Rejoin the {online_ta_vc.mention} channel!", ephemeral=True, wait=True) await msg.delete(delay=Messages.SHORT_TIMEOUT) - return - if voice_channel == online_ta_vc: - msg = await interaction.followup.send("You're not currently helping anyone!", ephemeral=True, wait=True) - await msg.delete(delay=Messages.SHORT_TIMEOUT) - return - - ta_role_id: int = await get_id(Roles.TA_ROLE, interaction.guild.id) - ta_role: discord.Role = get(interaction.guild.roles, id=ta_role_id) - for member in voice_channel.members: - if ta_role in member.roles: - continue - else: - await member.move_to(None) - await interaction.user.move_to(online_ta_vc) - ta_name = interaction.user.name try: - await set_time_finished(interaction.client.help_map.pop(ta_name)[0]) + for entry in interaction.client.help_map[ta_name]: + await set_time_finished(entry[0]) + interaction.client.help_map.pop(ta_name) + except (KeyError, TypeError): msg = await interaction.followup.send("Error: Could not find the student you were helping.", ephemeral=True, wait=True) await msg.delete(delay=Messages.SHORT_TIMEOUT) return await response.resource.delete() + await update_queue_messages(interaction.client, interaction.guild) class TAQueueManagement(discord.ui.ActionRow[discord.ui.LayoutView]): view: "TAView" @@ -274,9 +307,6 @@ def row_to_line(items): builder = f"```Student Info:\n{row_to_line(headers)}\n{divider}\n{body}```" await interaction.followup.send(builder, ephemeral=True) - @discord.ui.button(label="Edit Hours", style=discord.ButtonStyle.secondary, custom_id="edit_hours", emoji="🕐") - async def edit_queue_hours(self, interaction: discord.Interaction, button: discord.ui.Button): - await interaction.response.send_modal(EditQueueHoursModal()) @discord.ui.button(label="See Queue History", style=discord.ButtonStyle.secondary, custom_id="queue_history", emoji="🏛️") async def display_queue_history(self, interaction: discord.Interaction, button: discord.ui.Button): @@ -284,6 +314,50 @@ async def display_queue_history(self, interaction: discord.Interaction, button: csv_file = await get_queue_history_as_csv() await interaction.followup.send(file=csv_file) +class TAConfig(discord.ui.ActionRow[discord.ui.LayoutView]): + view: "TAView" + @discord.ui.button(label="Edit Queue Hours", style=discord.ButtonStyle.secondary, custom_id="edit_hours", emoji="🕐") + async def edit_queue_hours(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(EditQueueHoursModal()) + + @discord.ui.button(label="Edit TA Meeting", style=discord.ButtonStyle.blurple, custom_id="edit_ta_meeting", emoji="🤝") + async def edit_ta_meeting_hours(self, interaction: discord.Interaction, button: discord.ui.Button): + await interaction.response.send_modal(EditMeetingHoursModal()) + + @discord.ui.button(label="Edit Devotional Time", style=discord.ButtonStyle.red, custom_id="edit_devotional", emoji="🙏") + async def edit_devotional_time(self, interaction: discord.Interaction, button): + await interaction.response.send_modal(EditDevotionalTimeModal()) + + @discord.ui.button(label="Saturday Hours", style=discord.ButtonStyle.green, custom_id="saturday_hours", emoji="🗓️") + async def get_saturday_hours_view(self, interaction: discord.Interaction, button): + await interaction.response.send_message(view=SaturdayView(), ephemeral=True, delete_after=Messages.DEFAULT_TIMEOUT) + + @discord.ui.button(label="See Current Settings", style=discord.ButtonStyle.gray, custom_id="see_current_config", emoji="⚙️") + async def see_current_config(self, interaction: discord.Interaction, button): + await interaction.response.defer(thinking=True, ephemeral=True) + current_config = await get_config_data() + msg = await interaction.followup.send(current_config, wait=True) + await msg.delete(delay=Messages.DEFAULT_TIMEOUT) + + +class SaturdayView(discord.ui.View): + def __init__(self): + super().__init__(timeout=30) + + @discord.ui.button(label="Edit Hours", style=discord.ButtonStyle.green) + async def edit_hours(self, interaction: discord.Interaction, button): + await interaction.response.send_modal(EditSaturdayHoursModal()) + + @discord.ui.button(label="Remove Saturday Hours", style=discord.ButtonStyle.danger) + async def saturday_offline(self, interaction: discord.Interaction, button): + await interaction.response.defer(thinking=True) + await remove_saturday_hours() + + ta_id = await get_id(Roles.TA_ROLE, interaction.guild.id) + ta_role = get(interaction.guild.roles, id=ta_id) + await interaction.followup.send( + f"{ta_role.mention} ANNOUNCEMENT: Saturday hours removed! The queue will no longer auto-start on saturdays." + ) class TAView(discord.ui.LayoutView): @@ -308,6 +382,11 @@ def __init__(self): discord.ui.Separator(visible=False, spacing=discord.SeparatorSpacing.large), discord.ui.TextDisplay("### Information/Upkeep"), discord.ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small), - TAQueueInformation() + TAQueueInformation(), + discord.ui.Separator(visible=False, spacing=discord.SeparatorSpacing.large), + discord.ui.TextDisplay("### Configuration"), + discord.ui.Separator(visible=True, spacing=discord.SeparatorSpacing.small), + TAConfig() + ) self.add_item(container)