Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0b902d9
Prepare for external database by making all db functions async
TwoLettuce Jun 29, 2026
bc8d3bb
Create database_manager and modify create_table statements
TwoLettuce Jul 1, 2026
8b15c33
Migrate SQLite Table definitions to MySQL
TwoLettuce Jul 1, 2026
93eb114
Encourage singleton behavior for DBManager, remove references to "src"
TwoLettuce Jul 1, 2026
be9016c
Connect to database on bot startup
TwoLettuce Jul 1, 2026
01f9c6f
create dao files
TwoLettuce Jul 1, 2026
0df846c
Fix _DBManager.connect()
TwoLettuce Jul 1, 2026
f99a6d4
Migrate database operations to daos
TwoLettuce Jul 1, 2026
8dc48d4
Suppress warnings related to IF NOT EXISTS statements
TwoLettuce Jul 1, 2026
e944014
fix aiomysql statement syntax, timezone conflicts
TwoLettuce Jul 6, 2026
baa11c6
Prepare for external database by making all db functions async
TwoLettuce Jun 29, 2026
075a5fe
Create database_manager and modify create_table statements
TwoLettuce Jul 1, 2026
808c441
Migrate SQLite Table definitions to MySQL
TwoLettuce Jul 1, 2026
6a064e2
Encourage singleton behavior for DBManager, remove references to "src"
TwoLettuce Jul 1, 2026
d5b8393
Connect to database on bot startup
TwoLettuce Jul 1, 2026
4a538c9
create dao files
TwoLettuce Jul 1, 2026
244f20e
Fix _DBManager.connect()
TwoLettuce Jul 1, 2026
60dd04e
Migrate database operations to daos
TwoLettuce Jul 1, 2026
c4e3b56
Suppress warnings related to IF NOT EXISTS statements
TwoLettuce Jul 1, 2026
30f6dde
fix aiomysql statement syntax, timezone conflicts
TwoLettuce Jul 6, 2026
b61468c
Merge branch '35-migrate-to-mysql' of https://github.com/softwarecons…
TwoLettuce Jul 6, 2026
45fb231
Fix unaddressed merge conflicts
TwoLettuce Jul 6, 2026
3c9c355
Fix queue hours bug with multiple servers
TwoLettuce Jul 6, 2026
bccab2f
Fix join queue, garbage cleanup
TwoLettuce Jul 6, 2026
7a50700
Change queue messages on close
TwoLettuce Jul 6, 2026
554d10c
Add environment variable support for database connection parameters
TwoLettuce Jul 6, 2026
80f826e
fix queue_view buttons and some modals
TwoLettuce Jul 6, 2026
cf81cbe
make modal submissions resilient
TwoLettuce Jul 7, 2026
d321192
make ta_view buttons resilient
TwoLettuce Jul 7, 2026
85aba27
fix bugs, improve clear queue/remove buttons
TwoLettuce Jul 8, 2026
990adab
make error message for clear queue ephemeral, fix remove student
TwoLettuce Jul 8, 2026
32f4260
Fix requirements and db schema consistency
TwoLettuce Jul 8, 2026
c4d4978
Update README.md .env requirements
TwoLettuce Jul 8, 2026
60a609e
Update requirements.txt
TwoLettuce Jul 8, 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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Create a file named `.env` in the `src/resources` directory with the following c

```txt
TOKEN=your-token-here
USER=your-mysql-username
PASSWORD=your-mysql-password
HOST=localhost (unless you have an external MySQL database somewhere)
PORT=3306
```

