Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5a7b7c0
migrate existing configurable setting to new database schema
TwoLettuce Jul 8, 2026
6424e20
Edit return values of getters for configurable times
TwoLettuce Jul 8, 2026
4e23ed5
open and close for configured ta meeting time
TwoLettuce Jul 8, 2026
db7d45d
Require specific day of the week for meeting
TwoLettuce Jul 8, 2026
83af3ad
Add devotional hours
TwoLettuce Jul 8, 2026
64e7d76
Add configurable Saturday hours
TwoLettuce Jul 8, 2026
8966fad
Make settings viewable
TwoLettuce Jul 10, 2026
c7b9283
Fix bug, ensure that queue bot doesn't miss opening/closing times due…
TwoLettuce Jul 10, 2026
3d49d51
cleanup modal input format
TwoLettuce Jul 10, 2026
609bdcc
Improve code consistency
TwoLettuce Jul 10, 2026
ec0c96a
Ignore newlines in student questions
TwoLettuce Jul 10, 2026
3f0236f
Improve finish helping student error messages
TwoLettuce Jul 10, 2026
80a2d72
remove redundant checks
TwoLettuce Jul 10, 2026
cd703a0
Add list of students currently being helped by TAs to TA status message
TwoLettuce Jul 13, 2026
50a510d
update queue messages as part of finish helping student button
TwoLettuce Jul 13, 2026
13a31e6
Allow helping/finishing multiple students in-person
TwoLettuce Jul 13, 2026
fbde56e
Put multiple online helps into the same breakout
TwoLettuce Jul 13, 2026
a2ba7ac
Helping multiple students requires confirmation
TwoLettuce Jul 13, 2026
6b0b320
Fix moving to in-person vc when already in breakout vc
TwoLettuce Jul 13, 2026
afcc5c8
Merge pull request #47 from softwareconstruction240/34-allow-concurre…
TwoLettuce Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
"""
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
192 changes: 175 additions & 17 deletions src/data_access/config_dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
21 changes: 13 additions & 8 deletions src/data_access/db_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import aiomysql
import warnings
from datetime import datetime
from ui.helpers.constants import Config
from os import getenv

Expand Down Expand Up @@ -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
)
"""
)
Expand Down Expand Up @@ -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)}"))
12 changes: 12 additions & 0 deletions src/data_access/queue_history_dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/ui/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Loading