Move all files into agon module.

This commit is contained in:
2025-03-19 09:07:39 -04:00
parent a46dc0388c
commit 17769022bc
29 changed files with 0 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
"""Cog loader. Adds all command cogs to the bot."""
from nextcord.ext import commands
from cogs.commands.create import CreateCog
from cogs.commands.delete import DeleteCog
from cogs.commands.get import GetCog
from cogs.commands.help import HelpCog
from cogs.commands.refresh import RefreshCog
from cogs.commands.set import SetCog
from cogs.commands.submission import SubmissionCog
from cogs.commands.update import UpdateCog
from cogs.commands.view import ViewCog
# This function will be called when this extension is loaded.
# It is necessary to add these functions to the bot.
def setup(bot: commands.Bot) -> None:
"""Adds all command classes to bot."""
bot.add_cog(CreateCog(bot))
bot.add_cog(DeleteCog())
bot.add_cog(GetCog())
bot.add_cog(HelpCog())
bot.add_cog(RefreshCog(bot))
bot.add_cog(SetCog())
bot.add_cog(SubmissionCog())
bot.add_cog(UpdateCog(bot))
bot.add_cog(ViewCog())
+157
View File
@@ -0,0 +1,157 @@
"""Commands for creating items in bot."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import exc, select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup
from utilities.utils import MAX_LINE_LENGTH, autofill_groups, send_highscore_message
class CreateCog(commands.Cog, name="Create Cog"):
"""Class holds commands for creating things."""
def __init__(self, bot: commands.Bot) -> None:
"""Default constructor for CreateCog class."""
self._bot = bot
@nextcord.slash_command(default_member_permissions=nextcord.Permissions(administrator=True))
async def create(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for creating things. Never called."""
@create.subcommand(description="Create a new Category. Must not exist already in Group.")
async def category( # noqa: PLR0913 need all these arguments because discord command.
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to add Category to.", required=True),
category_name: str = nextcord.SlashOption(description="Name of new Category.", required=True),
is_ascending: bool = nextcord.SlashOption(description="If lower value is a better score.", required=True), # noqa: FBT001
is_time_record: bool = nextcord.SlashOption( # noqa: FBT001
description="If the score should be shown in time format. Cannot be changed.", required=True
),
is_validated: bool = nextcord.SlashOption( # noqa: FBT001
description="If scores need to be validated before posted.", required=True
),
size: int = nextcord.SlashOption(
description="How many scores to post in Category. Default is 3.", required=False, default=3, min_value=1
),
) -> None:
"""
Creates a new category to be placed into group_id group.
Fails if the category name already exists within the group. Then calls send_highscore_message().
Args:
interaction (nextcord.Interaction): Interaction for the command.
group_id (str): Id of group to add category to. Is SlashOption.
category_name (str): Name of new category. Is SlashOption.
is_ascending (bool): If the scores are sorted ascending or not. Is SlashOption.
is_time_record (bool): If the scores are a time record or not. Is SlashOption.
is_validated (bool): If the scores should be validated or not. Is SlashOption.
size (int): How many sores to show in the category message. Is SlashOption.
"""
new_category = Category(
name=category_name,
group_id=int(group_id),
size=size,
is_ascending=is_ascending,
is_time_record=is_time_record,
is_validated=is_validated,
)
# Open connection to engine and add new category.
with Session(engine) as session:
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
group = session.scalar(stmt)
try:
session.add(new_category)
session.commit()
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f'"{category_name}" has been added to "{group.name}"!', ephemeral=True
)
except exc.IntegrityError:
# Category name already exists in group
session.rollback()
await interaction.response.send_message(
f'"{category_name}" already exists in "{group.name}". Please rename.', ephemeral=True
)
@create.subcommand(description="Create a new Category Group. Must not exist already for Guild.")
async def group(
self,
interaction: nextcord.Interaction,
group_name: str = nextcord.SlashOption(description="Name of new Group.", required=True),
pad_length: int = nextcord.SlashOption(
description="Number of characters wide a category is printed. Default is 26.",
required=False,
default=26,
min_value=7,
max_value=MAX_LINE_LENGTH,
),
username_length: int = nextcord.SlashOption(
description="Number of characters of a username to display. Default is 12.",
required=False,
default=12,
min_value=1,
max_value=30,
),
channel: nextcord.TextChannel | None = nextcord.SlashOption(
description="Channel to post Group in. If left empty, defaults to current channel.",
required=False,
default=None,
),
) -> None:
"""
Creates a new category group to be placed into the database with the guild's id.
Fails if the category group name already exists within the guild. Then calls send_highscore_message().
Args:
interaction (nextcord.Interaction): Interaction for the command.
group_name (str): Name of new Group. Is SlashOption.
pad_length (int): Number of characters wide a category is printed. Is SlashOption.
username_length (int): Number of characters of a username to display. Is SlashOption.
channel (Optional[nextcord.abc.GuildChannel]): Channel to post group in. If None, defaults to current
channel. Is SlashOption.
"""
# if no channel provided, use current channel.
if channel is None:
channel_id: int = interaction.channel.id
else:
channel_id: int = channel.id
new_group = CategoryGroup(
name=group_name,
channel_id=channel_id,
guild_id=interaction.guild_id,
pad_length=pad_length,
username_length=username_length,
)
with Session(engine) as session:
try:
session.add(new_group)
session.commit()
await send_highscore_message(session, interaction.guild, new_group)
await interaction.response.send_message(f'"{group_name}" has been created!', ephemeral=True)
except exc.IntegrityError:
# Category Group already exists in guild
session.rollback()
await interaction.response.send_message(
f'"{group_name}" already exists in "{interaction.guild.name}". Please rename.', ephemeral=True
)
@category.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Collects list of group names and compares to input group name for autocomplete assistance.
Args:
interaction (nextcord.Interaction): Interaction with command.
group_id (str): String to run autocomplete compared to.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
+288
View File
@@ -0,0 +1,288 @@
"""Commands for deleting groups/categories/scores in database and messages in discord."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import distinct, select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup, Score
from utilities.utils import (
autofill_categories,
autofill_groups,
autofill_users_in_group_category,
autofill_users_in_guild,
send_highscore_message,
)
from .views.delete_confirm import DeleteConfirmView, create_delete_id
class DeleteCog(commands.Cog, name="Delete Cog"):
"""Class holds commands for deleting."""
def __init__(self) -> None:
"""Default constructor for DeleteCog class."""
@nextcord.slash_command(default_member_permissions=nextcord.Permissions(administrator=True))
async def delete(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for deleting things. Never called."""
@delete.subcommand()
async def all(self, interaction: nextcord.Interaction) -> None:
"""Subcommand parent for deleting all of a thing. Never called."""
@all.subcommand(description="Clears highscores channel of all highscore messages. Does not change data.")
async def channel_messages(
self,
interaction: nextcord.Interaction,
channel: nextcord.TextChannel | None = nextcord.SlashOption(
description="Channel to clear of messages.",
required=False,
default=None,
),
) -> None:
"""
Deletes all group messages from channel. Does not change data.
Does not delete highscores channel id. Uses current channel if not supplied a channel.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
channel (Optional[nextcord.abc.GuildChannel]): Channel to clear of messages.
Is a SlashOption. Limited to text channels. Defaults to None.
"""
if not channel:
if isinstance(interaction.channel, nextcord.TextChannel):
channel = interaction.channel
else:
# This should never happen.
await interaction.response.send_message("Channel is not a text channel somehow.", ephemeral=True)
return
with Session(engine) as session:
stmt = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.channel_id) == channel.id)
)
# TODO test when nothing in channel
groups = session.scalars(stmt)
await interaction.response.defer(ephemeral=True)
for group in groups:
if group.message_id is not None:
try:
message: nextcord.Message = await channel.fetch_message(group.message_id)
await message.delete()
except nextcord.NotFound:
# silently ignore that messages are not found. They got deleted already.
pass
await interaction.send("Highscore messages deleted.", ephemeral=True)
@all.subcommand(
description="Removes all of a user's scores. Warning: data is destroyed, use with care.",
)
async def users(
self,
interaction: nextcord.Interaction,
user_name: str = nextcord.SlashOption(
description="User to remove scores for. Note: This is not always a user's discord name.", required=True
),
) -> None:
"""
Searches all of guild's scores data for any score from the user and removes.
Updates highscore message if user was removed from that Category Group.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
user_name (str): User to remove from highscores
"""
with Session(engine) as session:
stmt = select(distinct(col(Score.category_id))).where(col(Score.username) == user_name)
category_ids: list[int] = list(session.scalars(stmt))
stmt = select(Score).where(col(Score.username) == user_name)
for score in session.scalars(stmt):
session.delete(score)
session.commit()
group_ids = select(col(Category.group_id)).where(col(Category.id).in_(category_ids))
stmt = select(CategoryGroup).where(col(CategoryGroup.id).in_(group_ids))
for group in session.scalars(stmt):
await send_highscore_message(session, interaction.guild, group)
await interaction.send(f'User "{user_name}" was removed from all Category Groups.', ephemeral=True)
@delete.subcommand(description="Delete a category from a group. Deletion is permanent.")
async def category(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to delete from.", required=True),
category_id: str = nextcord.SlashOption(name="category", description="Category to delete.", required=True),
) -> None:
"""
Calls the delete confirmation view for the given group and category.
Args:
interaction (nextcord.Interaction): Interaction of command.
group_id (str): Group to delete from. Is SlashOption.
category_id (str): Category to delete. Is SlashOption.
"""
await interaction.response.send_modal(
DeleteConfirmView(custom_id=create_delete_id(int(group_id), int(category_id)))
)
@delete.subcommand(description="Delete a group. Deletion is permanent.")
async def group(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to delete.", required=True),
) -> None:
"""
Calls the delete confirmation view for the given group.
Args:
interaction (nextcord.Interaction): Interaction of command.
group_id (str): Group to delete from. Is SlashOption.
"""
await interaction.response.send_modal(DeleteConfirmView(custom_id=create_delete_id(int(group_id))))
@delete.subcommand()
async def user(self, interaction: nextcord.Interaction) -> None:
"""Subcommand parent for deleting user information. Never called."""
@user.subcommand(description="Deletes a user's scores from a category.")
async def from_category(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to delete user from.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to delete user from.", required=True
),
user_name: str = nextcord.SlashOption(description="User to delete.", required=True),
) -> None:
"""
Removes a user from a category. Uses autofill functions to fill out group_id, category_id, and user_name.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to remove user from. Is SlashOption.
category_id (str): String representation of Category id to remove user from. Is SlashOption.
user_name (str): The user to remove. Is SlashOption.
"""
with Session(engine) as session:
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
group = session.scalar(stmt)
stmt = select(col(Category.name)).where(col(Category.id) == int(category_id))
category_name = session.scalar(stmt)
stmt = (
select(Score).where(col(Score.category_id) == int(category_id)).where(col(Score.username) == user_name)
)
# there should only be one because category id and username are a composite primary key in score
session.delete(session.scalar(stmt))
session.commit()
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f'User "{user_name}" removed from "{category_name}".', ephemeral=True
)
@user.subcommand(description="Deletes a user's scores from entire group.")
async def from_group(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to delete user from.", required=True),
user_name: str = nextcord.SlashOption(description="User to delete.", required=True),
) -> None:
"""
Removes user from a group. Uses autofill functions to fill out group_id and user_name.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to remove user from. Is SlashOption.
user_name (str): The user to remove. Is SlashOption.
"""
with Session(engine) as session:
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
group = session.scalar(stmt)
category_ids = select(col(Category.id)).where(col(Category.group_id) == int(group_id))
stmt = select(Score).where(col(Score.category_id).in_(category_ids)).where(col(Score.username) == user_name)
for score in session.scalars(stmt):
session.delete(score)
session.commit()
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f'User "{user_name}" removed from all categories in {group.name}.', ephemeral=True
)
@from_category.on_autocomplete("group_id")
@from_group.on_autocomplete("group_id")
@category.on_autocomplete("group_id")
@group.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Autocomplete function for group names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String to be autocompleted with Group name.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
@from_category.on_autocomplete("category_id")
@category.on_autocomplete("category_id")
async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None:
"""
Autocomplete function for category names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
category_id (str): String to be autocompleted with Category name.
group_id (str): Group to find Category from.
"""
await interaction.response.send_autocomplete(
autofill_categories(interaction.guild_id, int(group_id), category_id)
)
@from_category.on_autocomplete("user_name")
@from_group.on_autocomplete("user_name")
async def user_autocomplete(
self, interaction: nextcord.Interaction, user_name: str, group_id: str, category_id: str | None = None
) -> None:
"""
Autocomplete functions for user names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
user_name (str): String to be autocompleted with User name.
group_id (str): String representation of Group id to find category from.
category_id (Optional[str]): String representation of Category id to find User from, can be None.
"""
await interaction.response.send_autocomplete(
autofill_users_in_group_category(
interaction.guild_id, int(group_id), int(category_id) if category_id else 0, user_name
)
)
@users.on_autocomplete("user_name")
async def all_users_autocomplete(self, interaction: nextcord.Interaction, username: str) -> None:
"""
Sends response from autofill_users_in_guild back to update_username for autocompletion of old_username.
Args:
interaction (nextcord.Interaction): Interaction with command.
username (str): String to run autocompletion against.
"""
await interaction.response.send_autocomplete(autofill_users_in_guild(interaction.guild_id, username))
+199
View File
@@ -0,0 +1,199 @@
"""Get command hierarchy."""
import nextcord
from nextcord.ext import commands
from sqlmodel import Session, col, select
from database.db import engine
from database.models import Category, CategoryGroup
from utilities.utils import autofill_categories, autofill_groups
class GetCog(commands.Cog, name="Get command."):
"""Class holds commands for setting variables in guild's database."""
def __init__(self) -> None:
"""Default constructor for SetCog class. Does nothing."""
@nextcord.slash_command(default_member_permissions=nextcord.Permissions(administrator=True))
async def get(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for getting. Never called."""
@get.subcommand(description="Commands for getting category information.")
async def category(self, interaction: nextcord.Interaction) -> None:
"""Parent slash subcommand for getting category information. Never called."""
@category.subcommand(description="Get whether a category is sorted in ascending or descending order.")
async def is_ascending(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to information from.", required=True
),
) -> None:
"""
Command to view how a category is sorted.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id get information from. Is SlashOption.
category_id (str): String representation of Category id to get information from. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Group or Category was not found.", ephemeral=True)
return
await interaction.response.send_message(
f"**{category.name}** is sorted in **{'ascending' if category.is_ascending else 'descending'}** order.",
ephemeral=True,
)
@category.subcommand(description="Get whether a category required validation.")
async def is_validated(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to information from.", required=True
),
) -> None:
"""
Command to view if a category required photo validation.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id get information from. Is SlashOption.
category_id (str): String representation of Category id to get information from. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Group or Category was not found.", ephemeral=True)
return
await interaction.response.send_message(
f"{category.name} does {'not ' if not category.is_validated else ''} require validation.",
ephemeral=True,
)
@category.subcommand(description="Get the size for a category.")
async def size(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(name="category", description="Category to get size of.", required=True),
) -> None:
"""
Command to view a category's size.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id get information from. Is SlashOption.
category_id (str): String representation of Category id to get information from. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Group or Category was not found.", ephemeral=True)
return
await interaction.response.send_message(
f"**{category.name}** has a size of **{category.size}**.", ephemeral=True
)
@get.subcommand(description="Commands for getting group information.")
async def group(self, interaction: nextcord.Interaction) -> None:
"""Parent slash subcommand for getting group information. Never called."""
@group.subcommand(description="Get the pad length of a group.")
async def pad_length(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to get pad length of.", required=True),
) -> None:
"""
Command to view a group's pad length.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id get information from. Is SlashOption.
"""
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
with Session(engine) as session:
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
await interaction.response.send_message(
f"**{group.name}** has a pad length of **{group.pad_length}**.", ephemeral=True
)
@group.subcommand(description="Get the username length of a group.")
async def username_length(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(
name="group", description="Group to get username length of.", required=True
),
) -> None:
"""
Command to view a group's username length.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id get information from. Is SlashOption.
"""
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
with Session(engine) as session:
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
await interaction.response.send_message(
f"**{group.name}** has a username length of **{group.username_length}**.", ephemeral=True
)
@is_ascending.on_autocomplete("category_id")
@is_validated.on_autocomplete("category_id")
@size.on_autocomplete("category_id")
async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None:
"""
Autocomplete function for category names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
category_id (str): String to be autocompleted with Category name.
group_id (str): Group to find Category from.
"""
await interaction.response.send_autocomplete(
autofill_categories(interaction.guild_id, int(group_id), category_id)
)
@is_ascending.on_autocomplete("group_id")
@is_validated.on_autocomplete("group_id")
@pad_length.on_autocomplete("group_id")
@size.on_autocomplete("group_id")
@username_length.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Sends results from autofill_groups back to group_output_channel for autocompletion of group_id.
Args:
interaction (nextcord.Interaction): Interaction with command.
group_id (str): String to run autocompletion against.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
+35
View File
@@ -0,0 +1,35 @@
"""Commands for helping a user."""
import nextcord
from nextcord.ext import commands
class HelpCog(commands.Cog, name="Help commands."):
"""Class holds commands for getting help."""
def __init__(self) -> None:
"""Default constructor for HelpCog class. Does nothing."""
@nextcord.slash_command()
async def help(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for help. Never called."""
@help.subcommand(description="Describes how to get started with Agon.")
async def setup(self, interaction: nextcord.Interaction) -> None:
"""
Sends a message describing how to get started with Agon.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
"""
# I hate this but don't want indents in the string.
help_str: str = "Welcome to Agon! Three simple steps are needed to get started."
help_str += '1.) Set a channel to receive submission verifications with "/set verify_channel."\n'
help_str += "This channel is used to allow moderators to check if a submission is real/valid.\n\n"
help_str += '2.) Set up your highscores! Use "/create group" to first create a group message in the'
help_str += 'channel you would like and then use "/create category" to create a category in that group message!'
help_str += "\n\n"
help_str += '3.) Users can then submit scores with the "/submit" command.'
await interaction.response.send_message(content=help_str)
+89
View File
@@ -0,0 +1,89 @@
"""Commands for refreshing highscore messages."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import CategoryGroup
from utilities.utils import autofill_groups, send_highscore_message
class RefreshCog(commands.Cog, name="Refresh Cog"):
"""Class holds commands for refreshing."""
def __init__(self, bot: commands.Bot) -> None:
"""
Default constructor for RefreshCog class.
Args:
bot (commands.Bot): Bot object. Required to register submission button view.
"""
self._bot = bot
@nextcord.slash_command(default_member_permissions=nextcord.Permissions(administrator=True))
async def refresh(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for refreshing things. Never called."""
@refresh.subcommand(description="Manually refresh a group. Use when changing settings or data manually changed.")
async def group(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Name of Group.", required=True),
) -> None:
"""
Refreshes a group given by user.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): Str representation of Group id to refresh. Is SlashOption.
"""
with Session(engine) as session:
stmt = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.id) == int(group_id))
)
group = session.scalar(stmt)
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(f"Updated group {group.name}!", ephemeral=True)
@refresh.subcommand()
async def all(self, interaction: nextcord.Interaction) -> None:
"""Subcommand parent for refreshing all things. Never called."""
@all.subcommand(description="Triggers post/update of highscores to registered highscore channels.")
async def groups(self, interaction: nextcord.Interaction) -> None:
"""
A command which posts the highscore in the registered channel.
Sends warning if no channel has been registered. Either creates new messages for each group if one doesn't
exist or edits the existing message.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
"""
with Session(engine) as session:
stmt = select(CategoryGroup).where(col(CategoryGroup.guild_id) == interaction.guild_id)
# awaiting response because posting scores takes a while
await interaction.response.defer(ephemeral=True)
for group in session.scalars(stmt):
await send_highscore_message(session, interaction.guild, group)
await interaction.send(f"Posted all groups in {interaction.guild.name}'s highscores.", ephemeral=True)
@group.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Collects list of group names and compares to input group name for autocomplete assistance.
Args:
interaction (nextcord.Interaction): Interaction with command.
group_id (str): String to run autocomplete compared to.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
+442
View File
@@ -0,0 +1,442 @@
"""Set command hierarchy."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup, Guild
from utilities.logger import agon_logger
from utilities.utils import MAX_LINE_LENGTH, autofill_categories, autofill_groups, send_highscore_message
class SetCog(commands.Cog, name="Set command."):
"""Class holds commands for setting variables in guild's database."""
def __init__(self) -> None:
"""Default constructor for SetCog class. Does nothing."""
@nextcord.slash_command(default_member_permissions=nextcord.Permissions(administrator=True))
async def set(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for setting settings. Never called."""
@set.subcommand(description="Commands for editing a category.")
async def category(self, interaction: nextcord.Interaction) -> None:
"""Parent slash subcommand for setting category settings. Never called."""
@category.subcommand(description="Update the number of entries to show for a category.", name="size")
async def category_size(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to update size of.", required=True
),
size: int = nextcord.SlashOption(description="Number of entries to show for the category.", required=True),
) -> None:
"""
Command to update a category's size.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
category_id (str): String representation of Category id to register channel to. Is SlashOption.
size (int): Size to set. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Category or Group was not found.", ephemeral=True)
return
category.size = size
session.commit()
# Use the relationship to get group category is in.
if (group := category.category_group) is None:
agon_logger.warning(f"{category.name}:{category.id} group relationship was None.")
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == group_id)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f"**{category.name}** size has been updated to **{size}**.", ephemeral=True
)
@category.subcommand(description="Update the is_ascending value for a category.")
async def is_ascending(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to update is_ascending of.", required=True
),
ascending: bool = nextcord.SlashOption( # noqa: FBT001
description="Whether the scores are sorted ascending or not.", required=True
),
) -> None:
"""
Command to update a category's is_ascending boolean.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
category_id (str): String representation of Category id to register channel to. Is SlashOption.
ascending (bool): Boolean to set. Determines whether scores are sorted ascending or not. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Category or Group was not found.", ephemeral=True)
return
category.is_ascending = ascending
session.commit()
# Use the relationship to get group category is in.
if (group := category.category_group) is None:
agon_logger.warning(f"{category.name}:{category.id} group relationship was None.")
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == group_id)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f"**{category.name}** is_ascending has been updated to **{ascending}**.", ephemeral=True
)
@category.subcommand(description="Update the is_validated value for a category.")
async def is_validated(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to update is_ascending of.", required=True
),
validated: bool = nextcord.SlashOption( # noqa: FBT001
description="Whether the scores need to be validated.", required=True
),
) -> None:
"""
Command to update a category's is_validated boolean.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
category_id (str): String representation of Category id to register channel to. Is SlashOption.
validated (bool): Boolean to set. Determines whether scores are validated or not. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Category or Group was not found.", ephemeral=True)
return
category.is_validated = validated
session.commit()
await interaction.response.send_message(
f"**{category.name}** is_validated has been updated to **{validated}**.", ephemeral=True
)
@category.subcommand(name="name", description="Change the name of a category.")
async def category_name(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
category_id: str = nextcord.SlashOption(
name="category", description="Category to update the name of.", required=True
),
name: str = nextcord.SlashOption(description="Name to update to.", required=True, min_length=1, max_length=30),
) -> None:
"""
Command to update a category's name.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
category_id (str): String representation of Category id to register channel to. Is SlashOption.
name (str): The name to update the category's name to. Is SlashOption.
"""
stmt = (
select(Category).where(col(Category.group_id) == int(group_id)).where(col(Category.id) == int(category_id))
)
with Session(engine) as session:
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Category or Group was not found.", ephemeral=True)
return
old_name = category.name
category.name = name
session.commit()
# Use the relationship to get group category is in.
if (group := category.category_group) is None:
agon_logger.warning(f"{category.name}:{category.id} group relationship was None.")
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == group_id)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(
f"**{old_name}** has been updated to **{category.name}**.", ephemeral=True
)
@set.subcommand(description="Commands for editing a group.")
async def group(self, interaction: nextcord.Interaction) -> None:
"""Parent slash subcommand for setting group settings. Never called."""
@group.subcommand(name="name", description="Change the name of a Group.")
async def group_name(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group that Category is in.", required=True),
name: str = nextcord.SlashOption(description="Name to update to.", required=True, min_length=1, max_length=50),
) -> None:
"""
Command to update a group's name.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
name (str): The name to update the group's name to. Is SlashOption.
"""
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
with Session(engine) as session:
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
old_name = group.name
group.name = name
# session.add(group) test if this is needed. SQLAlchemy can do the update without adding.
session.commit()
await send_highscore_message(session, interaction.guild, group)
await interaction.response.send_message(f"**{old_name}** has been updated to **{group.name}**.", ephemeral=True)
@group.subcommand(description="Registers the channel for a Group's messages.")
async def output_channel(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to assign to channel.", required=True),
channel: nextcord.abc.GuildChannel | None = nextcord.SlashOption(
description="Channel to register",
required=False,
channel_types=[nextcord.ChannelType.text],
default=None,
),
) -> None:
"""
Command function to register a CategoryGroup's output channel.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to register channel to. Is SlashOption.
channel (nextcord.abc.GuildChannel, optional): Channel to register.
Is a SlashOption. Limited to text channels, Defaults to None.
"""
channel_id = interaction.channel.id if not channel else channel.id
with Session(engine) as session:
stmt = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.id) == int(group_id))
)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group or Category was not found.", ephemeral=True)
return
group.channel_id = channel_id
session.commit()
# TODO(imcompiling): delete the message and call send_highscore_message to send a new one. Bug needs fixed
# first.
# 082
await interaction.send(f"Registered <#{channel_id}>.", ephemeral=True)
@group.subcommand(
description="Set character length for a category. Can cut off username/score. Affects all categories."
)
async def pad_length(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", description="Group to assign pad length to.", required=True),
length: int = nextcord.SlashOption(
description="Number of characters alloted to a category.",
required=True,
default=26,
min_value=7,
max_value=MAX_LINE_LENGTH,
),
) -> None:
"""
Changes guild's category pad length.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
group_id (str): String representation of Group id to set pad length in. Is SlashOption.
length (int): Number of characters. Is SlashOption. Defaults to 26.
"""
with Session(engine) as session:
stmt = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.id) == int(group_id))
)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group was not found.", ephemeral=True)
return
group.pad_length = length
session.commit()
await interaction.response.send_message(
f"Category pad length set to **{length}** for **{group.name}**.", ephemeral=True
)
await send_highscore_message(session, interaction.guild, group)
@group.subcommand(
description="Registers the channel for submission messages. Currently this information is deprecated."
)
async def submission_channel(
self,
interaction: nextcord.Interaction,
channel: nextcord.abc.GuildChannel | None = nextcord.SlashOption(
description="Channel to register",
required=False,
channel_types=[nextcord.ChannelType.text],
default=None,
),
) -> None:
"""
Command function to register the submission message channel.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
channel (nextcord.abc.GuildChannel): Channel to register
Is a SlashOption. Limited to text channels, Defaults to None.
"""
channel_id = interaction.channel.id if not channel else channel.id
with Session(engine) as session:
stmt = select(Guild).where(col(Guild.id) == interaction.guild_id)
if (guild := session.scalar(stmt)) is None:
await interaction.response.send_message("Guild was not found.", ephemeral=True)
return
guild.submission_channel = channel_id
session.commit()
await interaction.send(f"Registered <#{channel_id}>.", ephemeral=True)
@group.subcommand(description="Set username length for a Group of Categories.")
async def username_length(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(
name="group", description="Group to change username length of.", required=True
),
length: int = nextcord.SlashOption(
description="Number of characters.", required=True, default=12, min_value=1, max_value=30
),
) -> None:
"""
Changes guild's username length limit.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
group_id (str): String representation of Group id to set username length in. Is SlashOption.
length (int): Number of characters. Is SlashOption. Defaults to 12.
"""
with Session(engine) as session:
stmt = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.id) == int(group_id))
)
if (group := session.scalar(stmt)) is None:
await interaction.response.send_message("Group or Category was not found.", ephemeral=True)
return
group.username_length = length
session.commit()
await interaction.response.send_message(
f"Username length for **{group.name}** set to **{length}**.", ephemeral=True
)
@set.subcommand(description="Registers the channel for verification messages.")
async def verify_channel(
self,
interaction: nextcord.Interaction,
channel: nextcord.abc.GuildChannel | None = nextcord.SlashOption(
description="Channel to register",
required=False,
channel_types=[nextcord.ChannelType.text],
default=None,
),
) -> None:
"""
Command function to register the verification message channel.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
channel (Optional[nextcord.abc.GuildChannel]): Channel to register
Is a SlashOption. Limited to text channels, Defaults to None.
"""
channel_id = interaction.channel.id if not channel else channel.id
with Session(engine) as session:
stmt = select(Guild).where(col(Guild.id) == interaction.guild_id)
if (guild := session.scalar(stmt)) is None:
await interaction.response.send_message("Guild was not found.", ephemeral=True)
return
guild.verification_channel = channel_id
session.commit()
await interaction.send(f"Registered <#{channel_id}>.", ephemeral=True)
@category_name.on_autocomplete("category_id")
@category_size.on_autocomplete("category_id")
@is_ascending.on_autocomplete("category_id")
@is_validated.on_autocomplete("category_id")
async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None:
"""
Autocomplete function for category names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
category_id (str): String to be autocompleted with Category name.
group_id (str): Group to find Category from.
"""
await interaction.response.send_autocomplete(
autofill_categories(interaction.guild_id, int(group_id), category_id)
)
@category_name.on_autocomplete("group_id")
@category_size.on_autocomplete("group_id")
@group_name.on_autocomplete("group_id")
@is_ascending.on_autocomplete("group_id")
@is_validated.on_autocomplete("group_id")
@output_channel.on_autocomplete("group_id")
@pad_length.on_autocomplete("group_id")
@username_length.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Sends results from autofill_groups back to group_output_channel for autocompletion of group_id.
Args:
interaction (nextcord.Interaction): Interaction with command.
group_id (str): String to run autocompletion against.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
+242
View File
@@ -0,0 +1,242 @@
"""Contains command for submitting a score."""
import re
import nextcord
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from cogs.submission.verification_view import VerificationView
from database.db import engine
from database.models import Category, CategoryGroup, Guild, Submission
from main import agon_logger
from utilities.utils import autofill_categories, autofill_groups, submit_score
class SubmissionCog(commands.Cog):
"""Class holds command for submitting a score."""
def __init__(self) -> None:
return
# TODO(imcompiling): fix too many return statements when reworking command.
# https://gitea.cheezers.org/imcompiling/agon/issues/76
@nextcord.slash_command()
async def submit( # noqa: PLR0911, PLR0913
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(name="group", required=True, description="Group to submit score to."),
category_id: str = nextcord.SlashOption(
name="category", required=True, description="Category to submit score to."
),
score: str = nextcord.SlashOption(required=True, description="Score to submit"),
proof: nextcord.Attachment = nextcord.SlashOption(
required=False, description="Proof for submission. Only needed if score requires verification."
),
username: str = nextcord.SlashOption(
required=False, description="Modified username if submitting under not your discord name."
),
) -> None:
"""
Submit a highscore.
Sets the username if one isn't passed in. Checks if a submission needs verified. Ensures that the score is
following the correct format. Then either creates the verification request or submits score if verification
isn't required.
Args:
interaction (nextcord.Interaction): Interaction of the command.
group_id (str): Is SlashOption. Group that the score category belongs in.
category_id (str): Is SlashOption. Category to submit the score in.
score (str): Is SlashOption. The score.
proof (nextcord.Attachment): Is SlashOption. Picture to provide proof of score.
username (str, optional): Is SlashOption. Username to associate with the score.
"""
# fill out username if not passed in
if username is None:
username = interaction.user.display_name
with Session(engine) as session:
stmt = (
select(Category)
.where(col(Category.group_id) == int(group_id))
.where(col(Category.id) == int(category_id))
)
if (category := session.scalar(stmt)) is None:
await interaction.response.send_message("Category not found.", ephemeral=True)
return
# check if verification is required. if no picture, deny
if category.is_validated and proof is None:
await interaction.response.send_message(
"This category required verification. Please attach a picture and re-submit.", ephemeral=True
)
return
# check with regex if score is in correct format
# check if verify_score was successful. It replies with corrections required itself.
if (
verified_score := await self.verify_score(interaction, score, is_time_record=category.is_time_record)
) is None:
# This response message will be filled out better once command is split up to
# know which type it expects...
await interaction.response.send_message("Format was incorrect.", ephemeral=True)
return
# Create submission verification request or submit score if verification not needed.
if not category.is_validated:
await submit_score(interaction, int(group_id), int(category_id), username, verified_score)
await interaction.response.send_message("Score has been submitted!", ephemeral=True)
return
stmt = select(col(Guild.verification_channel)).where(col(Guild.id) == interaction.guild_id)
if (verification_channel_id := session.scalar(stmt)) is None:
error_response = "No verification channel was registered. Contact server admins."
await interaction.response.send_message(error_response, ephemeral=True)
return
if (channel := interaction.guild.get_channel(verification_channel_id)) is None:
error_response = "Registered Verification channel does not exist. Contact server admins."
await interaction.response.send_message(error_response, ephemeral=True)
return
if not isinstance(channel, nextcord.TextChannel):
await interaction.response.send_message("An error happened with submission.")
agon_logger.error(
"Channel id %s in guild %s is does not correspond to a TextChannel.", channel.id, channel.guild.id
)
return
# Send the verification message to admins
verification_message = await channel.send(
file=await proof.to_file(),
view=VerificationView(),
embeds=[self.create_embed(interaction, group_id, category_id, verified_score, username, session)],
content=f"Submission from <@{interaction.user.id}>.",
)
await interaction.response.send_message(content="Score submitted!", ephemeral=True)
# Save the submission data for later use.
submission: Submission = Submission(
category_id=int(category_id),
group_id=int(group_id),
guild_id=interaction.guild_id,
message_id=verification_message.id,
score=verified_score,
user_id=interaction.user.id,
username=username,
)
session.add(submission)
session.commit()
def create_embed( # noqa: PLR0913
self,
interaction: nextcord.Interaction,
group_id: str,
category_id: str,
score: str,
username: str,
session: Session,
) -> nextcord.Embed:
"""
Creates an embed containing submission data for human verification.
Args:
interaction (nextcord.Interaction): Discord interaction of the command.
group_id (str): Used to get group name.
category_id (str): Used to get category name.
score (str): Score to set.
username (str): Username to set.
session (Session): Open connection to database.
Returns:
nextcord.Embed: Embed containing human readable submission data.
"""
embed = nextcord.Embed(title="Highscores Submission", description="Review fields and accept.")
embed.add_field(name="User Name", value=username, inline=False)
# Get group name from database
stmt = (
select(col(CategoryGroup.name))
.where(col(CategoryGroup.guild_id) == interaction.guild_id)
.where(col(CategoryGroup.id) == group_id)
)
embed.add_field(name="Group", value=session.scalar(stmt), inline=False)
# Get category name from database
stmt = (
select(col(Category.name)).where(col(Category.group_id) == group_id).where(col(Category.id) == category_id)
)
embed.add_field(name="Category", value=session.scalar(stmt), inline=False)
embed.add_field(name="Score", value=score, inline=False)
return embed
async def verify_score(self, interaction: nextcord.Interaction, score: str, *, is_time_record: bool) -> str | None:
"""
Uses regex to verify that scores are in the correct format.
Handles responding to the interaction if the format is incorrect. Also standardizes the time record.
Args:
interaction (nextcord.Interaction): Discord interaction of the command.
score (str): String representation of the score being verified.
is_time_record (bool): Whether the score needs to be verified/formatted as a time record.
Returns:
str | None: String of corrected score. None if formatted incorrectly.
"""
time_regex = re.search("^([0-9]{1,2}:[0-9]|[0-9])?[0-9](\\.[0-9])?$", score)
int_regex = re.search("^[0-9]+$", score)
if time_regex is None and is_time_record:
await interaction.response.send_message(
"Time record must follow format MM:SS.ms, SS.ms, or SS.", ephemeral=True
)
return None
if int_regex is None and not is_time_record:
await interaction.response.send_message("Number record must only be number.", ephemeral=True)
return None
if is_time_record:
# standardize time score format.
if re.search("^[1-9]?[0-9]:[0-9]{2}(\\.[0-9])?$", score):
# score is already fine.
pass
elif re.search("^[0-9]{2}(\\.[0-9])?$", score):
# need to add leading colon and 0
score = "0:" + score
elif re.search("^[0-9](\\.[0-9])?$", score):
# need to add 0 in seconds and leading colon and 0
score = "0:0" + score
elif re.search("^[0]?[0-9]:[0-9]{2}(\\.[0-9])?$", score):
# if there is a minute 10ths place 0, remove
score = score.lstrip("0")
return score
@submit.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Autocomplete function for group names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String to be autocompleted with Group name.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
@submit.on_autocomplete("category_id")
async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None:
"""
Autocomplete function for category names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
category_id (str): String to be autocompleted with Category name.
group_id (str): Group to find Category from.
"""
await interaction.response.send_autocomplete(
autofill_categories(interaction.guild_id, int(group_id), category_id)
)
+81
View File
@@ -0,0 +1,81 @@
"""Commands for updating information/variables held in database."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Guild
from utilities.utils import autofill_users_in_guild
from .views.update_username_verify import UpdateUsernameVerifyView
class UpdateCog(commands.Cog, name="Update commands."):
"""Class holds commands and views needed for updating things in bot."""
def __init__(self, bot: commands.Bot):
"""
Default constructor for Update class. Sets internal bot variable.
Args:
bot (commands.Bot): Bot object. Required to register update username button view.
"""
self._bot: commands.Bot = bot
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Called when cog is loaded. Adds views required for DataManagement commands."""
self._bot.add_view(UpdateUsernameVerifyView())
@nextcord.slash_command()
async def update(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for updating things. Never called."""
@update.subcommand(description="Request a username update. Admins must approve.")
async def username(
self,
interaction: nextcord.Interaction,
old_username: str = nextcord.SlashOption(description="Username currently stored in highscores.", required=True),
new_username: str = nextcord.SlashOption(description="Username to update to.", required=True),
) -> None:
"""
Creates a request to send to admins for changing a username.
Uses UpdateUsernameVerifyView to do the work.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command
old_username (str): Username to replace.
new_username (str): Username to replace old with.
"""
with Session(engine) as session:
stmt = select(col(Guild.verification_channel)).where(col(Guild.id) == interaction.guild_id)
verify_channel = interaction.guild.get_channel(session.scalar(stmt))
if not isinstance(verify_channel, nextcord.TextChannel):
await interaction.send("Verification channel not set. Cannot process request.", ephemeral=True)
return
embed: nextcord.Embed = nextcord.Embed(title="Username change request.")
embed.add_field(name="Old Username", value=old_username)
embed.add_field(name="New Username", value=new_username)
await verify_channel.send(
content=f"Username change request from <@{interaction.user.id}>.",
embed=embed,
view=UpdateUsernameVerifyView(),
)
await interaction.send("Request sent to admins for verification.", ephemeral=True)
@username.on_autocomplete("old_username")
async def all_users_autocomplete(self, interaction: nextcord.Interaction, username: str) -> None:
"""
Sends response from autofill_users_in_guild back to update_username for autocompletion of old_username.
Args:
interaction (nextcord.Interaction): Interaction with command.
username (str): String to run autocompletion against.
"""
await interaction.response.send_autocomplete(autofill_users_in_guild(interaction.guild_id, username))
+112
View File
@@ -0,0 +1,112 @@
"""View command hierarchy."""
import nextcord
import pandas as pd
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup, Score
from utilities.utils import MAX_LINE_LENGTH, autofill_categories, autofill_groups, format_score_string
class ViewCog(commands.Cog, name="View Command"):
"""Class holds commands for viewing data."""
def __init__(self) -> None:
"""Default constructor for ViewCog class. Does nothing."""
@nextcord.slash_command()
async def view(self, interaction: nextcord.Interaction) -> None:
"""Parent slash command for view. Never called."""
@view.subcommand(description="Lists all scores for a given category.")
async def leaderboard(
self,
interaction: nextcord.Interaction,
group_id: str = nextcord.SlashOption(
name="group", description="Group to show leaderboard from.", required=True
),
category_id: str = nextcord.SlashOption(
name="category", description="Category to show leaderboard.", required=True
),
) -> None:
"""
Create message with leaderboard of a group and category.
Gathers all users that have a submission in the group/category combo and returns a
message with them in ranked order.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String representation of Group id to search from. Is SlashOption.
category_id (str): String representation of Category id to show leaderboard. Is SlashOption.
"""
str_list: list[str] = []
# get group, category, and all scores sorted correctly.
with Session(engine) as session:
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == int(group_id))
group = session.scalar(stmt)
stmt = select(Category).where(col(Category.id) == int(category_id))
category = session.scalar(stmt)
d_f = pd.read_sql_query(
sql=session.query(Score).where(col(Score.category_id) == category_id).statement, con=engine
)
scores = d_f.sort_values("Score", ascending=category.is_ascending).reset_index()
# get the longest string
longest_str: int = scores.UserName.map(len).max()
for i, row in scores.iterrows():
str_list.append(
format_score_string(
row["UserName"],
row["Score"],
longest_str,
category.is_time_record,
i + 1, # type: ignore[reportOperatorIssue]
MAX_LINE_LENGTH,
)
)
message_str: str = f"**{group.name} {category.name} Leaderboard!**\n```"
if len(str_list) == 0:
message_str += "No Submissions!"
else:
for line in str_list:
message_str += f"{line}\n"
message_str += "```"
await interaction.response.send_message(content=message_str)
@leaderboard.on_autocomplete("group_id")
async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None:
"""
Autocomplete function for group names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
group_id (str): String to be autocompleted with Group name.
"""
await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id))
@leaderboard.on_autocomplete("category_id")
async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None:
"""
Autocomplete function for category names.
Args:
interaction (nextcord.Interaction): Interaction that invoked the command.
category_id (str): String to be autocompleted with Category name.
group_id (str): Group to find Category from.
"""
await interaction.response.send_autocomplete(
autofill_categories(interaction.guild_id, int(group_id), category_id)
)
+1
View File
@@ -0,0 +1 @@
"""Package for views used in commands."""
@@ -0,0 +1,98 @@
"""Contains view and helper functions for confirming and processing deletes."""
import nextcord
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from database.db import engine
from database.models import Category, CategoryGroup
from utilities.utils import send_highscore_message
class DeleteConfirmView(nextcord.ui.Modal):
"""View modal popup for confirming deletions."""
def __init__(self, custom_id: str):
super().__init__(title="Warning, deletion is permanent.", custom_id=custom_id)
self.checker = nextcord.ui.TextInput(
label='Type "delete" to confirm deletion.',
required=True,
placeholder="delete",
style=nextcord.TextInputStyle.short,
)
self.add_item(self.checker)
async def callback(self, interaction: nextcord.Interaction) -> None:
"""Action for confirming deletion.
Args:
interaction (nextcord.Interaction): User interaction object with the modal.
"""
if self.checker.value.lower() != "delete":
await interaction.response.send_message('Response was not "delete". Canceling delete.')
return
del_vals = parse_delete_id(self.custom_id)
with Session(engine) as session:
stmt = select(CategoryGroup).where(CategoryGroup.id == del_vals["group_id"])
group = session.scalar(stmt)
if del_vals["category_id"] is None:
# delete entire group
session.delete(group)
# delete the message related to group
channel = interaction.guild.get_channel(group.channel_id)
if not isinstance(channel, nextcord.TextChannel):
return
message: nextcord.Message = await channel.fetch_message(group.message_id)
await message.delete()
else:
stmt = (
delete(Category)
.where(Category.group_id == del_vals["group_id"])
.where(Category.id == del_vals["category_id"])
)
session.execute(stmt)
# update the message
await send_highscore_message(session, interaction.guild, group)
session.commit()
await interaction.response.send_message("Deletion complete.", ephemeral=True)
def create_delete_id(group_id: int, category_id: int | None = None) -> str:
"""
Creates a delete id string to tell the view what to delete.
Args:
group_id (int): group id to delete
category_id (Optional[int]): category id to delete. Can be None.
Returns:
str: string representation of the delete id.
"""
return f"delete-{group_id}-{category_id}"
def parse_delete_id(delete_id: str) -> dict:
"""
Returns a dict containing the values of a delete id.
Args:
delete_id (str): the delete id
Returns:
dict: dict containing values from a delete id.
"""
vals = {}
group_id = delete_id.split("-")[1]
category_id = delete_id.split("-")[2]
vals["group_id"] = group_id
if category_id == "None":
vals["category_id"] = None
else:
vals["category_id"] = category_id
return vals
@@ -0,0 +1,71 @@
"""View for allowing a username update."""
import nextcord
from sqlalchemy import distinct, exc, select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup, Score
from utilities.utils import send_highscore_message
class UpdateUsernameVerifyView(nextcord.ui.View):
"""Defines view for allowing a username update."""
def __init__(self) -> None:
"""Default constructor for UpdateUsernameVerifyView. Calls super constructor with no timeout."""
super().__init__(timeout=None)
@nextcord.ui.button(label="Deny", style=nextcord.ButtonStyle.red, custom_id="deny-username-update")
async def deny_button(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: # noqa: ARG002
"""
Button for denying username update. Deletes message.
Args:
button (nextcord.ui.Button): unused
interaction (nextcord.Interaction): Interaction with the button
"""
await interaction.message.delete()
@nextcord.ui.button(label="Approve", style=nextcord.ButtonStyle.green, custom_id="approve-username-update")
async def approve_button(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: # noqa: ARG002
"""
Button for approving username update.
Tells guild data to update username, then updates message to show it is complete.
Args:
button (nextcord.ui.Button): unused
interaction (nextcord.Interaction): Interaction with the button
"""
with Session(engine) as session:
try:
embed_content = interaction.message.embeds[0].to_dict()
old_username: str = embed_content["fields"][0]["value"] # type: ignore reportTypedDictNotRequiredAccess
new_username: str = embed_content["fields"][1]["value"] # type: ignore reportTypedDictNotRequiredAccess
stmt = select(distinct(col(Score.category_id))).where(col(Score.username) == old_username)
category_ids: list[int] = list(session.scalars(stmt))
stmt = select(Score).where(col(Score.username) == old_username)
for score in session.scalars(stmt):
score.UserName = new_username
session.commit()
group_ids = select(col(Category.group_id)).where(col(Category.id).in_(category_ids))
stmt = select(CategoryGroup).where(col(CategoryGroup.id).in_(group_ids))
for group in session.scalars(stmt):
await send_highscore_message(session, interaction.guild, group)
content = interaction.message.content
content += f"\nApproved by <@{interaction.user.id}>."
await interaction.message.edit(content=content, embeds=interaction.message.embeds, view=None)
except exc.DataError:
session.rollback()
content = interaction.message.content
content += "\nError, UserName is too long."
await interaction.message.edit(content=content, embeds=interaction.message.embeds, view=None)
await interaction.response.send_message(content="User must resubmit with shorter name.", ephemeral=True)
+69
View File
@@ -0,0 +1,69 @@
"""Defines cog for guild listeners. Contains functions for various guild events."""
import nextcord
from nextcord.ext import commands
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Guild
class GuildListener(commands.Cog, name="Guild Listener"):
"""Sets up message listener."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_guild_join(self, guild: nextcord.Guild) -> None:
"""
Listener for Guild Join. Adds guild to database.
Args:
guild (nextcord.Guild): Guild that was joined.
"""
with Session(engine) as session:
stmt = select(Guild).where(col(Guild.id == guild.id))
# just in case check if the guild already exists
if (guild_data := session.scalar(stmt)) is None:
new_guild = Guild(id=guild.id, name=guild.name)
session.add(new_guild)
else:
guild_data.GuildName = guild.name
guild_data.Active = True
session.commit()
# let imcompiling#2272 know about a new server joining!
# staying with fetch due to intents not being set up correctly. Maybe change to get eventually.
imcompiling: nextcord.User = await self.bot.fetch_user(206602713511886849)
await imcompiling.send(f'Agon has joined server "{guild.name}"!')
@commands.Cog.listener()
async def on_guild_remove(self, guild: nextcord.Guild) -> None:
"""
Listens to when the bot has been removed from a guild. Update's the database to record the active flag as false.
Args:
guild (nextcord.Guild): Guild that was left.
"""
with Session(engine) as session:
stmt = select(Guild).where(col(Guild.id == guild.id))
guild_data = session.scalar(stmt)
guild_data.Active = False
session.commit()
# let imcompiling#2272 know about leaving a server.
# staying with fetch due to intents not being set up correctly. Maybe change to get eventually.
imcompiling: nextcord.User = await self.bot.fetch_user(206602713511886849)
await imcompiling.send(f'Agon has been removed from "{guild.name}".')
# This function will be called when this extension is loaded.
# It is necessary to add these functions to the bot.
def setup(bot: commands.Bot) -> None:
"""Adds function to bot."""
bot.add_cog(GuildListener(bot))
+14
View File
@@ -0,0 +1,14 @@
"""Initializing code for all loops."""
from nextcord.ext import commands
from .bot_presence import BotPresence
from .guild_data import GuildData
# This function will be called when this extension is loaded.
# It is necessary to add these functions to the bot.
def setup(bot: commands.Bot) -> None:
"""Adds function to bot."""
bot.add_cog(BotPresence(bot))
bot.add_cog(GuildData(bot))
+45
View File
@@ -0,0 +1,45 @@
"""Functions for controlling the bot's presence. Also contains the loop."""
import random
import nextcord
from nextcord.ext import commands, tasks
class BotPresence(commands.Cog, name="Bot Presence"):
"""Class for bot presence functions."""
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@commands.Cog.listener()
async def on_guild_join(self, guild: nextcord.Guild) -> None: # noqa: ARG002
"""Listener for Guild Join. Updated presence on guild join."""
await self.change_presence()
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Starts the loop when bot is ready."""
await self.bot.wait_until_ready()
self.periodic_change_presence.start()
@tasks.loop(minutes=2)
async def periodic_change_presence(self) -> None:
"""Loops every 2 minutes and changes presence."""
await self.change_presence()
async def change_presence(self) -> None:
"""Randomly chooses a presence and updates bot's presence."""
statuses = [
"Now with slash commands!",
"Vuln Pls.",
"All your base are belong to us.",
"Double plus good.",
"That's a lot of damage!",
]
status = random.choice(statuses) # noqa: S311
status += f" | {len(self.bot.guilds)} servers"
await self.bot.change_presence(activity=nextcord.Game(status))
+44
View File
@@ -0,0 +1,44 @@
"""Contains a loop that updates guild data in the database periodically."""
from nextcord.ext import commands, tasks
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Guild
class GuildData(commands.Cog, name="Guild Data"):
"""Class to contain loop."""
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Starts the loop when bot is ready."""
await self.bot.wait_until_ready()
await self.periodic_update_guild_data.start()
@tasks.loop(hours=24)
async def periodic_update_guild_data(self) -> None:
"""Loops every day, calls update_guild_data."""
self.update_guild_data()
def update_guild_data(self) -> None:
"""Updates all known guild's data in the database."""
with Session(engine) as session:
for guild in self.bot.guilds:
# update given guild name for guild id
stmt = select(Guild).where(col(Guild.id) == guild.id)
guild_data = session.scalar(stmt)
# just in case checking if the guild doesn't exist.
if guild_data is None:
new_guild = Guild(id=guild.id, name=guild.name)
session.add(new_guild)
else:
guild_data.name = guild.name
session.commit()
+26
View File
@@ -0,0 +1,26 @@
"""Cog for creating submission process."""
from nextcord.ext import commands
from .submission_views import SubmissionCreateButton
from .verification_view import VerificationView
class SubmissionCog(commands.Cog, name="Submission Cog"):
"""Constructs submission method."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_ready(self) -> None:
"""Called when cog is loaded."""
self.bot.add_view(SubmissionCreateButton(self.bot))
self.bot.add_view(VerificationView())
# This function will be called when this extension is loaded.
# It is necessary to add these functions to the bot.
def setup(bot: commands.Bot) -> None:
"""Adds function to bot."""
bot.add_cog(SubmissionCog(bot))
+23
View File
@@ -0,0 +1,23 @@
"""Views used for submission process."""
import nextcord
from nextcord.ext import commands
class SubmissionCreateButton(nextcord.ui.View):
"""Defines view for score submission button."""
def __init__(self, bot: commands.Bot):
super().__init__(timeout=None)
self._bot = bot
@nextcord.ui.button(label="Submit", style=nextcord.ButtonStyle.primary, custom_id="submitcreate")
async def submitcreate_button(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: # noqa: ARG002
"""
Button for score submission.
Is deprecated, sends response telling user to use the new command instead.
"""
await interaction.response.send_message(
"This feature has been deprecated. Please use the '/submit' slash command instead.", ephemeral=True
)
+54
View File
@@ -0,0 +1,54 @@
"""View for verification buttons."""
import nextcord
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from sqlmodel import col
from database.db import engine
from database.models import Submission
from utilities.utils import submit_score
class VerificationView(nextcord.ui.View):
"""Defines views for verifying score submissions."""
def __init__(self) -> None:
super().__init__(timeout=None)
@nextcord.ui.button(label="Deny", style=nextcord.ButtonStyle.red, custom_id="deny-button-submission")
async def deny_button(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: # noqa: ARG002
"""Button for denying score submission."""
with Session(engine) as session:
stmt = (
delete(Submission)
.where(col(Submission.guild_id) == interaction.guild_id)
.where(col(Submission.message_id) == interaction.message.id)
)
session.execute(stmt)
session.commit()
await interaction.message.delete()
@nextcord.ui.button(label="Approve", style=nextcord.ButtonStyle.green, custom_id="approve-button-submission")
async def approve_button(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: # noqa: ARG002
"""Button for approving score submission."""
with Session(engine) as session:
stmt = (
select(Submission)
.where(col(Submission.guild_id) == interaction.guild_id)
.where(col(Submission.message_id) == interaction.message.id)
)
if (submission := session.scalar(stmt)) is None:
await interaction.response.send_message("Could not find submission data in database.", ephemeral=True)
await interaction.message.delete()
return
await submit_score(
interaction, submission.group_id, submission.category_id, submission.username, submission.score
)
session.delete(submission)
session.commit()
content = interaction.message.content
content += f"\nApproved by <@{interaction.user.id}>."
await interaction.message.edit(content=content, view=None)
+1
View File
@@ -0,0 +1 @@
"""Package for database interaction."""
+40
View File
@@ -0,0 +1,40 @@
import os
from dotenv import load_dotenv
from sqlmodel import Session, SQLModel, create_engine, select
from models import CategoryGroup, Guild
load_dotenv()
SQL_DB = os.getenv("SQL_DB", "")
SQL_HOST = os.getenv("SQL_HOST", "")
SQL_PASS = os.getenv("SQL_PASS", "")
SQL_USER = os.getenv("SQL_USER", "")
url = "sqlite:///database.db"
# url = f"mysql+pymysql://{SQL_USER}:{SQL_PASS}@{SQL_HOST}/{SQL_DB}"
engine = create_engine(url)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
guild = Guild(id=1, name="test guild")
session.add(guild)
group1 = CategoryGroup(name="group1", channel_id=1, guild_id=1, message_id=1)
group2 = CategoryGroup(name="group2", channel_id=2, guild_id=1, message_id=2)
session.add(group1)
session.add(group2)
session.commit()
# anotherone = Guild(id=2, name="test guild 2", submission_channel=1, verification_channel=2)
# session.add(anotherone)
# group1 = CategoryGroup(name="group1", channel_id=1, guild_id=2, message_id=1)
# group2 = CategoryGroup(name="group2", channel_id=2, guild_id=2, message_id=2)
# session.add(group1)
# session.add(group2)
# session.commit()
stmt = select(Guild).where(Guild.name == "Dakiny")
guild = session.scalar(stmt)
print(guild.category_groups)
+18
View File
@@ -0,0 +1,18 @@
"""Simple file to set up the sqlmodel connection to database."""
from sqlmodel import SQLModel, create_engine
from utilities import config
from utilities.logger import agon_logger
from . import models # noqa: F401 - Need this import for creating the models.
if config.ENVIRONMENT == "prod":
url = f"mysql+pymysql://{config.SQL_USER}:{config.SQL_PASS}@{config.SQL_HOST}/{config.SQL_DB}"
agon_logger.info("Connected to prod database.")
else:
url = "sqlite:///database.db"
agon_logger.info("Connected to local dev database.")
engine = create_engine(url)
# Don't need to create all when connected to prod.
SQLModel.metadata.create_all(engine)
+186
View File
@@ -0,0 +1,186 @@
"""File holds all model definitions of SQL tables."""
from sqlalchemy.dialects.mysql import BIGINT, BOOLEAN, FLOAT, INTEGER, SMALLINT, VARCHAR
from sqlmodel import Column, Field, ForeignKey, Relationship, SQLModel, UniqueConstraint
class Guild(SQLModel, table=True):
"""
Represents a Guild entity in the database.
Attributes:
id (int): The unique identifier for the guild.
name (str): The name of the guild.
submission_channel (int): The ID of the submission channel associated with the guild.
verification_channel (int): The ID of the verification channel associated with the guild.
active (bool): Indicates whether the guild is active. Defaults to True.
category_groups (list[CategoryGroup]): A list of category groups associated with the guild.
"""
__tablename__: str = "Guilds"
id: int = Field(sa_column=Column("GuildId", BIGINT(unsigned=True), primary_key=True, nullable=False))
name: str = Field(sa_column=Column("GuildName", VARCHAR), max_length=95)
# deprecated, not currently used.
submission_channel: int | None = Field(
sa_column=Column("SubmissionChannel", BIGINT(unsigned=True), nullable=True), default=None
)
verification_channel: int | None = Field(
sa_column=Column("VerificationChannel", BIGINT(unsigned=True), nullable=True), default=None
)
active: bool = Field(sa_column=Column("Active", BOOLEAN), default=True)
category_groups: list["CategoryGroup"] = Relationship(back_populates="guild", cascade_delete=True)
class CategoryGroup(SQLModel, table=True):
"""
Represents a category group in the database.
Attributes:
id (int | None): The primary key of the category group.
name (str): The name of the category group, with a maximum length of 50 characters.
channel_id (int): The ID of the channel associated with the category group.
guild_id (int): The ID of the guild associated with the category group.
message_id (int): The ID of the message associated with the category group.
pad_length (int): The padding length for the category group, default is 12.
username_length (int): The username length for the category group, default is 12.
guild (Guild | None): The guild relationship back-populated from the Guild model.
Table Constraints:
__table_args__: Unique constraint on guild_id and name to ensure unique category groups within a guild.
"""
__tablename__: str = "CategoryGroups"
id: int | None = Field(sa_column=Column("GroupId", INTEGER, primary_key=True), default=None)
name: str = Field(sa_column=Column("GroupName", VARCHAR), max_length=50)
channel_id: int = Field(sa_column=Column("ChannelId", BIGINT(unsigned=True)))
guild_id: int = Field(
sa_column=Column(
"GuildId",
BIGINT(unsigned=True),
ForeignKey("Guilds.GuildId", ondelete="CASCADE"),
nullable=False,
)
)
message_id: int | None = Field(sa_column=Column("MessageId", BIGINT(unsigned=True)), default=None)
pad_length: int = Field(default=12, sa_column=Column("PadLength", SMALLINT(unsigned=True)))
username_length: int = Field(default=12, sa_column=Column("UsernameLength", SMALLINT(unsigned=True)))
__table_args__ = (UniqueConstraint("GuildId", "GroupName", name="UniqueGuildCategoryGroup"),)
guild: Guild | None = Relationship(back_populates="category_groups")
categories: list["Category"] | None = Relationship(back_populates="category_group", cascade_delete=True)
class Category(SQLModel, table=True):
"""
Represents a category in the database.
Attributes:
id (int): The unique identifier for the category. Primary key.
name (str): The name of the category with a maximum length of 30 characters.
group_id (int): The identifier of the group to which the category belongs.
Foreign key referencing CategoryGroups.id.
size (int): The size attribute of the category, default is 3.
is_ascending (bool): Indicates if the category is in ascending order. Default is True.
is_time_record (bool): Indicates if the category is a time record. Default is False.
is_validated (bool): Indicates if the category is validated. Default is True.
category_group (CategoryGroup | None): The relationship to the CategoryGroup model.
scores (list["Score"] | None): The relationship to the Score model with cascade delete enabled.
Table Constraints:
__table_args__: Unique constraint on group_id and name to ensure unique category names within a group.
"""
__tablename__: str = "Categories"
id: int = Field(sa_column=Column("CategoryId", INTEGER, primary_key=True), default=None)
name: str = Field(sa_column=Column("CategoryName", VARCHAR), max_length=30)
group_id: int = Field(
sa_column=Column(
"GroupId",
INTEGER(unsigned=True),
ForeignKey("CategoryGroups.GroupId", ondelete="CASCADE"),
nullable=False,
)
)
size: int = Field(sa_column=Column("Size", SMALLINT(unsigned=True), nullable=False), default=3)
is_ascending: bool = Field(sa_column=Column("IsAscending", BOOLEAN, nullable=False), default=True)
is_time_record: bool = Field(sa_column=Column("IsTimeRecord", BOOLEAN, nullable=False), default=False)
is_validated: bool = Field(sa_column=Column("IsValidated", BOOLEAN, nullable=False), default=True)
__table_args__ = (UniqueConstraint("GroupId", "CategoryName", name="UniqueCategoryInGroup"),)
category_group: CategoryGroup | None = Relationship(back_populates="categories")
scores: list["Score"] | None = Relationship(back_populates="category", cascade_delete=True)
class Score(SQLModel, table=True):
"""
Represents a score entry in the database.
Attributes:
username (str): The username associated with the score. This is a primary key.
score (float): The score value.
category_id (int): The ID of the category this score belongs to. This is a primary key and a foreign key
referencing the Categories table.
category (Category | None): The relationship to the Category model, back-populated by the scores attribute
in the Category model.
"""
__tablename__: str = "Scores"
username: str = Field(sa_column=Column("UserName", VARCHAR, primary_key=True, nullable=False), max_length=30)
score: float = Field(sa_column=Column("Score", FLOAT, nullable=False))
category_id: int = Field(
sa_column=Column(
"CategoryId",
INTEGER(unsigned=True),
ForeignKey("Categories.CategoryId", ondelete="CASCADE"),
nullable=False,
primary_key=True,
),
)
category: Category | None = Relationship(back_populates="scores")
class Submission(SQLModel, table=True):
"""
Represents a submission in the database.
Attributes:
category_id (int): The ID of the category. This is a primary key.
group_id (int): The ID of the group.
guild_id (int): The ID of the guild.
message_id (int): The ID of the message.
score (str): The score of the submission, with a maximum length of 30 characters.
user_id (int): The ID of the user.
username (str): The username of the user. This is a primary key.
"""
__tablename__: str = "Submissions"
category_id: int = Field(
sa_column=Column(
"CategoryId",
INTEGER(unsigned=True),
nullable=False,
primary_key=True,
),
)
group_id: int = Field(
sa_column=Column(
"GroupId",
INTEGER(unsigned=True),
nullable=False,
)
)
guild_id: int = Field(sa_column=Column("GuildId", BIGINT(unsigned=True), nullable=False))
message_id: int = Field(sa_column=Column("MessageId", BIGINT(unsigned=True), nullable=False))
# Score is stored as a string until converted during submission.
score: str = Field(sa_column=Column("Score", VARCHAR, nullable=False), max_length=30)
user_id: int = Field(sa_column=Column("UserId", BIGINT(unsigned=True), nullable=False))
username: str = Field(sa_column=Column("UserName", VARCHAR, nullable=False, primary_key=True), max_length=30)
+53
View File
@@ -0,0 +1,53 @@
"""Main file for Agon bot."""
import logging
import os
import sys
import nextcord
from nextcord.ext import commands
from utilities import config
from utilities.logger import agon_logger
nextcord_logger = logging.getLogger("nextcord")
nextcord_logger.setLevel(logging.DEBUG)
nextcord_handler = logging.FileHandler(filename="nextcord.log", encoding="utf-8", mode="w")
nextcord_handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s"))
nextcord_logger.addHandler(nextcord_handler)
def main() -> None:
"""Main function for bot."""
min_python_version = (3, 12)
if sys.version_info < min_python_version:
agon_logger.error("ERROR: Python version must be >= 3.12")
sys.exit()
# Allows privileged intents for monitoring members joining, roles editing, and role assignments
# These need to be enabled in the developer portal as well
intents = nextcord.Intents.default()
bot = commands.Bot(
commands.when_mentioned_or(config.BOT_PREFIX),
intents=intents,
)
# Get the modules of all cogs whose directory structure is ./cogs/<module_name>
for folder in os.listdir("cogs"):
bot.load_extension(f"cogs.{folder}")
@bot.listen()
async def on_ready() -> None:
"""When Discord is connected."""
if bot.user is None:
agon_logger.error("Discord connection failed.")
return
agon_logger.info("%s has connected to Discord!", bot.user.name)
# Run Discord bot
bot.run(config.DISCORD_TOKEN)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Package of utility functions and environment configuration variables."""
+16
View File
@@ -0,0 +1,16 @@
"""Loads environment variables for use in bot."""
import os
from dotenv.main import load_dotenv
load_dotenv()
# Discord config
BOT_PREFIX = "?"
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
SQL_DB = os.getenv("SQL_DB", "")
SQL_HOST = os.getenv("SQL_HOST", "")
SQL_PASS = os.getenv("SQL_PASS", "")
SQL_USER = os.getenv("SQL_USER", "")
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "prod")
+10
View File
@@ -0,0 +1,10 @@
"""Creates logger for Agon bot."""
import logging
import sys
agon_logger = logging.getLogger("default")
agon_logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s"))
agon_logger.addHandler(handler)
+410
View File
@@ -0,0 +1,410 @@
"""Contains various utility functions for highscores bot."""
import time
import nextcord
import pandas as pd
from sqlalchemy import distinct, select
from sqlalchemy.orm import Session, aliased
from sqlmodel import col
from database.db import engine
from database.models import Category, CategoryGroup, Guild, Score
from main import agon_logger
MAX_LINE_LENGTH = 142
def autofill_categories(guild_id: int, group_id: int, category_begin: str) -> "dict[str, str]":
"""
Returns all category names that start with category_begin.
If category_begin is empty, returns list of all category names.
Args:
guild_id (int): Guild id that holds the group and categories.
group_id (int): Group id to search in.
category_begin (str): The beginning of a category name to search with. Can be empty.
Returns:
dict[str, str]: List of all category names that start with category_begin.
"""
with Session(engine) as session:
# search database for all category names
subq = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == guild_id)
.where(col(CategoryGroup.id) == group_id)
.subquery()
)
cat_group_alias = aliased(CategoryGroup, subq)
stmt = select(Category).where(col(Category.group_id) == cat_group_alias.id)
categories_dict: dict[str, str] = {cat.name: str(cat.id) for cat in session.scalars(stmt)}
# if string empty, return all category names
if not category_begin:
return dict(list(categories_dict.items())[:25])
# else find all categories that start with category_begin
categories: dict[str, str] = {
cat_name: cat_id for cat_name, cat_id in categories_dict.items() if category_begin.lower() in cat_name.lower()
}
return dict(list(categories.items())[:25])
def autofill_groups(guild_id: int, group_begin: str) -> "dict[str, str]":
"""
Returns all group names that start with group_begin. If group_begin is empty, returns list of all group names.
Args:
guild_id (int): Guild id that holds the groups.
group_begin (str): The beginning of a group name to search with. Can be empty.
Returns:
dict[str, str]: Dict of group names and ids where group name start with group_begin. Key is name, value is id.
"""
with Session(engine) as session:
# Select all groups in guild.
stmt = select(CategoryGroup).where(col(CategoryGroup.guild_id) == guild_id)
# dict has to have key values of same type for discord.
group_dict: dict[str, str] = {group.name: str(group.id) for group in session.scalars(stmt)}
# if string empty, return all group names
if not group_begin:
return dict(list(group_dict.items())[:25])
# else find all groups that start with group_begin
groups: dict[str, str] = {
group_name: group_id for group_name, group_id in group_dict.items() if group_begin.lower() in group_name.lower()
}
return dict(list(groups.items())[:25])
def autofill_users_in_group_category(guild_id: int, group_id: int, category_id: int, user_begin: str) -> "list[str]":
"""
Returns list of users that match user_begin.
Either all users in group or just users in category if category is not None. Might be expensive due to union-ing all
users in group.
Args:
guild_id (int): Guild id corresponding to group and category.
group_id (str): group to look in for category.
category_id (str): Category to look in for users. Can be None if not looking for category.
user_begin (str): Beginning of user name to search with. Can be empty.
Returns:
list[str]: List of users that start with user_begin.
"""
with Session(engine) as session:
# get all categories in the group
subq = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == guild_id)
.where(col(CategoryGroup.id) == group_id)
.subquery()
)
category_group = aliased(CategoryGroup, subq)
# If no category id, get all users in group.
if category_id == 0:
subq = select(col(Category.id)).where(col(Category.group_id) == category_group.id)
stmt = select(distinct(col(Score.username))).where(col(Score.category_id).in_(subq))
else:
# get all users in category_id
subq = (
select(Category)
.where(col(Category.group_id) == category_group.id)
.where(col(Category.id) == category_id)
.subquery()
)
category = aliased(Category, subq)
stmt = select(distinct(col(Score.username))).where(col(Score.category_id) == category.id)
users_list: list[str] = list(session.scalars(stmt))
# if string empty, return all users
if not user_begin:
return users_list[:25]
# else find all users that start with user_begin
users: list[str] = [user for user in users_list if user_begin.lower() in user.lower()]
return users[:25]
def autofill_users_in_guild(guild_id: int, user_begin: str) -> "list[str]":
"""
Obtains and returns a list of all the users who have submitted to the guild's highscores.
Args:
guild_id (int): Id of the guild.
user_begin (str): Beginning of user name to search with. Can be empty.
Returns:
list[str]: All users with submissions in the guild.
"""
with Session(engine) as session:
group_ids = select(col(CategoryGroup.id)).where(col(CategoryGroup.guild_id) == guild_id)
category_ids = select(col(Category.id)).where(col(Category.group_id).in_(group_ids))
stmt = select(distinct(col(Score.username))).where(col(Score.category_id).in_(category_ids))
users_list: list[str] = list(session.scalars(stmt))
# if string empty, return all users
if not user_begin:
return users_list[:25]
# else find all users that start with user_begin
users: list[str] = [user for user in users_list if user_begin.lower() in user.lower()]
return users[:25]
# TODO(imcompiling): #73 Address complexity warning.
# 073
def format_highscore_message(session: Session, group: CategoryGroup) -> str:
"""
Creates a formatted string for a category's highscore discord message.
This formatted string is in discord's code block to ensure monospace text.
Loops through each category to build the message one place at a time.
Args:
session (Session): the sqlalchemy session used for this function call.
group (CategoryGroup): group object that contains the contents of the message.
Returns:
str: Formatted string for discord message
"""
ret_string = f"**{group.name}**\n```"
stmt = select(Guild).where(col(Guild.id) == group.guild_id)
# Get all categories in the group
subq = (
select(CategoryGroup)
.where(col(CategoryGroup.guild_id) == group.guild_id)
.where(col(CategoryGroup.name) == group.name)
.subquery()
)
cat_group_alias = aliased(CategoryGroup, subq)
stmt = select(Category).where(col(Category.group_id) == cat_group_alias.id)
# set up a map for the categories
# also grab the max category size
categories: list[Category] = []
category_ids = []
max_cat_size: int = 0
pad_length = group.pad_length
for cat in session.scalars(stmt):
max_cat_size = max(cat.size, max_cat_size)
categories.append(cat)
category_ids.append(cat.id)
# get all scores in category group, load into data frame.
d_f = pd.read_sql_query(
sql=session.query(Score).where(col(Score.category_id).in_(category_ids)).statement,
con=engine,
)
# this list is what will be printed in the discord message
# first string will be category list, others will be score printouts
str_list: list[str] = ["" for i in range(max_cat_size + 1)]
groups_df = d_f.groupby("CategoryId")
for category in categories:
# category name string
str_list[0] += f"{category.name}".ljust(pad_length, " ")
# ensure there is data to print
if category.id in groups_df.indices:
group_df = groups_df.get_group(category.id)
# process data. Want each group sorted correctly and only top X results
processed_group = (
group_df.sort_values("Score", ascending=category.is_ascending).head(category.size).reset_index()
)
# add each score to list
for ind in range(max_cat_size): # need to subtract to deal with category name string
if ind in processed_group.index:
username = processed_group["UserName"][ind]
score = processed_group["Score"][ind]
str_list[ind + 1] += format_score_string(
username,
score,
group.username_length,
category.is_time_record,
ind + 1,
pad_length,
)
# if not enough entries, pad out
elif ind + 1 <= category.size:
str_list[ind + 1] += f"{ind + 1}: ".ljust(pad_length, " ")
else:
str_list[ind + 1] += "".ljust(pad_length, " ")
else:
for ind in range(max_cat_size):
if ind + 1 <= category.size:
str_list[ind + 1] += f"{ind + 1}: ".ljust(pad_length, " ")
else:
str_list[ind + 1] += "".ljust(pad_length, " ")
# next step is to cut down on the length of each line if required
# need to use index to allow for modifying the list
for ind, line in enumerate(str_list):
if len(line) > MAX_LINE_LENGTH:
split_loc = int(MAX_LINE_LENGTH / pad_length) * pad_length
new_line = line[split_loc:]
str_list[ind] = line[:split_loc]
str_list.append(new_line)
# this list can now be placed into the format message function in the bot
for ind, string in enumerate(str_list):
# rstrip to clear unneeded spaces
# add newline here for each string
ret_string += string.rstrip() + "\n"
# add extra newline at category split unless there is already a space.
if ((ind + 1) % (max_cat_size + 1)) == 0 and not ret_string.endswith("\n\n"):
ret_string += "\n"
return ret_string + "```"
# TODO(imcompiling): rework into two functions to remove the boolean and reduce function arguments.
# 084
def format_score_string(
username: str,
score: float,
username_length: int,
is_time_record: bool,
rank: int,
pad_length: int,
) -> str:
"""
Builds the string to show a user's score.
Args:
username (str): username.
score (int): score in int form.
username_length (float): limit to how long username is allowed to be.
is_time_record (bool): if the score represents an number or a time record.
rank (int): the user's rank.
pad_length (int): limit to how long the string can be.
Returns:
str: string to show user's score.
"""
username = username[:username_length].ljust(username_length, " ")
# get score, pad to correct length
if is_time_record:
# MM:SS.ms is 7 characters long
# if there are single digit minutes, add a space for formatting.
formatted_score = get_time_str(score)
if formatted_score.find(":") and len(formatted_score.split(":", maxsplit=1)[0]) < 2: # noqa: PLR2004
formatted_score = " " + formatted_score
else:
formatted_score = str(int(score)).rjust(7, " ")
return f"{rank}: {username} - {formatted_score}".ljust(pad_length, " ")
def get_time_str(time_in: float) -> str:
"""
Formats a float value into the desired string format for a timestamp.
Args:
time_in (float): A time record in seconds
Returns:
str: Describes the time record in string format.
"""
# TODO see if theres a better way to do this
milliseconds = round((time_in - int(time_in)) * 10)
return time.strftime(f"%-M:%S.{milliseconds}", time.gmtime(time_in))
async def send_highscore_message(session: Session, guild: nextcord.Guild, group: CategoryGroup) -> None:
"""
Function takes care of checking if a message exists for a group.
Creates and sends/edits the message.
Args:
session (Session): The sqlalchemy session being used for this function call.
guild (nextcord.Guild): the nextcord.Guild object requesting this message.
group (CategoryGroup): group to post to the message.
"""
highscore_string = format_highscore_message(session, group)
channel = guild.get_channel(group.channel_id)
if not isinstance(channel, nextcord.TextChannel):
agon_logger.error(
"Channel id %s in guild %s is does not correspond to a TextChannel.", channel.id, channel.guild.id
)
return
if group.message_id is None:
# message doesn't exist.
message: nextcord.Message = await channel.send(highscore_string)
group.message_id = message.id
session.commit()
else:
# TODO(imcompiling): check what happens when message is deleted.
# https://gitea.cheezers.org/imcompiling/agon/issues/83
message: nextcord.Message = await channel.fetch_message(group.message_id)
await message.edit(content=highscore_string)
return
async def submit_score(
interaction: nextcord.Interaction,
group_id: int,
category_id: int,
username: str,
score: str,
) -> None:
"""
Adds passed score to group. Then updates group message by calling send_highscore_message.
Ensures that a highscores channel exists.
Args:
interaction (nextcord.Interaction): Interaction that caused the score submission.
group_id (int): group id to submit score to.
category_id (int): Category id to submit score to.
username (str): The user who submitted the score.
score (str): Score to submit. Need to convert to correct type.
"""
with Session(engine) as session:
stmt = select(col(Category.is_time_record)).where(col(Category.id) == category_id)
is_time_record = session.scalar(stmt)
if is_time_record:
minutes = float(score.split(":", maxsplit=1)[0])
seconds = float(score.split(":", maxsplit=1)[1])
score_f = (minutes * 60) + seconds
else:
score_f = float(score)
stmt = select(Score).where(col(Score.username) == username).where(col(Score.category_id) == category_id)
existing_score = session.scalar(stmt)
if existing_score:
existing_score.score = score_f
else:
score_obj = Score(username=username, score=score_f, category_id=category_id)
session.add(score_obj)
session.commit()
stmt = select(CategoryGroup).where(col(CategoryGroup.id) == group_id)
group = session.scalar(stmt)
await send_highscore_message(session, interaction.guild, group)