#### 5. In OAuth2 > URL Generator, enable scopes:
Expand Down Expand Up @@ -106,4 +110,4 @@ The bot is now ready to use! When you are available, be sure to join the "Online
- To run tests, navigate to the root directory and run the following in the terminal:
```powershell
python -m unittest discover -s tests
```
```
34 changes: 24 additions & 10 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
from ui.views.ta_view import TAView
from ui.helpers.constants import Channels
from ui.helpers.discord_helpers import update_queue_messages, count_total_tas_in_voice
from server_script import setup_server, takedown
from server_script import setup_server
from records import QueueEntry
from datetime import datetime, UTC
from db import daily_reset, auto_queue_scheduler, set_time_finished, server_info_dao
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.config_dao import auto_queue_scheduler
from data_access.server_info_dao import get_id

import os
import random
Expand Down Expand Up @@ -41,6 +45,7 @@ async def setup_hook(self):
Initializes bot UI components (views) and starts background scheduling tasks
before the bot fully connects to Discord.
"""
await db_manager.connect()
self.add_view(QueueView())
self.add_view(TAView())
daily_reset.start()
Expand All @@ -58,8 +63,15 @@ async def on_ready(self):
if len(self.queue.entries) > 0:
self._player_task = asyncio.create_task(self._play_notifications())

async def close(self):
self.queue.is_open = False
for guild in self.guilds:
await update_queue_messages(self, guild)
await db_manager.close()
await super().close()

async def _get_ta_voice_channel(self, guild: discord.Guild) -> discord.VoiceChannel | None:
channel_id = server_info_dao.get_id(Channels.TA_VOICE_CHANNEL_NAME, guild.id)
channel_id = await get_id(Channels.TA_VOICE_CHANNEL_NAME, guild.id)
return get(guild.voice_channels, id=channel_id)


Expand Down Expand Up @@ -108,7 +120,7 @@ async def _play_notifications(self, guild: discord.Guild) -> None:
# wait one minute between plays
await asyncio.sleep(60)
except Exception as e:
print(e.with_traceback())
print("Error!" + e.with_traceback())
await asyncio.sleep(60)

# queue empty, disconnect
Expand All @@ -122,11 +134,11 @@ async def _play_notifications(self, guild: discord.Guild) -> None:
self._player_task = None

async def _get_ta_channel(self, guild: discord.Guild) -> discord.TextChannel | None:
channel_id = server_info_dao.get_id(Channels.TA_TEXT_CHANNEL_NAME, guild.id)
channel_id = await get_id(Channels.TA_TEXT_CHANNEL_NAME, guild.id)
return get(guild.text_channels, id=channel_id)

async def _get_help_channel(self, guild: discord.Guild) -> discord.TextChannel | None:
channel_id = server_info_dao.get_id(Channels.HELP_CHANNEL_NAME, guild.id)
channel_id = await get_id(Channels.HELP_CHANNEL_NAME, guild.id)
return get(guild.text_channels, id=channel_id)

async def _get_wait_time(self, guild: discord.Guild):
Expand All @@ -143,7 +155,7 @@ async def _get_wait_time(self, guild: discord.Guild):

#calculate wait time as if you were to join the queue right now
try:
time = calculate_expected_wait_time(num_tas, queue_size, available_tas, position=queue_size+1)
time = await calculate_expected_wait_time(num_tas, queue_size, available_tas, position=queue_size+1)
minutes = int(time // 60)
seconds = time % 60
return f" — expected wait: {minutes}m {seconds}s"
Expand Down Expand Up @@ -252,7 +264,7 @@ async def _refresh_help_map(self) -> None:
# remove them from the help_map and update the db table
for ta in tas_to_remove:
tableid, _ = self.help_map.pop(ta)
set_time_finished(tableid)
await set_time_finished(tableid)
except asyncio.CancelledError:
break
except Exception as e:
Expand Down Expand Up @@ -295,13 +307,15 @@ async def queue_handler(self, interaction: discord.Interaction, question, is_pas

@bot.tree.command(name="queue")
async def queue_panel(interaction: discord.Interaction):
await interaction.response.send_message(
await interaction.response.defer(thinking=True)
await interaction.followup.send(
view=QueueView()
)

@bot.tree.command(name="ta")
async def ta_panel(interaction: discord.Interaction):
await interaction.response.send_message(
await interaction.response.defer(thinking=True)
await interaction.followup.send(
view=TAView()
)

Expand Down
41 changes: 41 additions & 0 deletions src/data_access/bot_incidents_dao.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import aiomysql
from aiomysql.cursors import DictCursor
from data_access.db_manager import db_manager
from datetime import datetime, timedelta, UTC
from typing import Optional


async def record_bot_issue(username: str, issue: str) -> None:
async with db_manager.get_conn() as conn:
conn: aiomysql.Connection
async with conn.cursor() as cursor:
cursor: aiomysql.Cursor
timestamp = datetime.now(UTC)
await cursor.execute(
"INSERT INTO bot_incidents (incident_timestamp, reported_by, incident) VALUES (%s, %s, %s)",
(timestamp, username, issue)
)


async def get_last_incident_info() -> tuple[Optional[str], Optional[int], Optional[str]]:
"""Get information for most recent bot issue.
Returns:
A tuple of 3 elements containing the user who reported the issue, the number of days since the issue, and the issue description, in that order."""

async with db_manager.get_conn() as conn:
conn: aiomysql.Connection
async with conn.cursor(DictCursor) as cursor:
cursor: DictCursor

await cursor.execute("SELECT reported_by, incident_timestamp, incident FROM bot_incidents ORDER BY incident_timestamp DESC LIMIT 1")
row = await cursor.fetchone()
if not row:
return None, None, None

try:
last_incident_time = row["incident_timestamp"].replace(tzinfo=UTC)
except ValueError:
return row["reported_by"] or None, None, row["incident"] or None

delta: timedelta = datetime.now(UTC) - last_incident_time
return row["reported_by"] or None, delta.days, row["incident"] or None
87 changes: 87 additions & 0 deletions src/data_access/config_dao.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from datetime import datetime
from zoneinfo import ZoneInfo

import aiomysql
from aiomysql import DictCursor
import discord
from discord.utils import get
from discord.ext import tasks

from data_access.db_manager import db_manager
from data_access.server_info_dao import get_id
from ui.helpers.discord_helpers import update_queue_messages
from ui.helpers.constants import Channels, Config


async def _get_queue_times() -> tuple[int, int, int, int]:
"""Get the configured queue open and close times.

Returns:
Tuple of (open_hour, open_minute, close_hour, 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("SELECT open_hour, open_minute, close_hour, close_minute 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


async def set_queue_times(open_hour: int, open_minute: int, close_hour: int, close_minute: int) -> None:
"""Set the queue open and close times.

Args:
open_hour: Hour to open (0-23)
open_minute: Minute to open (0-59)
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")
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)
)


# Queue auto-open/close scheduled tasks
@tasks.loop(minutes=1)
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()
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:
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:
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:
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 ta_channel:
await ta_channel.send(message, delete_after=30)
else:
print(message)
await update_queue_messages(bot_client, guild)
142 changes: 142 additions & 0 deletions src/data_access/db_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import aiomysql
import warnings
from ui.helpers.constants import Config
from os import getenv

warnings.filterwarnings("ignore", message=r".*exists.*")

class _DBManager:
"""Manages connections to the MySQL Database.
Once connect() has been called, use get_conn() to get a connection to the database.
### Fields:
pool: Connection pool used to generate connections.
### Methods:
connect(): initialize pool and database schema
get_conn(): Acquire database connection
"""
def __init__(self):
self.pool = None

async def close(self):
if self.pool:
self.pool.close()
await self.pool.wait_closed()

async def connect(self):
print("Connecting to database at " +getenv("HOST") + ":" + getenv("PORT"))

self.pool: aiomysql.Pool = await aiomysql.create_pool(
user=getenv("USER"),
password=getenv("PASSWORD"),
host=getenv("HOST"),
port=int(getenv("PORT")),
charset="utf8mb4",

minsize=1,
maxsize=1,
autocommit=True
)

async with self.pool.acquire() as conn:
conn: aiomysql.Connection
async with conn.cursor() as cursor:
cursor: aiomysql.Cursor
await cursor.execute("CREATE DATABASE IF NOT EXISTS help_queue")

self.pool.close()
await self.pool.wait_closed()

self.pool: aiomysql.Pool = await aiomysql.create_pool(
user=getenv("USER"),
password=getenv("PASSWORD"),
host=getenv("HOST"),
port=int(getenv("PORT")),
db="help_queue",
charset="utf8mb4",

minsize=1,
maxsize=5,
autocommit=True
)
print("Database connection established successfully")
await self._initialize_database()
print("Database initialized successfully")

def get_conn(self):
return self.pool.acquire()

async def _initialize_database(self):
async with self.pool.acquire() as conn:
conn: aiomysql.Connection

async with conn.cursor() as cursor:
cursor: aiomysql.Cursor
await cursor.execute(
"""
CREATE TABLE IF NOT EXISTS user_stats (
user_id BIGINT PRIMARY KEY,
user_name VARCHAR(100),
student_name VARCHAR(100),
total_help INT DEFAULT 0,
daily_help INT DEFAULT 0
)
"""
)

await cursor.execute(
"""
CREATE TABLE IF NOT EXISTS bot_incidents (
id INT AUTO_INCREMENT PRIMARY KEY,
reported_by VARCHAR(100),
incident_timestamp DATETIME,
incident VARCHAR(500)
)
"""
)

await cursor.execute(
"""
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
)
"""
)

# dequeue_time refers to the time the TA begins helping the student, as the student is no longer waiting in the queue
await cursor.execute(
"""
CREATE TABLE IF NOT EXISTS queue_history (
id INT AUTO_INCREMENT PRIMARY KEY,
student_discord_name VARCHAR(100) NOT NULL,
TA_name VARCHAR(100) NOT NULL,
question VARCHAR(300) NOT NULL,
enqueue_time DATETIME NOT NULL,
dequeue_time DATETIME NOT NULL,
is_passoff BOOLEAN NOT NULL,
in_person BOOLEAN NOT NULL,
time_finished DATETIME
)
"""
)

await cursor.execute(
"""
CREATE TABLE IF NOT EXISTS server_ids (
guild_id BIGINT NOT NULL,
resource_name VARCHAR(100) NOT NULL,
resource_id BIGINT NOT NULL,
PRIMARY KEY(guild_id, resource_name)
)
"""
)

# 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,))

db_manager = _DBManager()
Loading