WIP: Refactor Aurora (3.0.0) #29

Draft
cswimr wants to merge 347 commits from aurora-pydantic into main
6 changed files with 167 additions and 162 deletions
Showing only changes of commit 6147c8c6d5 - Show all commits

View file

@ -19,8 +19,7 @@ from redbot.core import app_commands, commands, data_manager
from redbot.core.app_commands import Choice from redbot.core.app_commands import Choice
from redbot.core.bot import Red from redbot.core.bot import Red
from redbot.core.commands.converter import parse_relativedelta, parse_timedelta from redbot.core.commands.converter import parse_relativedelta, parse_timedelta
from redbot.core.utils.chat_formatting import (box, error, humanize_list, from redbot.core.utils.chat_formatting import box, error, humanize_list, humanize_timedelta, warning
humanize_timedelta, warning)
from aurora.importers.aurora import ImportAuroraView from aurora.importers.aurora import ImportAuroraView
from aurora.importers.galacticbot import ImportGalacticBotView from aurora.importers.galacticbot import ImportGalacticBotView
@ -28,21 +27,13 @@ from aurora.menus.addrole import Addrole
from aurora.menus.guild import Guild from aurora.menus.guild import Guild
from aurora.menus.immune import Immune from aurora.menus.immune import Immune
from aurora.menus.overrides import Overrides from aurora.menus.overrides import Overrides
from aurora.models import Moderation
from aurora.utilities.config import config, register_config from aurora.utilities.config import config, register_config
from aurora.utilities.database import (connect, create_guild_table, fetch_case, from aurora.utilities.database import connect, create_guild_table, fetch_case, mysql_log
mysql_log) from aurora.utilities.factory import addrole_embed, case_factory, changes_factory, evidenceformat_factory, guild_embed, immune_embed, message_factory, overrides_embed
from aurora.utilities.factory import (addrole_embed, case_factory,
changes_factory, evidenceformat_factory,
guild_embed, immune_embed,
message_factory, overrides_embed)
from aurora.utilities.json import dump, dumps from aurora.utilities.json import dump, dumps
from aurora.utilities.logger import logger from aurora.utilities.logger import logger
from aurora.utilities.utils import (check_moddable, check_permissions, from aurora.utilities.utils import check_moddable, check_permissions, convert_timedelta_to_str, fetch_channel_dict, fetch_user_dict, generate_dict, get_footer_image, log, send_evidenceformat, timedelta_from_relativedelta
convert_timedelta_to_str,
fetch_channel_dict, fetch_user_dict,
generate_dict, get_footer_image, log,
send_evidenceformat,
timedelta_from_relativedelta)
class Aurora(commands.Cog): class Aurora(commands.Cog):
@ -1406,10 +1397,10 @@ class Aurora(commands.Cog):
) )
if case != 0: if case != 0:
case_dict = await fetch_case(case, interaction.guild.id) mod = Moderation.from_sql(interaction.client, case, interaction.guild.id)
if case_dict: if mod:
if export: if export:
if export.value == "file" or len(str(case_dict)) > 1800: if export.value == "file" or len(mod.to_json(2)) > 1800:
filename = ( filename = (
str(data_manager.cog_data_path(cog_instance=self)) str(data_manager.cog_data_path(cog_instance=self))
+ str(os.sep) + str(os.sep)
@ -1417,8 +1408,7 @@ class Aurora(commands.Cog):
) )
with open(filename, "w", encoding="utf-8") as f: with open(filename, "w", encoding="utf-8") as f:
dump(case_dict, f, indent=2) mod.to_json(2, f)
if export.value == "codeblock": if export.value == "codeblock":
content = f"Case #{case:,} exported.\n" + warning( content = f"Case #{case:,} exported.\n" + warning(
"Case was too large to export as codeblock, so it has been uploaded as a `.json` file." "Case was too large to export as codeblock, so it has been uploaded as a `.json` file."
@ -1438,27 +1428,27 @@ class Aurora(commands.Cog):
os.remove(filename) os.remove(filename)
return return
await interaction.response.send_message( await interaction.response.send_message(
content=box(dumps(case_dict, indent=2), 'json'), content=box(mod.to_json(2), 'json'),
ephemeral=ephemeral, ephemeral=ephemeral,
) )
return return
if changes: if changes:
embed = await changes_factory( embed = await changes_factory(
interaction=interaction, case_dict=case_dict interaction=interaction, moderation=mod
) )
await interaction.response.send_message( await interaction.response.send_message(
embed=embed, ephemeral=ephemeral embed=embed, ephemeral=ephemeral
) )
elif evidenceformat: elif evidenceformat:
content = await evidenceformat_factory( content = await evidenceformat_factory(
interaction=interaction, case_dict=case_dict interaction=interaction, moderation=mod
) )
await interaction.response.send_message( await interaction.response.send_message(
content=content, ephemeral=ephemeral content=content, ephemeral=ephemeral
) )
else: else:
embed = await case_factory( embed = await case_factory(
interaction=interaction, case_dict=case_dict interaction=interaction, moderation=mod
) )
await interaction.response.send_message( await interaction.response.send_message(
embed=embed, ephemeral=ephemeral embed=embed, ephemeral=ephemeral

View file

@ -1,14 +1,18 @@
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, List, Optional from typing import Any, Dict, List, Optional, Union
from async_property import async_cached_property
from discord import Forbidden, HTTPException, InvalidData, NotFound
from pydantic import BaseModel from pydantic import BaseModel
from redbot.core.bot import Red
from aurora.utilities.utils import generate_dict
class AuroraBaseModel(BaseModel): class AuroraBaseModel(BaseModel):
"""Base class for all models in Aurora.""" """Base class for all models in Aurora."""
class Moderation(AuroraBaseModel): class Moderation(AuroraBaseModel):
bot: Red
moderation_id: int moderation_id: int
guild_id: int guild_id: int
timestamp: datetime timestamp: datetime
@ -27,11 +31,36 @@ class Moderation(AuroraBaseModel):
changes: List[Dict] changes: List[Dict]
metadata: Dict metadata: Dict
@property
def id(self) -> int:
return self.moderation_id
@property
def type(self) -> str:
return self.moderation_type
@async_cached_property
async def moderator(self) -> "PartialUser":
return await PartialUser.from_id(self.bot, self.moderator_id)
@async_cached_property
async def target(self) -> Union["PartialUser", "PartialChannel"]:
if self.target_type == "user":
return await PartialUser.from_id(self.bot, self.target_id)
else:
return await PartialChannel.from_id(self.bot, self.target_id)
@async_cached_property
async def resolved_by_user(self) -> Optional["PartialUser"]:
if self.resolved_by:
return await PartialUser.from_id(self.bot, self.resolved_by)
return None
def __str__(self): def __str__(self):
return f"{self.moderation_type} {self.target_type} {self.target_id} {self.reason}" return f"{self.moderation_type} {self.target_type} {self.target_id} {self.reason}"
@classmethod @classmethod
def from_sql(cls, moderation_id: int, guild_id: int): def from_sql(cls, bot: Red, moderation_id: int, guild_id: int) -> Optional["Moderation"]:
from aurora.utilities.database import connect from aurora.utilities.database import connect
query = f"SELECT * FROM moderation_{guild_id} WHERE moderation_id = ?;" query = f"SELECT * FROM moderation_{guild_id} WHERE moderation_id = ?;"
@ -41,37 +70,64 @@ class Moderation(AuroraBaseModel):
result = cursor.fetchone() result = cursor.fetchone()
if result: if result:
if result[7] is not None: case = generate_dict(result)
hours, minutes, seconds = map(int, result[7].split(':'))
duration = timedelta(hours=hours, minutes=minutes, seconds=seconds)
else:
duration = None
case = {
"moderation_id": int(result[0]),
"guild_id": int(guild_id),
"timestamp": datetime.fromtimestamp(result[1]),
"moderation_type": str(result[2]),
"target_type": str(result[3]),
"target_id": int(result[4]),
"moderator_id": int(result[5]),
"role_id": int(result[6]) if result[6] is not None else None,
"duration": duration,
"end_timestamp": datetime.fromtimestamp(result[8]) if result[8] is not None else None,
"reason": result[9],
"resolved": bool(result[10]),
"resolved_by": result[11],
"resolve_reason": result[12],
"expired": bool(result[13]),
"changes": json.loads(result[14].strip('"').replace('\\"', '"')) if result[14] else [],
"metadata": json.loads(result[15].strip('"').replace('\\"', '"')) if result[15] else {},
}
cursor.close() cursor.close()
return cls.from_dict(bot, case)
return cls(**case)
return None return None
def to_json(self, indent: int = None, file: bool = False): @classmethod
def from_dict(cls, bot: Red, data: dict) -> "Moderation":
return cls(bot=bot, **data)
def to_json(self, indent: int = None, file: Any = None):
from aurora.utilities.json import dump, dumps from aurora.utilities.json import dump, dumps
return dump(self.model_dump(), indent=indent) if file else dumps(self.model_dump(), indent=indent) return dump(self.model_dump(exclude={"bot", "guild_id"}), file, indent=indent) if file else dumps(self.model_dump(exclude={"bot", "guild_id"}), indent=indent)
class PartialUser(AuroraBaseModel):
id: int
username: str
discriminator: int
@property
def name(self):
return f"{self.username}#{self.discriminator}" if self.discriminator == 0 else self.username
def __str__(self):
return self.name
@classmethod
async def from_id(cls, bot: Red, user_id: int) -> "PartialUser":
user = bot.get_user(user_id)
if not user:
try:
user = await bot.fetch_user(user_id)
return cls(id=user.id, username=user.name, discriminator=user.discriminator)
except NotFound:
return cls(id=user_id, username="Deleted User", discriminator=0)
class PartialChannel(AuroraBaseModel):
id: int
name: str
@property
def mention(self):
if self.name == "Deleted Channel" or self.name == "Forbidden Channel":
return self.name
return f"<#{self.id}>"
def __str__(self):
return self.mention
@classmethod
async def from_id(cls, bot: Red, channel_id: int) -> "PartialChannel":
user = bot.get_channel(channel_id)
if not user:
try:
user = await bot.fetch_channel(channel_id)
return cls(id=user.id, username=user.name, discriminator=user.discriminator)
except (NotFound, InvalidData, HTTPException, Forbidden) as e:
if e == Forbidden:
return cls(id=channel_id, name="Forbidden Channel")
return cls(id=channel_id, name="Deleted Channel")

View file

@ -1,13 +1,18 @@
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Union from typing import Optional, Union
from discord import Color, Embed, Guild, Interaction, InteractionMessage, Member, Role, User from discord import (Color, Embed, Guild, Interaction, InteractionMessage,
Member, Role, User)
from redbot.core import commands from redbot.core import commands
from redbot.core.utils.chat_formatting import bold, box, error, humanize_timedelta, warning from redbot.core.utils.chat_formatting import (bold, box, error,
humanize_timedelta, warning)
from aurora.models import Moderation, PartialChannel, PartialUser
from aurora.utilities.config import config from aurora.utilities.config import config
from aurora.utilities.utils import fetch_channel_dict, fetch_user_dict, get_bool_emoji, get_next_case_number, get_pagesize_str from aurora.utilities.utils import (fetch_channel_dict, fetch_user_dict,
get_bool_emoji, get_next_case_number,
get_pagesize_str)
async def message_factory( async def message_factory(
@ -94,7 +99,7 @@ async def message_factory(
async def log_factory( async def log_factory(
interaction: Interaction, case_dict: dict, resolved: bool = False interaction: Interaction, moderation: Moderation, resolved: bool = False
) -> Embed: ) -> Embed:
"""This function creates a log embed from set parameters, meant for moderation logging. """This function creates a log embed from set parameters, meant for moderation logging.
@ -103,113 +108,50 @@ async def log_factory(
case_dict (dict): The case dictionary. case_dict (dict): The case dictionary.
resolved (bool, optional): Whether the case is resolved or not. Defaults to False. resolved (bool, optional): Whether the case is resolved or not. Defaults to False.
""" """
target: Union[PartialUser, PartialChannel] = await moderation.target
moderator: PartialUser = await moderation.moderator
if resolved: if resolved:
if case_dict["target_type"] == "USER":
target_user = await fetch_user_dict(interaction.client, case_dict["target_id"])
target_name = (
f"`{target_user['name']}`"
if target_user["discriminator"] == "0"
else f"`{target_user['name']}#{target_user['discriminator']}`"
)
elif case_dict["target_type"] == "CHANNEL":
target_user = await fetch_channel_dict(interaction.guild, case_dict["target_id"])
if target_user["mention"]:
target_name = f"{target_user['mention']}"
else:
target_name = f"`{target_user['name']}`"
moderator_user = await fetch_user_dict(interaction.client, case_dict["moderator_id"])
moderator_name = (
f"`{moderator_user['name']}`"
if moderator_user["discriminator"] == "0"
else f"`{moderator_user['name']}#{moderator_user['discriminator']}`"
)
embed = Embed( embed = Embed(
title=f"📕 Case #{case_dict['moderation_id']:,} Resolved", title=f"📕 Case #{moderation.id:,} Resolved",
color=await interaction.client.get_embed_color(interaction.channel), color=await interaction.client.get_embed_color(interaction.channel),
) )
embed.description = f"**Type:** {str.title(case_dict['moderation_type'])}\n**Target:** {target_name} ({target_user['id']})\n**Moderator:** {moderator_name} ({moderator_user['id']})\n**Timestamp:** <t:{case_dict['timestamp']}> | <t:{case_dict['timestamp']}:R>" resolved_by: Optional[PartialUser] = await moderation.resolved_by_user
embed.description = f"**Type:** {str.title(moderation.moderation_type)}\n**Target:** {target.name} ({target.id})\n**Moderator:** {moderator.name} ({moderator.id})\n**Timestamp:** <t:{moderation.timestamp}> | <t:{moderation.timestamp}:R>"
if case_dict["duration"] != "NULL": if moderation.duration is not None:
td = timedelta(
**{
unit: int(val)
for unit, val in zip(
["hours", "minutes", "seconds"],
case_dict["duration"].split(":"),
)
}
)
duration_embed = ( duration_embed = (
f"{humanize_timedelta(timedelta=td)} | <t:{case_dict['end_timestamp']}:R>" f"{humanize_timedelta(timedelta=moderation.duration)} | <t:{moderation.end_timestamp}:R>"
if case_dict["expired"] == "0" if not moderation.expired
else str(humanize_timedelta(timedelta=td)) else str(humanize_timedelta(timedelta=moderation.duration))
) )
embed.description = ( embed.description = (
embed.description embed.description
+ f"\n**Duration:** {duration_embed}\n**Expired:** {bool(case_dict['expired'])}" + f"\n**Duration:** {duration_embed}\n**Expired:** {moderation.expired}"
) )
embed.add_field(name="Reason", value=box(case_dict["reason"]), inline=False) embed.add_field(name="Reason", value=box(moderation.reason), inline=False)
resolved_user = await fetch_user_dict(interaction.client, case_dict["resolved_by"])
resolved_name = (
resolved_user["name"]
if resolved_user["discriminator"] == "0"
else f"{resolved_user['name']}#{resolved_user['discriminator']}"
)
embed.add_field( embed.add_field(
name="Resolve Reason", name="Resolve Reason",
value=f"Resolved by `{resolved_name}` ({resolved_user['id']}) for:\n" value=f"Resolved by `{resolved_by.name}` ({resolved_by.id}) for:\n"
+ box(case_dict["resolve_reason"]), + box(moderation.resolve_reason),
inline=False, inline=False,
) )
else: else:
if case_dict["target_type"] == "USER":
target_user = await fetch_user_dict(interaction.client, case_dict["target_id"])
target_name = (
f"`{target_user['name']}`"
if target_user["discriminator"] == "0"
else f"`{target_user['name']}#{target_user['discriminator']}`"
)
elif case_dict["target_type"] == "CHANNEL":
target_user = await fetch_channel_dict(interaction.guild, case_dict["target_id"])
if target_user["mention"]:
target_name = target_user["mention"]
else:
target_name = f"`{target_user['name']}`"
moderator_user = await fetch_user_dict(interaction.client, case_dict["moderator_id"])
moderator_name = (
f"`{moderator_user['name']}`"
if moderator_user["discriminator"] == "0"
else f"`{moderator_user['name']}#{moderator_user['discriminator']}`"
)
embed = Embed( embed = Embed(
title=f"📕 Case #{case_dict['moderation_id']:,}", title=f"📕 Case #{moderation.id:,}",
color=await interaction.client.get_embed_color(interaction.channel), color=await interaction.client.get_embed_color(interaction.channel),
) )
embed.description = f"**Type:** {str.title(case_dict['moderation_type'])}\n**Target:** {target_name} ({target_user['id']})\n**Moderator:** {moderator_name} ({moderator_user['id']})\n**Timestamp:** <t:{case_dict['timestamp']}> | <t:{case_dict['timestamp']}:R>" embed.description = f"**Type:** {str.title(moderation.type)}\n**Target:** {target.name} ({target.id})\n**Moderator:** {moderator.name} ({moderator.id})\n**Timestamp:** <t:{moderation.timestamp}> | <t:{moderation.timestamp}:R>"
if case_dict["duration"] != "NULL": if moderation.duration:
td = timedelta(
**{
unit: int(val)
for unit, val in zip(
["hours", "minutes", "seconds"],
case_dict["duration"].split(":"),
)
}
)
embed.description = ( embed.description = (
embed.description embed.description
+ f"\n**Duration:** {humanize_timedelta(timedelta=td)} | <t:{case_dict['end_timestamp']}:R>" + f"\n**Duration:** {humanize_timedelta(timedelta=moderation.duration)} | <t:{moderation.timestamp}:R>"
) )
embed.add_field(name="Reason", value=box(case_dict["reason"]), inline=False) embed.add_field(name="Reason", value=box(moderation.reason), inline=False)
return embed return embed

View file

@ -1,7 +1,6 @@
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
import json import json
from datetime import datetime from datetime import datetime, timedelta
from datetime import timedelta as td
from typing import Optional, Union from typing import Optional, Union
from dateutil.relativedelta import relativedelta as rd from dateutil.relativedelta import relativedelta as rd
@ -10,7 +9,7 @@ from discord.errors import Forbidden, NotFound
from redbot.core import commands, data_manager from redbot.core import commands, data_manager
from redbot.core.utils.chat_formatting import error from redbot.core.utils.chat_formatting import error
from .config import config from aurora.utilities.config import config
def check_permissions( def check_permissions(
@ -125,24 +124,30 @@ async def get_next_case_number(guild_id: str, cursor=None) -> int:
return (result[0] + 1) if result else 1 return (result[0] + 1) if result else 1
def generate_dict(result) -> dict: def generate_dict(result: dict, guild_id: int) -> dict:
if result[7] is not None:
hours, minutes, seconds = map(int, result[7].split(':'))
duration = timedelta(hours=hours, minutes=minutes, seconds=seconds)
else:
duration = None
case = { case = {
"moderation_id": result[0], "moderation_id": int(result[0]),
"timestamp": result[1], "guild_id": int(guild_id),
"moderation_type": result[2], "timestamp": datetime.fromtimestamp(result[1]),
"target_type": result[3], "moderation_type": str(result[2]),
"target_id": result[4], "target_type": str(result[3]),
"moderator_id": result[5], "target_id": int(result[4]),
"role_id": result[6], "moderator_id": int(result[5]),
"duration": result[7], "role_id": int(result[6]) if result[6] is not None else None,
"end_timestamp": result[8], "duration": duration,
"end_timestamp": datetime.fromtimestamp(result[8]) if result[8] is not None else None,
"reason": result[9], "reason": result[9],
"resolved": result[10], "resolved": bool(result[10]),
"resolved_by": result[11], "resolved_by": result[11],
"resolve_reason": result[12], "resolve_reason": result[12],
"expired": result[13], "expired": bool(result[13]),
"changes": json.loads(result[14]), "changes": json.loads(result[14].strip('"').replace('\\"', '"')) if result[14] else [],
"metadata": json.loads(result[15]), "metadata": json.loads(result[15].strip('"').replace('\\"', '"')) if result[15] else {},
} }
return case return case
@ -241,9 +246,9 @@ async def send_evidenceformat(interaction: Interaction, case_dict: dict) -> None
await interaction.followup.send(content=content, ephemeral=True) await interaction.followup.send(content=content, ephemeral=True)
def convert_timedelta_to_str(timedelta: td) -> str: def convert_timedelta_to_str(td: timedelta) -> str:
"""This function converts a timedelta object to a string.""" """This function converts a timedelta object to a string."""
total_seconds = int(timedelta.total_seconds()) total_seconds = int(td.total_seconds())
hours = total_seconds // 3600 hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60 minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60 seconds = total_seconds % 60
@ -286,7 +291,7 @@ def create_pagesize_options() -> list[SelectOption]:
) )
return options return options
def timedelta_from_relativedelta(relativedelta: rd) -> td: def timedelta_from_relativedelta(relativedelta: rd) -> timedelta:
"""Converts a relativedelta object to a timedelta object.""" """Converts a relativedelta object to a timedelta object."""
now = datetime.now() now = datetime.now()
then = now - relativedelta then = now - relativedelta

13
poetry.lock generated
View file

@ -206,6 +206,17 @@ files = [
{file = "astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"}, {file = "astroid-3.1.0.tar.gz", hash = "sha256:ac248253bfa4bd924a0de213707e7ebeeb3138abeb48d798784ead1e56d419d4"},
] ]
[[package]]
name = "async-property"
version = "0.2.2"
description = "Python decorator for async properties."
optional = false
python-versions = "*"
files = [
{file = "async_property-0.2.2-py2.py3-none-any.whl", hash = "sha256:8924d792b5843994537f8ed411165700b27b2bd966cefc4daeefc1253442a9d7"},
{file = "async_property-0.2.2.tar.gz", hash = "sha256:17d9bd6ca67e27915a75d92549df64b5c7174e9dc806b30a3934dc4ff0506380"},
]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "23.2.0" version = "23.2.0"
@ -2545,4 +2556,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.11,<3.12" python-versions = ">=3.11,<3.12"
content-hash = "67eb5e616951979332b6f32bcb39d85171cbf8377f566ea1862c51b5068b52f3" content-hash = "05c89da1577b4a3507856338502218e0da92dd9785a5fc4a78d6cb59058d887f"

View file

@ -15,6 +15,7 @@ websockets = "^12.0"
pillow = "^10.3.0" pillow = "^10.3.0"
numpy = "^1.26.4" numpy = "^1.26.4"
pydantic = "^2.7.1" pydantic = "^2.7.1"
async-property = "^0.2.2"
[tool.poetry.group.dev] [tool.poetry.group.dev]
optional = true optional = true