1
Fork 0
mirror of https://github.com/wlinator/luminara.git synced 2024-10-02 18:03:12 +00:00

Merge pull request #41 from wlinator/v3

[v3] Rewrite entire codebase to port to discord.py
This commit is contained in:
wlinator 2024-09-02 05:48:08 -04:00 committed by GitHub
commit 14111b15e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
131 changed files with 8495 additions and 5277 deletions

View file

@ -1,11 +1,14 @@
TOKEN=
INSTANCE=
OWNER_IDS=
XP_GAIN_PER_MESSAGE=
XP_GAIN_COOLDOWN=
DBX_OAUTH2_REFRESH_TOKEN=
DBX_APP_KEY=
DBX_APP_SECRET=
MARIADB_USER=
MARIADB_PASSWORD=
MARIADB_ROOT_PASSWORD=

View file

@ -1,23 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,55 +0,0 @@
name: Create and Publish Docker Image CI
on:
push:
branches: [ "main" ]
tags: [ "v*.*.*" ]
pull_request:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
wlinator/luminara
ghcr.io/wlinator/luminara
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View file

@ -3,7 +3,11 @@ repos:
rev: v4.6.0
hooks:
- id: check-yaml
- id: sort-simple-yaml
files: settings.yaml
- id: check-json
- id: pretty-format-json
args: [--autofix]
- id: check-toml
- repo: https://github.com/asottile/add-trailing-comma
@ -16,7 +20,7 @@ repos:
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
args: [--fix]
# Run the formatter.
- id: ruff-format
@ -25,11 +29,6 @@ repos:
hooks:
- id: gitleaks
- repo: https://github.com/hija/clean-dotenv
rev: v0.0.7
hooks:
- id: clean-dotenv
- repo: https://github.com/asottile/pyupgrade
rev: v3.16.0
hooks:

View file

@ -1,43 +0,0 @@
import os
import platform
import discord
from discord.ext import bridge
from loguru import logger
from lib.constants import CONST
class LumiBot(bridge.Bot):
async def on_ready(self):
"""
Called when the bot is ready.
Logs various information about the bot and the environment it is running on.
Note: This function isn't guaranteed to only be called once. The event is called when a RESUME request fails.
"""
logger.info(f"{CONST.TITLE} v{CONST.VERSION}")
logger.info(f"Logged in with ID {self.user.id if self.user else 'Unknown'}")
logger.info(f"discord.py API version: {discord.__version__}")
logger.info(f"Python version: {platform.python_version()}")
logger.info(f"Running on: {platform.system()} {platform.release()} ({os.name})")
if self.owner_ids:
for owner_id in self.owner_ids:
logger.info(f"Added bot admin: {owner_id}")
async def process_commands(self, message: discord.Message):
"""
Processes commands sent by users.
Args:
message (discord.Message): The message object containing the command.
"""
if message.author.bot:
return
ctx = await self.get_context(message)
if ctx.command:
# await ctx.trigger_typing()
await self.invoke(ctx)

View file

@ -22,4 +22,4 @@ RUN rm -rf .venv
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
CMD [ "poetry", "run", "python", "./Luminara.py" ]
CMD [ "poetry", "run", "python", "-O", "./main.py" ]

View file

@ -1,100 +0,0 @@
import os
import sys
import discord
from discord.ext import commands
from loguru import logger
import Client
import services.config_service
import services.help_service
from db.database import run_migrations
from lib.constants import CONST
from lib.exceptions.LumiExceptions import Blacklisted
from services.blacklist_service import BlacklistUserService
# Remove the default logger configuration
logger.remove()
# Add a new logger configuration with colors and a short datetime format
log_format = (
"<green>{time:YY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
# "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)
logger.add(sys.stdout, format=log_format, colorize=True, level="DEBUG")
async def get_prefix(bot, message):
extras = services.config_service.GuildConfig.get_prefix(message)
return commands.when_mentioned_or(*extras)(bot, message)
client = Client.LumiBot(
owner_ids=CONST.OWNER_IDS,
command_prefix=get_prefix,
intents=discord.Intents.all(),
status=discord.Status.online,
help_command=services.help_service.LumiHelp(),
)
@client.check
async def blacklist_check(ctx):
if BlacklistUserService.is_user_blacklisted(ctx.author.id):
raise Blacklisted
return True
def load_modules():
loaded = set()
# Load event listeners (handlers) and command cogs (modules)
for directory in ["handlers", "modules"]:
directory_path = os.path.join(os.getcwd(), directory)
if not os.path.isdir(directory_path):
continue
items = (
[
d
for d in os.listdir(directory_path)
if os.path.isdir(os.path.join(directory_path, d))
]
if directory == "modules"
else [f[:-3] for f in os.listdir(directory_path) if f.endswith(".py")]
)
for item in items:
if item in loaded:
continue
try:
client.load_extension(f"{directory}.{item}")
loaded.add(item)
logger.debug(f"{item.upper()} loaded.")
except Exception as e:
logger.exception(f"Failed to load {item.upper()}: {e}")
if __name__ == "__main__":
"""
This code is only ran when Lumi.py is the primary module,
so NOT when main is imported from a cog. (sys.modules)
"""
logger.info("LUMI IS BOOTING")
# Run database migrations
run_migrations()
# load command and listener cogs
load_modules()
if not CONST.TOKEN:
logger.error("token is not set in .env")
exit(1)
client.run(CONST.TOKEN)

View file

@ -7,7 +7,7 @@
Self-hosting refers to running Luminara on your own server or computer, rather than using the publicly hosted version.
This approach offers the ability to manage your own instance of the bot and give it a custom name and avatar.
**Note:** From `v2.9.0` and onward, Lumi now utilizes a [settings.yaml](settings/settings.yaml) file to manage configuration settings. This allows you to customize your bot's behavior without needing to modify the source code itself.
**Note:** From `v2.9.0` and onward, Lumi now utilizes a [settings.yaml](settings.yaml) file to manage configuration settings. This allows you to customize your bot's behavior without needing to modify the source code itself.
### Requirements

0
db/__init__.py Normal file
View file

View file

@ -1,12 +1,13 @@
import os
import pathlib
import re
from typing import Any
import mysql.connector
from loguru import logger
from mysql.connector import pooling
from lib.constants import CONST
from lib.const import CONST
def create_connection_pool(name: str, size: int) -> pooling.MySQLConnectionPool:
@ -27,35 +28,31 @@ try:
_cnxpool = create_connection_pool("core-pool", 25)
except mysql.connector.Error as e:
logger.critical(f"Couldn't create the MySQL connection pool: {e}")
raise e
raise
def execute_query(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
def execute_query(query: str, values: tuple[Any, ...] | None = None) -> None:
with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
cursor.execute(query, values)
conn.commit()
return cursor
def select_query(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
def select_query(query: str, values: tuple[Any, ...] | None = None) -> list[Any]:
with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
cursor.execute(query, values)
return cursor.fetchall()
def select_query_one(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
def select_query_one(query: str, values: tuple[Any, ...] | None = None) -> Any | None:
with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
cursor.execute(query, values)
output = cursor.fetchone()
return output[0] if output else None
def select_query_dict(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
def select_query_dict(query: str, values: tuple[Any, ...] | None = None) -> list[dict[str, Any]]:
with _cnxpool.get_connection() as conn, conn.cursor(dictionary=True) as cursor:
cursor.execute(query, values)
return cursor.fetchall()
@ -66,8 +63,7 @@ def run_migrations():
[f for f in os.listdir(migrations_dir) if f.endswith(".sql")],
)
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
# Create migrations table if it doesn't exist
cursor.execute("""
CREATE TABLE IF NOT EXISTS migrations (
@ -90,9 +86,8 @@ def run_migrations():
continue
# Read and execute migration file
migration_sql = pathlib.Path(
os.path.join(migrations_dir, migration_file),
).read_text()
migration_sql = pathlib.Path(migrations_dir) / migration_file
migration_sql = migration_sql.read_text()
try:
# Split the migration file into individual statements
statements = re.split(r";\s*$", migration_sql, flags=re.MULTILINE)
@ -112,4 +107,4 @@ def run_migrations():
logger.error(f"Error applying migration {migration_file}: {e}")
raise
logger.debug("All migrations completed.")
logger.success("All database migrations completed.")

View file

View file

@ -1,6 +1,6 @@
services:
core:
image: ghcr.io/wlinator/luminara:2 # Remove "ghcr.io/" if you want to use the Docker Hub image.
image: ghcr.io/wlinator/luminara:3 # Remove "ghcr.io/" if you want to use the Docker Hub image.
container_name: lumi-core
restart: always
env_file:

0
handlers/__init__.py Normal file
View file

111
handlers/error.py Normal file
View file

@ -0,0 +1,111 @@
import sys
import traceback
from typing import Any
import discord
from discord import app_commands
from discord.ext import commands
from loguru import logger
from lib import exceptions
from lib.const import CONST
error_map: dict[type[Exception], str] = {
commands.BotMissingPermissions: CONST.STRINGS["error_bot_missing_permissions_description"],
commands.MissingPermissions: CONST.STRINGS["error_missing_permissions_description"],
commands.NoPrivateMessage: CONST.STRINGS["error_no_private_message_description"],
commands.NotOwner: CONST.STRINGS["error_not_owner_unknown"],
commands.PrivateMessageOnly: CONST.STRINGS["error_private_message_only_description"],
exceptions.BirthdaysDisabled: CONST.STRINGS["error_birthdays_disabled_description"],
}
async def on_error(event: str, *args: Any, **kwargs: Any) -> None:
logger.exception(
f"on_error INFO: errors.event.{event} | '*args': {args} | '**kwargs': {kwargs}",
)
logger.exception(f"on_error EXCEPTION: {sys.exc_info()}")
traceback.print_exc()
async def log_command_error(
user_name: str,
command_name: str | None,
guild_id: int | None,
error: commands.CommandError | commands.CheckFailure | app_commands.AppCommandError,
command_type: str,
) -> None:
if isinstance(error, commands.NotOwner | exceptions.Blacklisted):
return
log_msg = f"{user_name} executed {command_type}{command_name or 'Unknown'}"
log_msg += " in DMs" if guild_id is None else f" | guild: {guild_id}"
if CONST.INSTANCE == "dev":
logger.exception(
f"{log_msg} | {error.__module__}.{error.__class__.__name__} | {''.join(traceback.format_exception(type(error), error, error.__traceback__))}",
)
else:
logger.error(f"{log_msg} | {error.__module__}.{error.__class__.__name__}")
class ErrorHandler(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
async def cog_load(self):
tree = self.bot.tree
self._old_tree_error = tree.on_error
tree.on_error = self.on_app_command_error
async def cog_unload(self):
tree = self.bot.tree
tree.on_error = self._old_tree_error
async def on_app_command_error(
self,
interaction: discord.Interaction,
error: app_commands.AppCommandError,
) -> None:
if isinstance(error, commands.CommandNotFound | exceptions.Blacklisted):
return
await log_command_error(
user_name=interaction.user.name,
command_name=interaction.command.qualified_name if interaction.command else None,
guild_id=interaction.guild.id if interaction.guild else None,
error=error,
command_type="/",
)
error_msg = error_map.get(type(error), str(error))
await interaction.response.send_message(content=f"❌ **{interaction.user.name}** {error_msg}", ephemeral=True)
@commands.Cog.listener()
async def on_command_error(
self,
ctx: commands.Context[commands.Bot],
error: commands.CommandError | commands.CheckFailure,
) -> None:
if isinstance(error, commands.CommandNotFound | exceptions.Blacklisted):
return
await log_command_error(
user_name=ctx.author.name,
command_name=ctx.command.qualified_name if ctx.command else None,
guild_id=ctx.guild.id if ctx.guild else None,
error=error,
command_type=".",
)
error_msg = error_map.get(type(error), str(error))
await ctx.send(content=f"❌ **{ctx.author.name}** {error_msg}")
@commands.Cog.listener()
async def on_error(self, event: str, *args: Any, **kwargs: Any) -> None:
await on_error(event, *args, **kwargs)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(ErrorHandler(bot))

View file

@ -1,121 +0,0 @@
import sys
import traceback
from discord.ext import commands
from discord.ext.commands import Cog
from loguru import logger
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions import LumiExceptions
async def on_command_error(ctx, error):
if isinstance(error, (commands.CommandNotFound, LumiExceptions.Blacklisted)):
return
author_text = None
description = None
footer_text = None
ephemeral = False
if isinstance(error, commands.MissingRequiredArgument):
author_text = CONST.STRINGS["error_bad_argument_author"]
description = CONST.STRINGS["error_bad_argument_description"].format(str(error))
elif isinstance(error, commands.BadArgument):
author_text = CONST.STRINGS["error_bad_argument_author"]
description = CONST.STRINGS["error_bad_argument_description"].format(str(error))
elif isinstance(error, commands.BotMissingPermissions):
author_text = CONST.STRINGS["error_bot_missing_permissions_author"]
description = CONST.STRINGS["error_bot_missing_permissions_description"]
elif isinstance(error, commands.CommandOnCooldown):
author_text = CONST.STRINGS["error_command_cooldown_author"]
description = CONST.STRINGS["error_command_cooldown_description"].format(
int(error.retry_after // 60),
int(error.retry_after % 60),
)
ephemeral = True
elif isinstance(error, commands.MissingPermissions):
author_text = CONST.STRINGS["error_missing_permissions_author"]
description = CONST.STRINGS["error_missing_permissions_description"]
elif isinstance(error, commands.NoPrivateMessage):
author_text = CONST.STRINGS["error_no_private_message_author"]
description = CONST.STRINGS["error_no_private_message_description"]
elif isinstance(error, commands.NotOwner):
author_text = CONST.STRINGS["error_not_owner_author"]
description = CONST.STRINGS["error_not_owner_description"]
elif isinstance(error, commands.PrivateMessageOnly):
author_text = CONST.STRINGS["error_private_message_only_author"]
description = CONST.STRINGS["error_private_message_only_description"]
elif isinstance(error, LumiExceptions.BirthdaysDisabled):
author_text = CONST.STRINGS["error_birthdays_disabled_author"]
description = CONST.STRINGS["error_birthdays_disabled_description"]
footer_text = CONST.STRINGS["error_birthdays_disabled_footer"]
elif isinstance(error, LumiExceptions.LumiException):
author_text = CONST.STRINGS["error_lumi_exception_author"]
description = CONST.STRINGS["error_lumi_exception_description"].format(
str(error),
)
else:
author_text = CONST.STRINGS["error_unknown_error_author"]
description = CONST.STRINGS["error_unknown_error_description"]
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=author_text,
description=description,
footer_text=footer_text,
),
ephemeral=ephemeral,
)
async def on_error(event: str, *args, **kwargs) -> None:
logger.exception(
f"on_error INFO: errors.event.{event} | '*args': {args} | '**kwargs': {kwargs}",
)
logger.exception(f"on_error EXCEPTION: {sys.exc_info()}")
traceback.print_exc()
class ErrorListener(Cog):
def __init__(self, client):
self.client = client
@staticmethod
async def log_command_error(ctx, error, command_type):
log_msg = (
f"{ctx.author.name} executed {command_type}{ctx.command.qualified_name}"
)
log_msg += " in DMs" if ctx.guild is None else f" | guild: {ctx.guild.name} "
logger.warning(f"{log_msg} | FAILED: {error}")
@Cog.listener()
async def on_command_error(self, ctx, error) -> None:
await on_command_error(ctx, error)
await self.log_command_error(ctx, error, ".")
@Cog.listener()
async def on_application_command_error(self, ctx, error) -> None:
await on_command_error(ctx, error)
await self.log_command_error(ctx, error, "/")
@Cog.listener()
async def on_error(self, event: str, *args, **kwargs) -> None:
await on_error(event, *args, **kwargs)
def setup(client):
client.add_cog(ErrorListener(client))

98
handlers/event.py Normal file
View file

@ -0,0 +1,98 @@
import discord
from discord.ext import commands
from loguru import logger
from services.blacklist_service import BlacklistUserService
from services.config_service import GuildConfig
from ui.config import create_boost_embed, create_greet_embed
class EventHandler(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
if BlacklistUserService.is_user_blacklisted(member.id):
return
config = GuildConfig(member.guild.id)
if not config.welcome_channel_id:
return
embed = create_greet_embed(
user_name=member.name,
user_avatar_url=member.display_avatar.url,
guild_name=member.guild.name,
template=config.welcome_message,
)
try:
channel = member.guild.get_channel(config.welcome_channel_id)
if isinstance(channel, discord.TextChannel):
await channel.send(
embed=embed,
content=member.mention,
)
except Exception as e:
logger.warning(
f"Greet message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}",
)
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member):
if BlacklistUserService.is_user_blacklisted(after.id):
return
if before.premium_since is None and after.premium_since is not None:
await self.on_nitro_boost(after)
@staticmethod
async def on_nitro_boost(member: discord.Member):
config = GuildConfig(member.guild.id)
if not config.boost_channel_id:
return
embed = create_boost_embed(
user_name=member.name,
user_avatar_url=member.display_avatar.url,
boost_count=member.guild.premium_subscription_count,
template=config.boost_message,
image_url=config.boost_image_url,
)
try:
channel = member.guild.get_channel(config.boost_channel_id)
if isinstance(channel, discord.TextChannel):
await channel.send(
embed=embed,
content=member.mention,
)
except Exception as e:
logger.warning(
f"Boost message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}",
)
@commands.Cog.listener()
async def on_command_completion(self, ctx: commands.Context[commands.Bot]) -> None:
log_msg = f"{ctx.author.name} executed .{ctx.command.qualified_name if ctx.command else 'Unknown'}"
if ctx.guild is not None:
logger.debug(f"{log_msg} | guild: {ctx.guild.name} ")
else:
logger.debug(f"{log_msg} in DMs")
@commands.Cog.listener()
async def on_application_command_completion(self, ctx: discord.Interaction) -> None:
log_msg = f"{ctx.user.name} executed /{ctx.command.qualified_name if ctx.command else 'Unknown'}"
if ctx.guild is not None:
logger.debug(f"{log_msg} | guild: {ctx.guild.name} ")
else:
logger.debug(f"{log_msg} in DMs")
async def setup(bot: commands.Bot):
await bot.add_cog(EventHandler(bot))

View file

@ -1,86 +0,0 @@
from discord.ext.commands import Cog
from loguru import logger
from modules.config import c_boost, c_greet
from services.blacklist_service import BlacklistUserService
from services.config_service import GuildConfig
class EventHandler(Cog):
def __init__(self, client):
self.client = client
@Cog.listener()
async def on_member_join(self, member):
if BlacklistUserService.is_user_blacklisted(member.id):
return
config = GuildConfig(member.guild.id)
if not config.welcome_channel_id:
return
embed = c_greet.create_greet_embed(member, config.welcome_message)
try:
await member.guild.get_channel(config.welcome_channel_id).send(
embed=embed,
content=member.mention,
)
except Exception as e:
logger.warning(
f"Greet message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}",
)
@Cog.listener()
async def on_member_update(self, before, after):
if BlacklistUserService.is_user_blacklisted(after.id):
return
if before.premium_since is None and after.premium_since is not None:
await self.on_nitro_boost(after)
@staticmethod
async def on_nitro_boost(member):
config = GuildConfig(member.guild.id)
if not config.boost_channel_id:
return
embed = c_boost.create_boost_embed(
member,
config.boost_message,
config.boost_image_url,
)
try:
await member.guild.get_channel(config.boost_channel_id).send(
embed=embed,
content=member.mention,
)
except Exception as e:
logger.warning(
f"Boost message not sent in '{member.guild.name}'. Channel ID may be invalid. {e}",
)
@Cog.listener()
async def on_command_completion(self, ctx) -> None:
log_msg = f"{ctx.author.name} executed .{ctx.command.qualified_name}"
if ctx.guild is not None:
logger.debug(f"{log_msg} | guild: {ctx.guild.name} ")
else:
logger.debug(f"{log_msg} in DMs")
@Cog.listener()
async def on_application_command_completion(self, ctx) -> None:
log_msg = f"{ctx.author.name} executed /{ctx.command.qualified_name}"
if ctx.guild is not None:
logger.debug(f"{log_msg} | guild: {ctx.guild.name} ")
else:
logger.debug(f"{log_msg} in DMs")
def setup(client):
client.add_cog(EventHandler(client))

View file

@ -1,9 +1,11 @@
import contextlib
from typing import Any
from discord import Message
from discord.ext.commands import Cog
from discord.ext import commands
from loguru import logger
from lib.client import Luminara
from services.blacklist_service import BlacklistUserService
from services.reactions_service import CustomReactionsService
@ -13,8 +15,8 @@ class ReactionHandler:
Handles reactions to messages based on predefined triggers and responses.
"""
def __init__(self, client, message: Message) -> None:
self.client = client
def __init__(self, bot: Luminara, message: Message) -> None:
self.bot = bot
self.message: Message = message
self.content: str = self.message.content.lower()
self.reaction_service = CustomReactionsService()
@ -43,7 +45,7 @@ class ReactionHandler:
int(data["id"]),
)
async def try_respond(self, data) -> bool:
async def try_respond(self, data: dict[str, Any]) -> bool:
"""
Tries to respond to the message.
"""
@ -53,23 +55,23 @@ class ReactionHandler:
return True
return False
async def try_react(self, data) -> bool:
async def try_react(self, data: dict[str, Any]) -> bool:
"""
Tries to react to the message.
"""
if emoji_id := data.get("emoji_id"):
with contextlib.suppress(Exception):
if emoji := self.client.get_emoji(emoji_id):
if emoji := self.bot.get_emoji(emoji_id):
await self.message.add_reaction(emoji)
return True
return False
class ReactionListener(Cog):
def __init__(self, client) -> None:
self.client = client
class ReactionListener(commands.Cog):
def __init__(self, bot: Luminara) -> None:
self.bot = bot
@Cog.listener("on_message")
@commands.Cog.listener("on_message")
async def reaction_listener(self, message: Message) -> None:
"""
Listens for new messages and processes them if the author is not a bot and not blacklisted.
@ -79,8 +81,8 @@ class ReactionListener(Cog):
if not message.author.bot and not BlacklistUserService.is_user_blacklisted(
message.author.id,
):
await ReactionHandler(self.client, message).run_checks()
await ReactionHandler(self.bot, message).run_checks()
def setup(client) -> None:
client.add_cog(ReactionListener(client))
async def setup(bot: Luminara) -> None:
await bot.add_cog(ReactionListener(bot))

View file

@ -2,27 +2,26 @@ import asyncio
import contextlib
import random
import time
from typing import Optional
import discord
from discord.ext import commands
from discord.ext.commands import TextChannelConverter
from loguru import logger
from Client import LumiBot
from lib import formatter
from lib.constants import CONST
import lib.format
from lib.client import Luminara
from lib.const import CONST
from services.blacklist_service import BlacklistUserService
from services.config_service import GuildConfig
from services.xp_service import XpRewardService, XpService
class XPHandler:
def __init__(self, client: LumiBot, message: discord.Message) -> None:
def __init__(self, client: Luminara, message: discord.Message) -> None:
"""
Initializes the XPHandler with the given client and message.
Args:
client (LumiBot): The bot client.
client (Luminara): The bot client.
message (discord.Message): The message object.
"""
self.client = client
@ -33,7 +32,7 @@ class XPHandler:
self.author.id,
self.guild.id if self.guild else 0,
)
self.guild_conf: Optional[GuildConfig] = None
self.guild_conf: GuildConfig | None = None
def process(self) -> bool:
"""
@ -72,13 +71,13 @@ class XPHandler:
_xp: XpService = self.xp_conf
_gd: GuildConfig = GuildConfig(self.guild.id)
level_message: Optional[str] = None # Initialize level_message
level_message: str | None = None # Initialize level_message
if isinstance(self.author, discord.Member):
level_message = await self.get_level_message(_gd, _xp, self.author)
if level_message:
level_channel: Optional[discord.TextChannel] = await self.get_level_channel(
level_channel: discord.TextChannel | None = await self.get_level_channel(
self.message,
_gd,
)
@ -102,29 +101,29 @@ class XPHandler:
reason: str = "Automated Level Reward"
if role := self.guild.get_role(role_id):
with contextlib.suppress(
discord.Forbidden,
discord.NotFound,
discord.HTTPException,
):
try:
if isinstance(self.author, discord.Member):
await self.author.add_roles(role, reason=reason)
except (discord.Forbidden, discord.NotFound, discord.HTTPException) as e:
logger.error(f"Failed to add role {role_id} to {self.author.id}: {e}")
previous, replace = _rew.should_replace_previous_reward(_xp.level)
if replace and isinstance(self.author, discord.Member):
if role := self.guild.get_role(previous or role_id):
with contextlib.suppress(
discord.Forbidden,
discord.NotFound,
discord.HTTPException,
if (
replace
and isinstance(self.author, discord.Member)
and (role := self.guild.get_role(previous or role_id))
):
try:
await self.author.remove_roles(role, reason=reason)
except (discord.Forbidden, discord.NotFound, discord.HTTPException) as e:
logger.error(f"Failed to replace role {previous} with {role_id} from {self.author.id}: {e}")
async def get_level_channel(
self,
message: discord.Message,
guild_config: GuildConfig,
) -> Optional[discord.TextChannel]:
) -> discord.TextChannel | None:
"""
Retrieves the level up notification channel for the guild.
@ -139,7 +138,7 @@ class XPHandler:
context = await self.client.get_context(message)
with contextlib.suppress(commands.BadArgument, commands.CommandError):
return await TextChannelConverter().convert(
return await commands.TextChannelConverter().convert(
context,
str(guild_config.level_channel_id),
)
@ -150,7 +149,7 @@ class XPHandler:
guild_config: GuildConfig,
level_config: XpService,
author: discord.Member,
) -> Optional[str]:
) -> str | None:
"""
Retrieves the level up message for the user.
@ -174,13 +173,14 @@ class XPHandler:
author,
)
else:
level_message = formatter.template(
level_message = lib.format.template(
guild_config.level_message,
author.name,
level_config.level,
)
case _:
raise ValueError("Invalid level message type")
msg = "Invalid level message type"
raise ValueError(msg)
return level_message
@ -210,8 +210,8 @@ class XPHandler:
Returns:
str: The whimsical level up message.
"""
level_range: Optional[str] = None
for key in CONST.LEVEL_MESSAGES.keys():
level_range: str | None = None
for key in CONST.LEVEL_MESSAGES:
start, end = map(int, key.split("-"))
if start <= level <= end:
level_range = key
@ -228,14 +228,14 @@ class XPHandler:
class XpListener(commands.Cog):
def __init__(self, client: LumiBot) -> None:
def __init__(self, client: Luminara) -> None:
"""
Initializes the XpListener with the given client.
Args:
client (LumiBot): The bot client.
client (Luminara): The bot client.
"""
self.client: LumiBot = client
self.client: Luminara = client
@commands.Cog.listener("on_message")
async def xp_listener(self, message: discord.Message) -> None:
@ -259,5 +259,5 @@ class XpListener(commands.Cog):
)
def setup(client: LumiBot) -> None:
client.add_cog(XpListener(client))
async def setup(client: Luminara) -> None:
await client.add_cog(XpListener(client))

0
lib/__init__.py Normal file
View file

View file

@ -1,7 +1,7 @@
import discord
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
from lib.const import CONST
from lib.exceptions import LumiException
async def async_actionable(

View file

@ -1,24 +1,23 @@
from typing import Optional
import discord
from discord.ext.commands import TextChannelConverter, UserConverter
from discord.ext import commands
from loguru import logger
from modules.moderation.utils.case_embed import create_case_embed
from services.moderation.case_service import CaseService
from services.moderation.modlog_service import ModLogService
from lib.exceptions import LumiException
from services.case_service import CaseService
from services.modlog_service import ModLogService
from ui.cases import create_case_embed
case_service = CaseService()
modlog_service = ModLogService()
async def create_case(
ctx,
ctx: commands.Context[commands.Bot],
target: discord.User,
action_type: str,
reason: Optional[str] = None,
duration: Optional[int] = None,
expires_at: Optional[str] = None,
reason: str | None = None,
duration: int | None = None,
expires_at: str | None = None,
):
"""
Creates a new moderation case and logs it to the modlog channel if configured.
@ -43,6 +42,10 @@ async def create_case(
3. If a modlog channel is configured, it sends an embed with the case details to that channel.
4. If the embed is successfully sent to the modlog channel, it updates the case with the message ID for later edits.
"""
if not ctx.guild:
raise LumiException
guild_id = ctx.guild.id
moderator_id = ctx.author.id
target_id = target.id
@ -63,7 +66,7 @@ async def create_case(
if mod_log_channel_id := modlog_service.fetch_modlog_channel_id(guild_id):
try:
mod_log_channel = await TextChannelConverter().convert(
mod_log_channel = await commands.TextChannelConverter().convert(
ctx,
str(mod_log_channel_id),
)
@ -90,7 +93,7 @@ async def create_case(
async def edit_case_modlog(
ctx,
ctx: commands.Context[commands.Bot],
guild_id: int,
case_number: int,
new_reason: str,
@ -110,7 +113,8 @@ async def edit_case_modlog(
"""
case = case_service.fetch_case_by_guild_and_number(guild_id, case_number)
if not case:
raise ValueError(f"Case {case_number} not found in guild {guild_id}")
msg = f"Case {case_number} not found in guild {guild_id}"
raise ValueError(msg)
modlog_message_id = case.get("modlog_message_id")
if not modlog_message_id:
@ -121,12 +125,12 @@ async def edit_case_modlog(
return False
try:
mod_log_channel = await TextChannelConverter().convert(
mod_log_channel = await commands.TextChannelConverter().convert(
ctx,
str(mod_log_channel_id),
)
message = await mod_log_channel.fetch_message(modlog_message_id)
target = await UserConverter().convert(ctx, str(case["target_id"]))
target = await commands.UserConverter().convert(ctx, str(case["target_id"]))
updated_embed: discord.Embed = create_case_embed(
ctx=ctx,

View file

@ -1,19 +1,19 @@
from discord.ext import commands
import discord
from discord import app_commands
from lib.exceptions import LumiExceptions
from lib.exceptions import BirthdaysDisabled
from services.config_service import GuildConfig
def birthdays_enabled():
async def predicate(ctx):
if ctx.guild is None:
async def predicate(interaction: discord.Interaction) -> bool:
if interaction.guild is None:
return True
guild_config = GuildConfig(ctx.guild.id)
if not guild_config.birthday_channel_id:
raise LumiExceptions.BirthdaysDisabled
guild_config = GuildConfig(interaction.guild.id)
if guild_config.birthday_channel_id is None:
raise BirthdaysDisabled
return True
return commands.check(predicate)
return app_commands.check(predicate)

70
lib/client.py Normal file
View file

@ -0,0 +1,70 @@
import asyncio
import os
import platform
from typing import Any
import discord
from discord.ext import commands
from loguru import logger
from db.database import run_migrations
from lib.const import CONST
from lib.loader import CogLoader
class Luminara(commands.Bot):
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.is_shutting_down: bool = False
self.setup_task: asyncio.Task[None] = asyncio.create_task(self.setup())
async def on_ready(self) -> None:
logger.success(f"{CONST.TITLE} v{CONST.VERSION}")
logger.success(f"Logged in with ID {self.user.id if self.user else 'Unknown'}")
logger.success(f"discord.py API version: {discord.__version__}")
logger.success(f"Python version: {platform.python_version()}")
logger.success(f"Running on: {platform.system()} {platform.release()} ({os.name})")
if self.owner_ids:
for owner in self.owner_ids:
logger.info(f"Added bot administrator: {owner}")
if not self.setup_task.done():
await self.setup_task
async def setup(self) -> None:
try:
run_migrations()
except Exception as e:
logger.error(f"Failed to setup: {e}")
await self.shutdown()
await self.load_cogs()
async def load_cogs(self) -> None:
await CogLoader.setup(bot=self)
@commands.Cog.listener()
async def on_disconnect(self) -> None:
logger.warning("Disconnected from Discord.")
async def shutdown(self) -> None:
if self.is_shutting_down:
logger.info("Shutdown already in progress. Exiting.")
return
self.is_shutting_down = True
logger.info("Shutting down...")
await self.close()
if tasks := [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]:
logger.debug(f"Cancelling {len(tasks)} outstanding tasks.")
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
logger.debug("All tasks cancelled.")
logger.info("Shutdown complete.")

127
lib/const.py Normal file
View file

@ -0,0 +1,127 @@
import json
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any, Final
import yaml
class Parser:
"""Internal parses class. Not intended to be used outside of this module."""
def __init__(self):
self._cache: dict[str, Any] = {}
def read_s(self) -> dict[str, Any]:
if "settings" not in self._cache:
self._cache["settings"] = self._read_file("settings.yaml", yaml.safe_load)
return self._cache["settings"]
def read_json(self, path: str) -> dict[str, Any]:
cache_key = f"json_{path}"
if cache_key not in self._cache:
self._cache[cache_key] = self._read_file(f"locales/{path}.json", json.load)
return self._cache[cache_key]
def _read_file(self, file_path: str, load_func: Callable[[Any], dict[str, Any]]) -> dict[str, Any]:
with Path(file_path).open() as file:
return load_func(file)
class Constants:
_p: Final = Parser()
_s: Final = Parser().read_s()
# bot credentials
TOKEN: Final[str | None] = os.environ.get("TOKEN")
INSTANCE: Final[str | None] = os.environ.get("INSTANCE")
OWNER_IDS: Final[set[int]] = {int(oid) for oid in os.environ.get("OWNER_IDS", "").split(",") if oid.strip()}
XP_GAIN_PER_MESSAGE: Final[int] = int(os.environ.get("XP_GAIN_PER_MESSAGE", 1))
XP_GAIN_COOLDOWN: Final[int] = int(os.environ.get("XP_GAIN_COOLDOWN", 8))
DBX_TOKEN: Final[str | None] = os.environ.get("DBX_OAUTH2_REFRESH_TOKEN")
DBX_APP_KEY: Final[str | None] = os.environ.get("DBX_APP_KEY")
DBX_APP_SECRET: Final[str | None] = os.environ.get("DBX_APP_SECRET")
MARIADB_USER: Final[str | None] = os.environ.get("MARIADB_USER")
MARIADB_PASSWORD: Final[str | None] = os.environ.get("MARIADB_PASSWORD")
MARIADB_ROOT_PASSWORD: Final[str | None] = os.environ.get("MARIADB_ROOT_PASSWORD")
MARIADB_DATABASE: Final[str | None] = os.environ.get("MARIADB_DATABASE")
# metadata
TITLE: Final[str] = _s["info"]["title"]
AUTHOR: Final[str] = _s["info"]["author"]
LICENSE: Final[str] = _s["info"]["license"]
VERSION: Final[str] = _s["info"]["version"]
REPO_URL: Final[str] = _s["info"]["repository_url"]
INVITE_URL: Final[str] = _s["info"]["invite_url"]
# loguru
LOG_LEVEL: Final[str] = _s["logs"]["level"] or "DEBUG"
LOG_FORMAT: Final[str] = _s["logs"]["format"]
# cogs
COG_IGNORE_LIST: Final[set[str]] = set(_s["cogs"]["ignore"]) if _s["cogs"]["ignore"] else set()
# images
ALLOWED_IMAGE_EXTENSIONS: Final[list[str]] = _s["images"]["allowed_image_extensions"]
BIRTHDAY_GIF_URL: Final[str] = _s["images"]["birthday_gif_url"]
# colors
COLOR_DEFAULT: Final[int] = _s["colors"]["color_default"]
COLOR_WARNING: Final[int] = _s["colors"]["color_warning"]
COLOR_ERROR: Final[int] = _s["colors"]["color_error"]
# economy
DAILY_REWARD: Final[int] = _s["economy"]["daily_reward"]
BLACKJACK_MULTIPLIER: Final[float] = _s["economy"]["blackjack_multiplier"]
BLACKJACK_HIT_EMOJI: Final[str] = _s["economy"]["blackjack_hit_emoji"]
BLACKJACK_STAND_EMOJI: Final[str] = _s["economy"]["blackjack_stand_emoji"]
SLOTS_MULTIPLIERS: Final[dict[str, float]] = _s["economy"]["slots_multipliers"]
# art from git repository
_fetch_url: Final[str] = _s["art"]["fetch_url"]
LUMI_LOGO_OPAQUE: Final[str] = _fetch_url + _s["art"]["logo"]["opaque"]
LUMI_LOGO_TRANSPARENT: Final[str] = _fetch_url + _s["art"]["logo"]["transparent"]
BOOST_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["boost"]
CHECK_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["check"]
CROSS_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["cross"]
EXCLAIM_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["exclaim"]
INFO_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["info"]
HAMMER_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["hammer"]
MONEY_BAG_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["money_bag"]
MONEY_COINS_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["money_coins"]
QUESTION_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["question"]
STREAK_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak"]
STREAK_BRONZE_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_bronze"]
STREAK_GOLD_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_gold"]
STREAK_SILVER_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["streak_silver"]
WARNING_ICON: Final[str] = _fetch_url + _s["art"]["icons"]["warning"]
# art from imgur
FLOWERS_ART: Final[str] = _s["art"]["juicybblue"]["flowers"]
TEAPOT_ART: Final[str] = _s["art"]["juicybblue"]["teapot"]
MUFFIN_ART: Final[str] = _s["art"]["juicybblue"]["muffin"]
CLOUD_ART: Final[str] = _s["art"]["other"]["cloud"]
TROPHY_ART: Final[str] = _s["art"]["other"]["trophy"]
# emotes
EMOTES_SERVER_ID: Final[int] = _s["emotes"]["guild_id"]
EMOTE_IDS: Final[dict[str, int]] = _s["emotes"]["emote_ids"]
# introductions (currently only usable in ONE guild)
INTRODUCTIONS_GUILD_ID: Final[int] = _s["introductions"]["intro_guild_id"]
INTRODUCTIONS_CHANNEL_ID: Final[int] = _s["introductions"]["intro_channel_id"]
INTRODUCTIONS_QUESTION_MAPPING: Final[dict[str, str]] = _s["introductions"]["intro_question_mapping"]
# Reponse strings
# TODO: Implement switching between languages
STRINGS: Final = _p.read_json("strings.en-US")
LEVEL_MESSAGES: Final = _p.read_json("levels.en-US")
_bday: Final = _p.read_json("bdays.en-US")
BIRTHDAY_MESSAGES: Final[list[str]] = _bday["birthday_messages"]
BIRTHDAY_MONTHS: Final[list[str]] = _bday["months"]
CONST = Constants()

View file

@ -1,120 +0,0 @@
import os
from typing import Optional, Set, List, Dict
import yaml
import json
from functools import lru_cache
class _parser:
"""Internal parser class. Not intended for direct use outside this module."""
@lru_cache(maxsize=1024)
def read_yaml(self, path):
return self._read_file(f"settings/{path}.yaml", yaml.safe_load)
@lru_cache(maxsize=1024)
def read_json(self, path):
return self._read_file(f"settings/{path}.json", json.load)
def _read_file(self, file_path, load_func):
with open(file_path) as file:
return load_func(file)
class Constants:
_p = _parser()
_settings = _p.read_yaml("settings")
# bot credentials (.env file)
TOKEN: Optional[str] = os.environ.get("TOKEN")
INSTANCE: Optional[str] = os.environ.get("INSTANCE")
XP_GAIN_PER_MESSAGE: int = int(os.environ.get("XP_GAIN_PER_MESSAGE", 1))
XP_GAIN_COOLDOWN: int = int(os.environ.get("XP_GAIN_COOLDOWN", 8))
DBX_TOKEN: Optional[str] = os.environ.get("DBX_OAUTH2_REFRESH_TOKEN")
DBX_APP_KEY: Optional[str] = os.environ.get("DBX_APP_KEY")
DBX_APP_SECRET: Optional[str] = os.environ.get("DBX_APP_SECRET")
MARIADB_USER: Optional[str] = os.environ.get("MARIADB_USER")
MARIADB_PASSWORD: Optional[str] = os.environ.get("MARIADB_PASSWORD")
MARIADB_ROOT_PASSWORD: Optional[str] = os.environ.get("MARIADB_ROOT_PASSWORD")
MARIADB_DATABASE: Optional[str] = os.environ.get("MARIADB_DATABASE")
OWNER_IDS: Optional[Set[int]] = (
{int(id.strip()) for id in os.environ.get("OWNER_IDS", "").split(",") if id}
if "OWNER_IDS" in os.environ
else None
)
# metadata
TITLE: str = _settings["info"]["title"]
AUTHOR: str = _settings["info"]["author"]
LICENSE: str = _settings["info"]["license"]
VERSION: str = _settings["info"]["version"]
REPO_URL: str = _settings["info"]["repository_url"]
# images
ALLOWED_IMAGE_EXTENSIONS: List[str] = _settings["images"][
"allowed_image_extensions"
]
BIRTHDAY_GIF_URL: str = _settings["images"]["birthday_gif_url"]
# colors
COLOR_DEFAULT: int = _settings["colors"]["color_default"]
COLOR_WARNING: int = _settings["colors"]["color_warning"]
COLOR_ERROR: int = _settings["colors"]["color_error"]
# economy
DAILY_REWARD: int = _settings["economy"]["daily_reward"]
BLACKJACK_MULTIPLIER: float = _settings["economy"]["blackjack_multiplier"]
BLACKJACK_HIT_EMOJI: str = _settings["economy"]["blackjack_hit_emoji"]
BLACKJACK_STAND_EMOJI: str = _settings["economy"]["blackjack_stand_emoji"]
SLOTS_MULTIPLIERS: Dict[str, float] = _settings["economy"]["slots_multipliers"]
# art from git repository
_fetch_url: str = _settings["art"]["fetch_url"]
LUMI_LOGO_OPAQUE: str = _fetch_url + _settings["art"]["logo"]["opaque"]
LUMI_LOGO_TRANSPARENT: str = _fetch_url + _settings["art"]["logo"]["transparent"]
BOOST_ICON: str = _fetch_url + _settings["art"]["icons"]["boost"]
CHECK_ICON: str = _fetch_url + _settings["art"]["icons"]["check"]
CROSS_ICON: str = _fetch_url + _settings["art"]["icons"]["cross"]
EXCLAIM_ICON: str = _fetch_url + _settings["art"]["icons"]["exclaim"]
HAMMER_ICON: str = _fetch_url + _settings["art"]["icons"]["hammer"]
MONEY_BAG_ICON: str = _fetch_url + _settings["art"]["icons"]["money_bag"]
MONEY_COINS_ICON: str = _fetch_url + _settings["art"]["icons"]["money_coins"]
QUESTION_ICON: str = _fetch_url + _settings["art"]["icons"]["question"]
STREAK_ICON: str = _fetch_url + _settings["art"]["icons"]["streak"]
STREAK_BRONZE_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_bronze"]
STREAK_GOLD_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_gold"]
STREAK_SILVER_ICON: str = _fetch_url + _settings["art"]["icons"]["streak_silver"]
WARNING_ICON: str = _fetch_url + _settings["art"]["icons"]["warning"]
# art from imgur
FLOWERS_ART: str = _settings["art"]["juicybblue"]["flowers"]
TEAPOT_ART: str = _settings["art"]["juicybblue"]["teapot"]
MUFFIN_ART: str = _settings["art"]["juicybblue"]["muffin"]
CLOUD_ART: str = _settings["art"]["other"]["cloud"]
TROPHY_ART: str = _settings["art"]["other"]["trophy"]
# emotes
EMOTES_SERVER_ID: int = _settings["emotes"]["guild_id"]
EMOTE_IDS: Dict[str, int] = _settings["emotes"]["emote_ids"]
# introductions (currently only usable in ONE guild)
INTRODUCTIONS_GUILD_ID: int = _settings["introductions"]["intro_guild_id"]
INTRODUCTIONS_CHANNEL_ID: int = _settings["introductions"]["intro_channel_id"]
INTRODUCTIONS_QUESTION_MAPPING: Dict[str, str] = _settings["introductions"][
"intro_question_mapping"
]
# Response strings
# TODO: Implement switching between languages
STRINGS = _p.read_json("responses/strings.en-US")
LEVEL_MESSAGES = _p.read_json("responses/levels.en-US")
# birthday messages
_bday = _p.read_json("responses/bdays.en-US")
BIRTHDAY_MESSAGES = _bday["birthday_messages"]
BIRTHDAY_MONTHS = _bday["months"]
CONST = Constants()

View file

@ -1,206 +0,0 @@
import datetime
import discord
from lib.constants import CONST
class EmbedBuilder:
@staticmethod
def create_embed(
ctx,
title=None,
author_text=None,
author_icon_url=None,
author_url=None,
description=None,
color=None,
footer_text=None,
footer_icon_url=None,
show_name=True,
image_url=None,
thumbnail_url=None,
timestamp=None,
hide_author=False,
hide_author_icon=False,
hide_timestamp=False,
):
if not hide_author:
if not author_text:
author_text = ctx.author.name
elif show_name:
description = f"**{ctx.author.name}** {description}"
if not hide_author_icon and not author_icon_url:
author_icon_url = ctx.author.display_avatar.url
if not footer_text:
footer_text = "Luminara"
if not footer_icon_url:
footer_icon_url = CONST.LUMI_LOGO_TRANSPARENT
embed = discord.Embed(
title=title,
description=description,
color=color or CONST.COLOR_DEFAULT,
)
if not hide_author:
embed.set_author(
name=author_text,
icon_url=None if hide_author_icon else author_icon_url,
url=author_url,
)
embed.set_footer(text=footer_text, icon_url=footer_icon_url)
if not hide_timestamp:
embed.timestamp = timestamp or datetime.datetime.now()
if image_url:
embed.set_image(url=image_url)
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
return embed
@staticmethod
def create_error_embed(
ctx,
title=None,
author_text=None,
author_icon_url=None,
author_url=None,
description=None,
footer_text=None,
show_name=True,
image_url=None,
thumbnail_url=None,
timestamp=None,
hide_author=False,
hide_author_icon=False,
hide_timestamp=False,
):
return EmbedBuilder.create_embed(
ctx,
title=title,
author_text=author_text,
author_icon_url=author_icon_url or CONST.CROSS_ICON,
author_url=author_url,
description=description,
color=CONST.COLOR_ERROR,
footer_text=footer_text,
footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT,
show_name=show_name,
image_url=image_url,
thumbnail_url=thumbnail_url,
timestamp=timestamp,
hide_author=hide_author,
hide_author_icon=hide_author_icon,
hide_timestamp=hide_timestamp,
)
@staticmethod
def create_success_embed(
ctx,
title=None,
author_text=None,
author_icon_url=None,
author_url=None,
description=None,
footer_text=None,
show_name=True,
image_url=None,
thumbnail_url=None,
timestamp=None,
hide_author=False,
hide_author_icon=False,
hide_timestamp=False,
):
return EmbedBuilder.create_embed(
ctx,
title=title,
author_text=author_text,
author_icon_url=author_icon_url or CONST.CHECK_ICON,
author_url=author_url,
description=description,
color=CONST.COLOR_DEFAULT,
footer_text=footer_text,
footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT,
show_name=show_name,
image_url=image_url,
thumbnail_url=thumbnail_url,
timestamp=timestamp,
hide_author=hide_author,
hide_author_icon=hide_author_icon,
hide_timestamp=hide_timestamp,
)
@staticmethod
def create_info_embed(
ctx,
title=None,
author_text=None,
author_icon_url=None,
author_url=None,
description=None,
footer_text=None,
show_name=True,
image_url=None,
thumbnail_url=None,
timestamp=None,
hide_author=False,
hide_author_icon=False,
hide_timestamp=False,
):
return EmbedBuilder.create_embed(
ctx,
title=title,
author_text=author_text,
author_icon_url=author_icon_url or CONST.EXCLAIM_ICON,
author_url=author_url,
description=description,
color=CONST.COLOR_DEFAULT,
footer_text=footer_text,
footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT,
show_name=show_name,
image_url=image_url,
thumbnail_url=thumbnail_url,
timestamp=timestamp,
hide_author=hide_author,
hide_author_icon=hide_author_icon,
hide_timestamp=hide_timestamp,
)
@staticmethod
def create_warning_embed(
ctx,
title=None,
author_text=None,
author_icon_url=None,
author_url=None,
description=None,
footer_text=None,
show_name=True,
image_url=None,
thumbnail_url=None,
timestamp=None,
hide_author=False,
hide_author_icon=False,
hide_timestamp=False,
):
return EmbedBuilder.create_embed(
ctx,
title=title,
author_text=author_text,
author_icon_url=author_icon_url or CONST.WARNING_ICON,
author_url=author_url,
description=description,
color=CONST.COLOR_WARNING,
footer_text=footer_text,
footer_icon_url=CONST.LUMI_LOGO_TRANSPARENT,
show_name=show_name,
image_url=image_url,
thumbnail_url=thumbnail_url,
timestamp=timestamp,
hide_author=hide_author,
hide_author_icon=hide_author_icon,
hide_timestamp=hide_timestamp,
)

36
lib/exceptions.py Normal file
View file

@ -0,0 +1,36 @@
from discord import app_commands
from discord.ext import commands
from lib.const import CONST
class BirthdaysDisabled(commands.CheckFailure, app_commands.CheckFailure):
"""
Raised when the birthdays module is disabled in ctx.guild.
"""
class LumiException(commands.CommandError, app_commands.AppCommandError):
"""
A generic exception to raise for quick error handling.
"""
def __init__(self, message: str = CONST.STRINGS["lumi_exception_generic"]):
self.message = message
super().__init__(message)
def __str__(self) -> str:
return self.message
class Blacklisted(commands.CommandError, app_commands.AppCommandError):
"""
Raised when a user is blacklisted.
"""
def __init__(self, message: str = CONST.STRINGS["lumi_exception_blacklisted"]):
self.message = message
super().__init__(message)
def __str__(self) -> str:
return self.message

View file

@ -1,31 +0,0 @@
from discord.ext import commands
from lib.constants import CONST
class BirthdaysDisabled(commands.CheckFailure):
"""
Raised when the birthdays module is disabled in ctx.guild.
"""
pass
class LumiException(commands.CommandError):
"""
A generic exception to raise for quick error handling.
"""
def __init__(self, message=CONST.STRINGS["lumi_exception_generic"]):
self.message = message
super().__init__(message)
class Blacklisted(commands.CommandError):
"""
Raised when a user is blacklisted.
"""
def __init__(self, message=CONST.STRINGS["lumi_exception_blacklisted"]):
self.message = message
super().__init__(message)

View file

@ -1,11 +1,13 @@
import inspect
import textwrap
from typing import Any
import discord
from discord.ext import commands
from pytimeparse import parse
from pytimeparse import parse # type: ignore
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
from lib import exceptions
from lib.const import CONST
from services.config_service import GuildConfig
@ -74,7 +76,7 @@ def format_case_number(case_number: int) -> str:
return f"{case_number:03d}" if case_number < 1000 else str(case_number)
def get_prefix(ctx: commands.Context) -> str:
def get_prefix(ctx: commands.Context[commands.Bot]) -> str:
"""
Attempts to retrieve the prefix for the given guild context.
@ -90,7 +92,7 @@ def get_prefix(ctx: commands.Context) -> str:
return "."
def get_invoked_name(ctx: commands.Context) -> str | None:
def get_invoked_name(ctx: commands.Context[commands.Bot]) -> str | None:
"""
Attempts to get the alias of the command used. If the user used a SlashCommand, return the command name.
@ -102,20 +104,24 @@ def get_invoked_name(ctx: commands.Context) -> str | None:
"""
try:
return ctx.invoked_with
except (discord.ApplicationCommandInvokeError, AttributeError):
except (discord.app_commands.CommandInvokeError, AttributeError):
return ctx.command.name if ctx.command else None
def format_duration_to_seconds(duration: str) -> int:
"""
Formats a duration in seconds to a human-readable string.
Converts a duration string to seconds. If the input is just an integer, it returns that integer as seconds.
"""
parsed_duration = parse(duration)
if duration.isdigit():
return int(duration)
if isinstance(parsed_duration, int):
return parsed_duration
else:
raise LumiException(CONST.STRINGS["error_invalid_duration"].format(duration))
try:
parsed_duration: int = parse(duration) # type: ignore
return max(0, parsed_duration)
except Exception as e:
raise exceptions.LumiException(CONST.STRINGS["error_invalid_duration"].format(duration)) from e
def format_seconds_to_duration_string(seconds: int) -> str:
@ -132,7 +138,61 @@ def format_seconds_to_duration_string(seconds: int) -> str:
if days > 0:
return f"{days}d{hours}h" if hours > 0 else f"{days}d"
elif hours > 0:
if hours > 0:
return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
else:
return f"{minutes}m"
def generate_usage(
command: commands.Command[Any, Any, Any],
flag_converter: type[commands.FlagConverter] | None = None,
) -> str:
"""
Generate a usage string for a command with flags.
Credit to https://github.com/allthingslinux/tux (thanks kaizen ;p)
Parameters
----------
command : commands.Command
The command for which to generate the usage string.
flag_converter : type[commands.FlagConverter]
The flag converter class for the command.
Returns
-------
str
The usage string for the command. Example: "ban [target] -[reason] -<silent>"
"""
# Get the name of the command
command_name = command.qualified_name
# Start the usage string with the command name
usage = f"{command_name}"
# Get the parameters of the command (excluding the `ctx` and `flags` parameters)
parameters: dict[str, commands.Parameter] = command.clean_params
flag_prefix = getattr(flag_converter, "__commands_flag_prefix__", "-")
flags: dict[str, commands.Flag] = flag_converter.get_flags() if flag_converter else {}
# Add non-flag arguments to the usage string
for param_name, param in parameters.items():
# Ignore these parameters
if param_name in ["ctx", "flags"]:
continue
# Determine if the parameter is required
is_required = param.default == inspect.Parameter.empty
# Add the parameter to the usage string with required or optional wrapping
usage += f" <{param_name}>" if is_required else f" [{param_name}]"
# Add flag arguments to the usage string
for flag_name, flag_obj in flags.items():
# Determine if the flag is required or optional
if flag_obj.required:
usage += f" {flag_prefix}<{flag_name}>"
else:
usage += f" {flag_prefix}[{flag_name}]"
return usage

180
lib/help.py Normal file
View file

@ -0,0 +1,180 @@
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import discord
from discord.ext import commands
from lib.const import CONST
from ui.embeds import Builder
class LuminaraHelp(commands.HelpCommand):
def __init__(self):
"""Initializes the LuminaraHelp command with necessary attributes."""
super().__init__(
command_attrs={
"help": "Lists all commands and sub-commands.",
"aliases": ["h"],
"usage": "$help <command> or <sub-command>",
},
)
async def _get_prefix(self) -> str:
"""
Dynamically fetches the prefix from the context or uses a default prefix constant.
Returns
-------
str
The prefix used to invoke the bot.
"""
return "."
def _embed_base(self, author: str, description: str | None = None) -> discord.Embed:
"""
Creates a base embed with uniform styling.
Parameters
----------
title : str
The title of the embed.
description : str | None
The description of the embed.
Returns
-------
discord.Embed
The created embed.
"""
return Builder.create_embed(
theme="info",
author_text=author,
description=description,
footer_text=CONST.STRINGS["help_footer"],
)
def _get_cog_groups(self) -> list[str]:
"""
Retrieves a list of cog groups from the 'modules' folder.
Returns
-------
list[str]
A list of cog groups.
"""
cog_groups = sorted(
[
d
for d in os.listdir("./modules")
if Path(f"./modules/{d}").is_dir() and d not in ("__pycache__", "admin")
],
)
if "moderation" in cog_groups:
cog_groups.remove("moderation")
cog_groups.insert(0, "moderation")
return cog_groups
async def send_bot_help(
self,
mapping: Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]],
) -> None:
"""
Sends an overview of all commands in a single embed, grouped by module.
Parameters
----------
mapping : Mapping[commands.Cog | None, list[commands.Command[Any, Any, Any]]]
The mapping of cogs to commands.
"""
embed = self._embed_base("Luminara Help Overview")
cog_groups = self._get_cog_groups()
for group in cog_groups:
group_commands: list[commands.Command[Any, Any, Any]] = []
for cog, commands_list in mapping.items():
if cog and commands_list and cog.__module__.startswith(f"modules.{group}"):
group_commands.extend(commands_list)
if group_commands:
command_list = ", ".join(f"`{c.name}`" for c in group_commands)
embed.add_field(name=group.capitalize(), value=command_list, inline=False)
await self.get_destination().send(embed=embed)
async def _add_command_help_fields(self, embed: discord.Embed, command: commands.Command[Any, Any, Any]) -> None:
"""
Adds fields with usage and alias information for a command to an embed.
Parameters
----------
embed : discord.Embed
The embed to which the fields will be added.
command : commands.Command[Any, Any, Any]
The command whose details are to be added.
"""
prefix = await self._get_prefix()
embed.add_field(
name="Usage",
value=f"`{prefix}{command.usage or 'No usage.'}`",
inline=False,
)
async def send_command_help(self, command: commands.Command[Any, Any, Any]) -> None:
"""
Sends a help message for a specific command.
Parameters
----------
command : commands.Command[Any, Any, Any]
The command for which the help message is to be sent.
"""
prefix = await self._get_prefix()
author = f"{prefix}{command.qualified_name}"
author += f" ({', '.join(command.aliases)})" if command.aliases else ""
embed = self._embed_base(
author=author,
description=f"> {command.help}" or "No description available.",
)
await self._add_command_help_fields(embed, command)
await self.get_destination().send(embed=embed)
async def send_group_help(self, group: commands.Group[Any, Any, Any]) -> None:
"""
Sends a help message for a specific command group.
Parameters
----------
group : commands.Group[Any, Any, Any]
The group for which the help message is to be sent.
"""
prefix = await self._get_prefix()
embed = self._embed_base(
author=f"{prefix}{group.qualified_name}",
description=group.help or "No description available.",
)
for command in group.commands:
embed.add_field(name=command.name, value=command.short_doc or "No description available.", inline=False)
await self.get_destination().send(embed=embed)
async def send_error_message(self, error: str) -> None:
"""
Sends an error message.
Parameters
----------
error : str
The error message to be sent.
"""
embed = Builder.create_embed(
theme="error",
title="Error in help command",
description=error,
)
await self.get_destination().send(embed=embed, delete_after=30)

View file

@ -1,34 +0,0 @@
import discord
from discord.ui import View
class ExchangeConfirmation(View):
def __init__(self, ctx):
super().__init__(timeout=180)
self.ctx = ctx
self.clickedConfirm = False
async def on_timeout(self):
for child in self.children:
child.disabled = True
await self.message.edit(view=None)
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.green)
async def confirm_button_callback(self, button, interaction):
await interaction.response.edit_message(view=None)
self.clickedConfirm = True
self.stop()
@discord.ui.button(label="Stop", style=discord.ButtonStyle.red)
async def stop_button_callback(self, button, interaction):
await interaction.response.edit_message(view=None)
self.stop()
async def interaction_check(self, interaction) -> bool:
if interaction.user == self.ctx.author:
return True
await interaction.response.send_message(
"You can't use these buttons, they're someone else's!",
ephemeral=True,
)
return False

55
lib/loader.py Normal file
View file

@ -0,0 +1,55 @@
from pathlib import Path
import aiofiles.os
from discord.ext import commands
from loguru import logger
from lib.const import CONST
class CogLoader(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.cog_ignore_list: set[str] = CONST.COG_IGNORE_LIST
async def is_cog(self, path: Path) -> bool:
cog_name: str = path.stem
if cog_name in self.cog_ignore_list:
logger.debug(f"Ignoring cog: {cog_name} because it is in the ignore list")
return False
return path.suffix == ".py" and not path.name.startswith("_") and await aiofiles.os.path.isfile(path)
async def load_cogs(self, path: Path) -> None:
try:
if await aiofiles.os.path.isdir(path):
for item in path.iterdir():
try:
await self.load_cogs(path=item)
except Exception as e:
logger.exception(f"Error loading cog from {item}: {e}")
elif await self.is_cog(path):
relative_path: Path = path.relative_to(Path(__file__).parent.parent)
module: str = str(relative_path).replace("/", ".").replace("\\", ".")[:-3]
try:
await self.bot.load_extension(name=module)
logger.debug(f"Loaded cog: {module}")
except Exception as e:
logger.exception(f"Error loading cog: {module}. Error: {e}")
except Exception as e:
logger.exception(f"Error loading cogs from {path}: {e}")
async def load_cog_from_dir(self, dir_name: str) -> None:
path: Path = Path(__file__).parent.parent / dir_name
await self.load_cogs(path)
@classmethod
async def setup(cls, bot: commands.Bot) -> None:
cog_loader = cls(bot)
await cog_loader.load_cog_from_dir(dir_name="modules")
await cog_loader.load_cog_from_dir(dir_name="handlers")
await bot.add_cog(cog_loader)

View file

@ -1,36 +0,0 @@
import random
class ReactionHandler:
def __init__(self):
self.eightball = [
"It is certain.",
"It is decidedly so.",
"Without a doubt.",
"Yes - definitely.",
"You may rely on it.",
"As I see it, yes.",
"Most likely.",
"Outlook good.",
"Yes.",
"Signs point to yes.",
"Reply hazy, try again.",
"Ask again later.",
"Better not tell you now.",
"Cannot predict now.",
"Concentrate and ask again.",
"Don't count on it.",
"My reply is no.",
"My sources say no.",
"Outlook not so good.",
"Very doubtful.",
]
async def handle_message(self, message):
content = message.content.lower()
if (
content.startswith("Lumi ") or content.startswith("Lumi, ")
) and content.endswith("?"):
response = random.choice(self.eightball)
await message.reply(content=response)

View file

@ -1,19 +0,0 @@
import datetime
import pytz
def seconds_until(hours, minutes):
eastern_timezone = pytz.timezone("US/Eastern")
now = datetime.datetime.now(eastern_timezone)
# Create a datetime object for the given time in the Eastern Timezone
given_time = datetime.time(hours, minutes)
future_exec = eastern_timezone.localize(datetime.datetime.combine(now, given_time))
# If the given time is before the current time, add one day to the future execution time
if future_exec < now:
future_exec += datetime.timedelta(days=1)
return (future_exec - now).total_seconds()

76
locales/bdays.en-US.json Normal file
View file

@ -0,0 +1,76 @@
{
"birthday_messages": [
"\ud83c\udf82 Happy Birthday, **{0}**! \ud83c\udf89 Wishing you a day filled with joy and laughter.",
"\ud83c\udf88 It's party time! Happy Birthday, **{0}**! \ud83c\udf89",
"\ud83c\udf89 Another year older, another year wiser! Happy Birthday, **{0}**! \ud83c\udf82",
"\ud83c\udf1f Today's the day you shine brighter than ever! Happy Birthday, **{0}**! \ud83c\udf1f",
"\ud83c\udf81 Special day alert! It's **{0}**'s birthday! \ud83c\udf81",
"\ud83c\udf8a Hip, hip, hooray! It's **{0}**'s birthday today! \ud83c\udf8a",
"\ud83c\udf82 Cake and confetti time! Happy Birthday, **{0}**! \ud83c\udf89",
"\ud83c\udf08 Sending you a rainbow of happiness on your birthday, **{0}**! \ud83c\udf88",
"\ud83c\udf89 Let's raise a toast to **{0}** on their birthday! Cheers to another fantastic year! \ud83e\udd42",
"\ud83c\udf88 Birthdays are like sprinkles on the cupcake of life! Happy Birthday, **{0}**! \ud83e\uddc1",
"\ud83c\udf81 Gift-wrapped wishes for a wonderful birthday and an amazing year ahead, **{0}**! \ud83c\udf81",
"\ud83c\udf8a Time to blow out the candles and make a wish! Happy Birthday, **{0}**! \ud83c\udf82",
"\ud83c\udf1f It's your day to sparkle and shine, **{0}**! Happy Birthday! \u2728",
"\ud83c\udf88 May your birthday be as fabulous as you are, **{0}**! \ud83c\udf89",
"\ud83c\udf89 Here's to a year filled with success, happiness, and endless opportunities for **{0}**! Happy Birthday! \ud83e\udd73",
"\ud83c\udf81 Wishing **{0}** all the best on their special day! Happy Birthday! \ud83c\udf81",
"\ud83c\udf8a Another year of unforgettable memories begins today for **{0}**! Happy Birthday! \ud83c\udf8a",
"\ud83c\udf1f Your birthday is the perfect excuse to pamper yourself, **{0}**! Enjoy your special day! \ud83c\udf88",
"\ud83c\udf82 Age is just a number, and you're looking more fabulous with each passing year, **{0}**! Happy Birthday! \ud83d\udc95",
"\ud83c\udf89 Today, we celebrate the amazing person you are, **{0}**! Happy Birthday! \ud83c\udf82",
"\ud83c\udf88 Life's journey gets even more exciting as **{0}** celebrates another year of it! Happy Birthday! \ud83c\udf89",
"\ud83c\udf1f Happy Birthday to someone who makes every day brighter with their presence, **{0}**! \ud83c\udf1e",
"\ud83c\udf81 May this birthday be the beginning of the most extraordinary year yet for **{0}**! \ud83d\ude80",
"\ud83c\udf8a Birthdays are nature's way of telling us to eat more cake! Enjoy your special treat, **{0}**! \ud83c\udf70",
"\ud83c\udf89 Time to pop the confetti and make some fabulous birthday memories, **{0}**! \ud83c\udf82",
"\ud83c\udf88 Today, the world received a gift in the form of **{0}**! Happy Birthday! \ud83c\udf89",
"\ud83c\udf1f Wishing **{0}** health, happiness, and all the things they desire on their birthday! \ud83c\udf81",
"\ud83c\udf82 Cheers to **{0}** on another year of being amazing! Happy Birthday! \ud83c\udf89",
"\ud83c\udf89 It's **{0}**'s big day, so let loose and enjoy every moment! Happy Birthday! \ud83c\udf8a",
"\ud83c\udf88 Sending virtual hugs and lots of love to **{0}** on their special day! \ud83e\udd17\u2764\ufe0f",
"\ud83c\udf1f On your birthday, the world becomes a better place because of your presence, **{0}**! \ud83c\udf89",
"\ud83c\udf81 As you blow out the candles, know that your wishes are heard and your dreams matter. Happy Birthday, **{0}**! \ud83c\udf20",
"\ud83c\udf8a Here's to a birthday filled with laughter, love, and all the things that make you happy, **{0}**! Cheers! \ud83e\udd42",
"\ud83c\udf82 May your birthday be filled with delightful surprises and sweet moments, **{0}**! Enjoy your special day! \ud83c\udf88",
"\ud83c\udf89 It's time for the world to celebrate the incredible person that is **{0}**! Happy Birthday! \ud83c\udf8a",
"\ud83c\udf88 Another year, another chapter in the adventure of life for **{0}**! May this year be full of excitement and joy! \ud83c\udf1f",
"\ud83c\udf1f May this birthday mark the beginning of extraordinary achievements and unforgettable memories for **{0}**! \ud83c\udf81",
"\ud83c\udf82 Here's to a birthday filled with love, happiness, and all the good things you deserve, **{0}**! \ud83c\udf89",
"\ud83c\udf89 Sending virtual confetti and a big smile to **{0}** on their birthday! Let's celebrate! \ud83c\udf88",
"\ud83c\udf88 Time to indulge in cake and celebrate the wonderful human that is **{0}**! Happy Birthday! \ud83c\udf82",
"\ud83c\udf8a Today, we honor the amazing journey of **{0}**'s life! Happy Birthday! \ud83c\udf1f",
"\ud83c\udf1f Birthdays are a time for reflection, growth, and gratitude. Wishing **{0}** a wonderful birthday and year ahead! \ud83c\udf81",
"\ud83c\udf81 Another trip around the sun calls for a big celebration! Happy Birthday, **{0}**! \ud83c\udf89",
"\ud83c\udf82 As you blow out the candles, know that you are loved and cherished, **{0}**! Happy Birthday! \ud83c\udf88",
"\ud83c\udf89 It's a new age and a new opportunity to shine, **{0}**! May this year be your best yet! \ud83c\udf1f",
"\ud83c\udf88 On your special day, may you be surrounded by love, happiness, and everything that brings you joy, **{0}**! \ud83c\udf82",
"\ud83c\udf1f Today, we celebrate **{0}** and the unique light they bring into the world! Happy Birthday! \ud83c\udf89",
"\ud83c\udf81 Wishing a fantastic birthday to the one and only **{0}**! May this day be filled with laughter and love! \ud83c\udf88",
"\ud83c\udf8a May your birthday be as extraordinary as the person you are, **{0}**! Cheers to you! \ud83e\udd73",
"\ud83c\udf82 You're not just a year older; you're a year more incredible! Happy Birthday, **{0}**! \ud83c\udf1f",
"\ud83c\udf89 It's time to embrace the joy, love, and happiness that come with birthdays! Enjoy every moment, **{0}**! \ud83c\udf88",
"\ud83c\udf88 Another year, another chance to create beautiful memories. Happy Birthday, **{0}**! \ud83c\udf82",
"\ud83c\udf1f Your birthday is a reminder of how much you mean to all of us! Wishing you the best day ever, **{0}**! \ud83c\udf81",
"\ud83c\udf81 Sending warm birthday wishes and virtual hugs to **{0}** on their special day! \ud83e\udd17\u2764\ufe0f",
"\ud83c\udf89 Today, we celebrate the unique and wonderful person that is **{0}**! Happy Birthday! \ud83c\udf82",
"\ud83c\udf88 Here's to a birthday filled with laughter, love, and all the things that make you smile, **{0}**! \ud83c\udf1f",
"\ud83c\udf8a It's a day to be spoiled and celebrated, **{0}**! Wishing you the happiest of birthdays! \ud83c\udf81",
"\ud83c\udf82 As you turn another year older, know that you are loved and cherished, **{0}**! Happy Birthday! \ud83c\udf89"
],
"months": [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
}

View file

@ -14,7 +14,7 @@
"Rumor has it that reaching **Level {}** grants you the ability to mildly impress others.",
"You got to **Level {}**! Prepare for a slightly raised eyebrow of acknowledgement.",
"Congratulations on reaching **Level {}**. It's a modest achievement, to say the least.",
"Congratulations on **Level {}**! You must be SO proud of yourself. \uD83D\uDE44",
"Congratulations on **Level {}**! You must be SO proud of yourself. \ud83d\ude44",
"You've reached **Level {}**! Your achievement is about as significant as a grain of sand.",
"Congratulations on your ascent to **Level {}**. It's a small step for mankind.",
"At **Level {}**, you're like a firework that fizzles out before it even begins.",
@ -39,7 +39,7 @@
"*elevator music* Welcome to **level {}**."
],
"21-40": [
"**Level {}** 👍",
"**Level {}** \ud83d\udc4d",
"Look who's slacking off work to level up on Discord. **Level {}** and counting!",
"**Level {}**? Have you considered that there might be an entire world outside of Discord?",
"Wow, you've climbed to **level {}**. Is Discord your full-time job now?",
@ -68,7 +68,7 @@
"Lol it took you this long to reach **Level {}**.",
"**{}**.",
"**Level {}**???? Who are you? Gear?",
"Yay you reached **Level {}**!! :3 UwU \uD83E\uDD8B \uD83D\uDDA4 (nobody cares)",
"Yay you reached **Level {}**!! :3 UwU \ud83e\udd8b \ud83d\udda4 (nobody cares)",
"Conragulasions your level **{}** now.",
"Hey man congrats on reaching **Level {}**. I mean it. GG.",
"You reached **Level {}**!! What's it like being a loser?",

View file

@ -16,7 +16,10 @@
"admin_sync_error_description": "An error occurred while syncing: {0}",
"admin_sync_error_title": "Sync Error",
"admin_sync_title": "Sync Successful",
"bet_limit": "❌ | **{0}** you cannot place any bets above **${1}**.",
"balance_author": "{0}'s wallet",
"balance_cash": "**Cash**: ${0}",
"balance_footer": "check out /daily",
"bet_limit": "\u274c | **{0}** you cannot place any bets above **${1}**.",
"birthday_add_invalid_date": "The date you entered is invalid.",
"birthday_add_success_author": "Birthday Set",
"birthday_add_success_description": "your birthday has been set to **{0} {1}**.",
@ -29,7 +32,7 @@
"birthday_delete_success_description": "your birthday has been deleted from this server.",
"birthday_leap_year": "February 29",
"birthday_upcoming_author": "Upcoming Birthdays!",
"birthday_upcoming_description_line": "🎂 {0} - {1}",
"birthday_upcoming_description_line": "\ud83c\udf82 {0} - {1}",
"birthday_upcoming_no_birthdays": "there are no upcoming birthdays in this server.",
"birthday_upcoming_no_birthdays_author": "No Upcoming Birthdays",
"blackjack_bet": "Bet ${0}",
@ -42,15 +45,15 @@
"blackjack_error": "I.. don't know if you won?",
"blackjack_error_description": "This is an error, please report it.",
"blackjack_footer": "Game finished",
"blackjack_hit": "hit",
"blackjack_lost": "You lost **${0}**.",
"blackjack_lost_generic": "You lost..",
"blackjack_player_hand": "**You**\nScore: {0}\n*Hand: {1}*",
"blackjack_stand": "stand",
"blackjack_title": "BlackJack",
"blackjack_won_21": "You won with a score of 21!",
"blackjack_won_natural": "You won with a natural hand!",
"blackjack_won_payout": "You won **${0}**.",
"blackjack_hit": "hit",
"blackjack_stand": "stand",
"boost_default_description": "Thanks for boosting, **{0}**!!",
"boost_default_title": "New Booster",
"case_case_field": "Case:",
@ -71,7 +74,7 @@
"case_reason_update_author": "Case Reason Updated",
"case_reason_update_description": "case `{0}` reason has been updated.",
"case_target_field": "Target:",
"case_target_field_value": "`{0}` 🎯",
"case_target_field_value": "`{0}` \ud83c\udfaf",
"case_type_field": "Type:",
"case_type_field_value": "`{0}`",
"case_type_field_value_with_duration": "`{0} ({1})`",
@ -87,6 +90,7 @@
"config_boost_module_disabled": "the boost module was successfully disabled.",
"config_boost_template_field": "New Template:",
"config_boost_template_updated": "the boost message template has been updated.",
"config_boost_total_count": "Total server boosts: {0}",
"config_example_next_footer": "An example will be sent next.",
"config_level_channel_set": "all level announcements will be sent in {0}.",
"config_level_current_channel_set": "members will receive level announcements in their current channel.",
@ -98,15 +102,15 @@
"config_level_template_updated": "the level template was successfully updated.",
"config_level_type_example": "Example:",
"config_level_type_generic": "level announcements will be **generic messages**.",
"config_level_type_generic_example": "📈 | **lucas** you have reached **Level 15**.",
"config_level_type_generic_example": "\ud83d\udcc8 | **lucas** you have reached **Level 15**.",
"config_level_type_whimsical": "level announcements will be **sarcastic comments**.",
"config_level_type_whimsical_example": "📈 | **lucas** Lol it took you this long to reach **Level 15**.",
"config_level_type_whimsical_example": "\ud83d\udcc8 | **lucas** Lol it took you this long to reach **Level 15**.",
"config_modlog_channel_set": "moderation logs will be sent in {0}.",
"config_modlog_info_author": "Moderation Log Configuration",
"config_modlog_info_commands_name": "📖 Case commands",
"config_modlog_info_commands_name": "\ud83d\udcd6 Case commands",
"config_modlog_info_commands_value": "`/cases` - View all cases in this server\n`/case <case_id>` - View a specific case\n`/editcase <case_id> <new_reason>` - Update a case reason",
"config_modlog_info_description": "This channel has been set as the moderation log channel for **{0}**. All moderation actions issued with Lumi will be logged here as cases.",
"config_modlog_info_warning_name": "⚠️ Warning",
"config_modlog_info_warning_name": "\u26a0\ufe0f Warning",
"config_modlog_info_warning_value": "Changing the mod-log channel in the future will make old cases uneditable in this channel.",
"config_modlog_permission_error": "I don't have perms to send messages in that channel. Please fix & try again.",
"config_prefix_get": "the current prefix for this server is `{0}`",
@ -115,21 +119,24 @@
"config_show_author": "{0} Configuration",
"config_show_birthdays": "Birthdays",
"config_show_boost_announcements": "Boost announcements",
"config_show_default_enabled": " Enabled (default)",
"config_show_disabled": " Disabled",
"config_show_enabled": " Enabled",
"config_show_default_enabled": "\u2705 Enabled (default)",
"config_show_disabled": "\u274c Disabled",
"config_show_enabled": "\u2705 Enabled",
"config_show_guide": "Guide: {0}",
"config_show_level_announcements": "Level announcements",
"config_show_moderation_log": "Moderation Log",
"config_show_moderation_log_channel_deleted": "⚠️ **Not configured** (channel deleted?)",
"config_show_moderation_log_enabled": " {0}",
"config_show_moderation_log_not_configured": "⚠️ **Not configured yet**",
"config_show_moderation_log_channel_deleted": "\u26a0\ufe0f **Not configured** (channel deleted?)",
"config_show_moderation_log_enabled": "\u2705 {0}",
"config_show_moderation_log_not_configured": "\u26a0\ufe0f **Not configured yet**",
"config_show_new_member_greets": "New member greets",
"config_welcome_channel_set": "I will announce new members in {0}.",
"config_welcome_module_already_disabled": "the greeting module was already disabled.",
"config_welcome_module_disabled": "the greeting module was successfully disabled.",
"config_welcome_template_field": "New Template:",
"config_welcome_template_updated": "the welcome message template has been updated.",
"config_xpreward_added": "xp reward for **Level {0}** with role {1} has been added.",
"config_xpreward_removed": "xp reward for **Level {0}** has been removed.",
"config_xpreward_show_no_rewards": "**There are no XP rewards set up yet.**\n\nTo add a reward, use `/config xpreward add`.",
"daily_already_claimed_author": "Already Claimed",
"daily_already_claimed_description": "you can claim your daily reward again <t:{0}:R>.",
"daily_already_claimed_footer": "Daily reset is at 7 AM EST",
@ -137,12 +144,12 @@
"daily_success_claim_author": "Reward Claimed",
"daily_success_claim_description": "you claimed your reward of **${0}**!",
"default_level_up_message": "**{0}** you have reached **Level {1}**.",
"dev_clear_tree": "The application command tree has been cleared.",
"dev_sync_tree": "The application command tree has been synced.",
"error_actionable_hierarchy_bot": "I don't have permission to perform this action on this user due to role hierarchy.",
"error_actionable_hierarchy_user": "you don't have permission to perform this action on this user due to role hierarchy.",
"error_actionable_self": "you can't perform this action on yourself.",
"error_already_playing_blackjack": "you already have a game of blackjack running.",
"error_bad_argument_author": "Bad Argument",
"error_bad_argument_description": "{0}",
"error_birthdays_disabled_author": "Birthdays Disabled",
"error_birthdays_disabled_description": "birthdays are disabled in this server.",
"error_birthdays_disabled_footer": "Contact a mod to enable them.",
@ -150,6 +157,7 @@
"error_boost_image_url_invalid": "the image URL must end with `.jpg` or `.png`.",
"error_bot_missing_permissions_author": "Bot Missing Permissions",
"error_bot_missing_permissions_description": "Lumi lacks the required permissions to run this command.",
"error_cant_use_buttons": "You can't use these buttons, they're someone else's!",
"error_command_cooldown_author": "Command Cooldown",
"error_command_cooldown_description": "try again in **{0:02d}:{1:02d}**.",
"error_command_not_found": "No command called \"{0}\" found.",
@ -167,18 +175,24 @@
"error_no_private_message_author": "Guild Only",
"error_no_private_message_description": "this command can only be used in servers.",
"error_not_enough_cash": "you don't have enough cash.",
"error_not_owner_author": "Owner Only",
"error_not_owner_description": "this command requires Lumi ownership permissions.",
"error_not_owner": "{0} tried to use a bot admin command ({1})",
"error_not_owner_unknown": "Unknown",
"error_out_of_time": "you ran out of time.",
"error_out_of_time_economy": "you ran out of time. Your bet was forfeited.",
"error_private_message_only_author": "Private Message Only",
"error_private_message_only_description": "this command can only be used in private messages.",
"error_unknown_error_author": "Unknown Error",
"error_unknown_error_description": "an unknown error occurred. Please try again later.",
"give_error_bot": "you can't give money to a bot.",
"give_error_insufficient_funds": "you don't have enough cash.",
"give_error_invalid_amount": "invalid amount.",
"give_error_self": "you can't give money to yourself.",
"give_success": "you gave **${1}** to {2}.",
"greet_default_description": "_ _\n**Welcome** to **{0}**",
"greet_template_description": "↓↓↓\n{0}",
"greet_template_description": "\u2193\u2193\u2193\n{0}",
"help_footer": "Help Service",
"help_use_prefix": "Please use Lumi's prefix to get help. Type `{0}help`",
"info_api_version": "**API:** v{0}\n",
"info_api_version": "**discord.py:** v{0}\n",
"info_database_records": "**Database:** {0} records",
"info_latency": "**Latency:** {0}ms\n",
"info_memory": "**Memory:** {0:.2f} MB\n",
@ -192,6 +206,7 @@
"intro_no_guild": "you're not in a server that supports introductions.",
"intro_no_guild_author": "Server Not Supported",
"intro_post_confirmation": "your introduction has been posted in {0}!",
"intro_post_confirmation_author": "Introduction Posted",
"intro_preview_field": "**{0}:** {1}\n\n",
"intro_question_footer": "Type your answer below.",
"intro_service_name": "Introduction Service",
@ -203,15 +218,16 @@
"intro_timeout_author": "Timeout",
"intro_too_long": "your answer was too long, please keep it below 200 characters.",
"intro_too_long_author": "Answer Too Long",
"invite_author": "Invite Lumi",
"invite_button_text": "Invite Lumi",
"invite_description": "Thanks for inviting me to your server!",
"level_up": "📈 | **{0}** you have reached **Level {1}**.",
"level_up_prefix": "📈 | **{0}** ",
"invite_description": "thanks for inviting me to your server!",
"level_up": "\ud83d\udcc8 | **{0}** you have reached **Level {1}**.",
"level_up_prefix": "\ud83d\udcc8 | **{0}** ",
"lumi_exception_blacklisted": "User is blacklisted",
"lumi_exception_generic": "An error occurred.",
"lumi_exception_generic": "An error occurred. Please try again later.",
"mod_ban_dm": "**{0}** you have been banned from `{1}`.\n\n**Reason:** `{2}`",
"mod_banned_author": "User Banned",
"mod_banned_user": "user with ID `{0}` has been banned.",
"mod_banned_user": "user `{0}` has been banned.",
"mod_dm_not_sent": "Failed to notify them in DM",
"mod_dm_sent": "notified them in DM",
"mod_kick_dm": "**{0}** you have been kicked from `{1}`.\n\n**Reason:** `{2}`",
@ -230,7 +246,8 @@
"mod_timed_out_author": "User Timed Out",
"mod_timed_out_user": "user `{0}` has been timed out.",
"mod_timeout_dm": "**{0}** you have been timed out in `{1}` for `{2}`.\n\n**Reason:** `{3}`",
"mod_unbanned": "user with ID `{0}` has been unbanned.",
"mod_timeout_too_long": "you cannot timeout a user for longer than 27 days.",
"mod_unbanned": "user `{0}` has been unbanned.",
"mod_unbanned_author": "User Unbanned",
"mod_untimed_out": "timeout has been removed for user `{0}`.",
"mod_untimed_out_author": "User Timeout Removed",
@ -239,10 +256,15 @@
"mod_warned_user": "user `{0}` has been warned.",
"ping_author": "I'm online!",
"ping_footer": "Latency: {0}ms",
"ping_pong": "Pong!",
"ping_pong": "pong!",
"ping_uptime": "I've been online since <t:{0}:R>.",
"stats_blackjack": "🃏 | You've played **{0}** games of BlackJack, betting a total of **${1}**. You won **{2}** of those games with a total payout of **${3}**.",
"stats_slots": "🎰 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.",
"slowmode_channel_not_found": "Channel not found.",
"slowmode_current_value": "The current slowmode for {0} is **{1}s**.",
"slowmode_forbidden": "I don't have permission to change the slowmode in that channel.",
"slowmode_invalid_duration": "Slowmode duration must be between 0 and 21600 seconds.",
"slowmode_success": "Slowmode set to **{0}s** in {1}.",
"stats_blackjack": "\ud83c\udccf | You've played **{0}** games of BlackJack, betting a total of **${1}**. You won **{2}** of those games with a total payout of **${3}**.",
"stats_slots": "\ud83c\udfb0 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.",
"trigger_already_exists": "Failed to add custom reaction. This text already contains another trigger. To avoid unexpected behavior, please delete it before adding a new one.",
"trigger_limit_reached": "Failed to add custom reaction. You have reached the limit of 100 custom reactions for this server.",
"triggers_add_author": "Custom Reaction Created",
@ -283,14 +305,5 @@
"xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`",
"xp_level": "Level {0}",
"xp_progress": "Progress to next level",
"xp_server_rank": "Server Rank: #{0}",
"balance_cash": "**Cash**: ${0}",
"balance_author": "{0}'s wallet",
"balance_footer": "check out /daily",
"give_error_self": "you can't give money to yourself.",
"give_error_bot": "you can't give money to a bot.",
"give_error_invalid_amount": "invalid amount.",
"give_error_insufficient_funds": "you don't have enough cash.",
"give_success": "**{0}** gave **${1}** to {2}.",
"error_cant_use_buttons": "You can't use these buttons, they're someone else's!"
"xp_server_rank": "Server Rank: #{0}"
}

49
main.py Normal file
View file

@ -0,0 +1,49 @@
import asyncio
import sys
import discord
from discord.ext import commands
from loguru import logger
from lib.client import Luminara
from lib.const import CONST
from lib.help import LuminaraHelp
from services.config_service import GuildConfig
logger.remove()
logger.add(sys.stdout, format=CONST.LOG_FORMAT, colorize=True, level=CONST.LOG_LEVEL)
async def get_prefix(bot: Luminara, message: discord.Message) -> list[str]:
extras = GuildConfig.get_prefix(message)
return commands.when_mentioned_or(*extras)(bot, message)
async def main() -> None:
if not CONST.TOKEN:
logger.error("No token provided")
return
lumi: Luminara = Luminara(
owner_ids=CONST.OWNER_IDS,
intents=discord.Intents.all(),
command_prefix=get_prefix,
allowed_mentions=discord.AllowedMentions(everyone=False),
case_insensitive=True,
strip_after_prefix=True,
help_command=LuminaraHelp(),
)
try:
await lumi.start(CONST.TOKEN, reconnect=True)
except KeyboardInterrupt:
logger.info("Keyboard interrupt detected. Shutting down...")
finally:
logger.info("Closing resources...")
await lumi.shutdown()
if __name__ == "__main__":
asyncio.run(main())

0
modules/__init__.py Normal file
View file

View file

@ -1,45 +0,0 @@
from typing import Optional
import discord
from discord.ext import commands
from modules.admin import award, blacklist, sql, sync
class BotAdmin(commands.Cog, name="Bot Admin"):
"""
This module is intended for commands that only bot owners can do.
For server configuration with Lumi, see the "config" module.
"""
def __init__(self, client):
self.client = client
@commands.command(name="award")
@commands.is_owner()
async def award_command(self, ctx, user: discord.User, *, amount: int):
return await award.cmd(ctx, user, amount)
@commands.command(name="sqlselect", aliases=["sqls"])
@commands.is_owner()
async def select(self, ctx, *, query: str):
return await sql.select_cmd(ctx, query)
@commands.command(name="sqlinject", aliases=["sqli"])
@commands.is_owner()
async def inject(self, ctx, *, query: str):
return await sql.inject_cmd(ctx, query)
@commands.command(name="blacklist")
@commands.is_owner()
async def blacklist(self, ctx, user: discord.User, *, reason: Optional[str] = None):
return await blacklist.blacklist_user(ctx, user, reason)
@commands.command(name="sync")
@commands.is_owner()
async def sync_command(self, ctx):
await sync.sync_commands(self.client, ctx)
def setup(client):
client.add_cog(BotAdmin(client))

109
modules/admin/admin.py Normal file
View file

@ -0,0 +1,109 @@
import mysql.connector
from discord.ext import commands
import lib.format
from db import database
from lib.const import CONST
from lib.format import shorten
from ui.embeds import Builder
class Sql(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.select_cmd.usage = lib.format.generate_usage(self.select_cmd)
self.inject_cmd.usage = lib.format.generate_usage(self.inject_cmd)
@commands.command(name="sqlselect", aliases=["sqls"])
@commands.is_owner()
async def select_cmd(
self,
ctx: commands.Context[commands.Bot],
*,
query: str,
) -> None:
"""
Execute a SQL SELECT query.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
query : str
The SQL query to execute.
"""
if query.lower().startswith("select "):
query = query[7:]
try:
results = database.select_query(f"SELECT {query}")
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_sql_select_title"],
description=CONST.STRINGS["admin_sql_select_description"].format(
shorten(query, 200),
shorten(str(results), 200),
),
hide_name_in_description=True,
)
except mysql.connector.Error as error:
embed = Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_sql_select_error_title"],
description=CONST.STRINGS["admin_sql_select_error_description"].format(
shorten(query, 200),
shorten(str(error), 200),
),
hide_name_in_description=True,
)
await ctx.send(embed=embed, ephemeral=True)
@commands.command(name="sqlinject", aliases=["sqli"])
@commands.is_owner()
async def inject_cmd(
self,
ctx: commands.Context[commands.Bot],
*,
query: str,
) -> None:
"""
Execute a SQL INJECT query.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
query : str
The SQL query to execute.
"""
try:
database.execute_query(query)
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_sql_inject_title"],
description=CONST.STRINGS["admin_sql_inject_description"].format(
shorten(query, 200),
),
hide_name_in_description=True,
)
except mysql.connector.Error as error:
embed = Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_sql_inject_error_title"],
description=CONST.STRINGS["admin_sql_inject_error_description"].format(
shorten(query, 200),
shorten(str(error), 200),
),
hide_name_in_description=True,
)
await ctx.send(embed=embed, ephemeral=True)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Sql(bot))

View file

@ -1,18 +1,44 @@
import discord
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from ui.embeds import Builder
async def cmd(ctx, user: discord.User, amount: int):
# Currency handler
class Award(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.award_command.usage = lib.format.generate_usage(self.award_command)
@commands.command(name="award", aliases=["aw"])
@commands.is_owner()
async def award_command(
self,
ctx: commands.Context[commands.Bot],
user: discord.User,
amount: int,
) -> None:
"""
Award a user with a specified amount of currency.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
user : discord.User
The user to award.
amount : int
The amount of currency to award.
"""
curr = Currency(user.id)
curr.add_balance(amount)
curr.push()
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_award_title"],
description=CONST.STRINGS["admin_award_description"].format(
Currency.format(amount),
@ -20,4 +46,8 @@ async def cmd(ctx, user: discord.User, amount: int):
),
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Award(bot))

View file

@ -1,26 +1,51 @@
from typing import Optional
import discord
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from services.blacklist_service import BlacklistUserService
from ui.embeds import Builder
async def blacklist_user(
ctx,
class Blacklist(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.blacklist_command.usage = lib.format.generate_usage(self.blacklist_command)
@commands.command(name="blacklist")
@commands.is_owner()
async def blacklist_command(
self,
ctx: commands.Context[commands.Bot],
user: discord.User,
reason: Optional[str] = None,
) -> None:
*,
reason: str | None = None,
) -> None:
"""
Blacklist a user from the bot.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
user : discord.User
The user to blacklist.
reason : str | None, optional
"""
blacklist_service = BlacklistUserService(user.id)
blacklist_service.add_to_blacklist(reason)
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["admin_blacklist_author"],
description=CONST.STRINGS["admin_blacklist_description"].format(user.name),
footer_text=CONST.STRINGS["admin_blacklist_footer"],
hide_timestamp=True,
hide_time=True,
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Blacklist(bot))

70
modules/admin/dev.py Normal file
View file

@ -0,0 +1,70 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
class Dev(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.sync.usage = lib.format.generate_usage(self.sync)
self.clear.usage = lib.format.generate_usage(self.clear)
@commands.group(name="dev", description="Lumi developer commands")
@commands.guild_only()
@commands.is_owner()
async def dev(self, ctx: commands.Context[commands.Bot]) -> None:
pass
@dev.command(
name="sync_tree",
aliases=["sync"],
)
async def sync(
self,
ctx: commands.Context[commands.Bot],
guild: discord.Guild | None = None,
) -> None:
"""
Sync the bot's tree to the specified guild.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
guild : discord.Guild | None, optional
The guild to sync the tree to, by default None.
"""
if guild:
self.bot.tree.copy_global_to(guild=guild)
await self.bot.tree.sync(guild=guild)
await ctx.send(content=CONST.STRINGS["dev_sync_tree"])
@dev.command(
name="clear_tree",
aliases=["clear"],
)
async def clear(
self,
ctx: commands.Context[commands.Bot],
guild: discord.Guild | None = None,
) -> None:
"""
Clear the bot's tree for the specified guild.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
guild : discord.Guild | None, optional
"""
self.bot.tree.clear_commands(guild=guild)
await ctx.send(content=CONST.STRINGS["dev_clear_tree"])
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Dev(bot))

View file

@ -1,60 +0,0 @@
import mysql.connector
from db import database
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import shorten
async def select_cmd(ctx, query: str):
if query.lower().startswith("select "):
query = query[7:]
try:
results = database.select_query(f"SELECT {query}")
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["admin_sql_select_title"],
description=CONST.STRINGS["admin_sql_select_description"].format(
shorten(query, 200),
shorten(str(results), 200),
),
show_name=False,
)
except mysql.connector.Error as error:
embed = EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["admin_sql_select_error_title"],
description=CONST.STRINGS["admin_sql_select_error_description"].format(
shorten(query, 200),
shorten(str(error), 200),
),
show_name=False,
)
return await ctx.respond(embed=embed, ephemeral=True)
async def inject_cmd(ctx, query: str):
try:
database.execute_query(query)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["admin_sql_inject_title"],
description=CONST.STRINGS["admin_sql_inject_description"].format(
shorten(query, 200),
),
show_name=False,
)
except mysql.connector.Error as error:
embed = EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["admin_sql_inject_error_title"],
description=CONST.STRINGS["admin_sql_inject_error_description"].format(
shorten(query, 200),
shorten(str(error), 200),
),
show_name=False,
)
await ctx.respond(embed=embed, ephemeral=True)

View file

@ -1,20 +0,0 @@
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
async def sync_commands(client, ctx):
try:
await client.sync_commands()
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["admin_sync_title"],
description=CONST.STRINGS["admin_sync_description"],
)
await ctx.send(embed=embed)
except discord.HTTPException as e:
raise LumiException(
CONST.STRINGS["admin_sync_error_description"].format(e),
) from e

View file

@ -1,46 +0,0 @@
import datetime
import discord
import pytz
from discord.commands import SlashCommandGroup
from discord.ext import commands, tasks
from lib import checks
from lib.constants import CONST
from modules.birthdays import birthday, daily_check
class Birthdays(commands.Cog):
def __init__(self, client):
self.client = client
self.daily_birthday_check.start()
birthday = SlashCommandGroup(
name="birthday",
description="Birthday commands.",
contexts={discord.InteractionContextType.guild},
)
@birthday.command(name="set", description="Set your birthday in this server.")
@checks.birthdays_enabled()
@discord.commands.option(name="month", choices=CONST.BIRTHDAY_MONTHS)
async def set_birthday(self, ctx, month, day: int):
index = CONST.BIRTHDAY_MONTHS.index(month) + 1
await birthday.add(ctx, month, index, day)
@birthday.command(name="delete", description="Delete your birthday in this server.")
async def delete_birthday(self, ctx):
await birthday.delete(ctx)
@birthday.command(name="upcoming", description="Shows the upcoming birthdays.")
@checks.birthdays_enabled()
async def upcoming_birthdays(self, ctx):
await birthday.upcoming(ctx)
@tasks.loop(time=datetime.time(hour=12, minute=0, tzinfo=pytz.UTC)) # 12 PM UTC
async def daily_birthday_check(self):
await daily_check.daily_birthday_check(self.client)
def setup(client):
client.add_cog(Birthdays(client))

View file

@ -1,76 +1,198 @@
import asyncio
import calendar
import datetime
import random
from zoneinfo import ZoneInfo
import discord
from discord.ext import commands
from discord import app_commands
from discord.ext import commands, tasks
from loguru import logger
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.birthday_service import Birthday
from lib.checks import birthdays_enabled
from lib.const import CONST
from services.birthday_service import BirthdayService
from services.config_service import GuildConfig
from ui.embeds import Builder
async def add(ctx, month, month_index, day):
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
class Birthday(commands.GroupCog, group_name="birthday"):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.daily_birthday_check.start()
@tasks.loop(time=datetime.time(hour=12, minute=0, tzinfo=ZoneInfo("UTC")))
async def daily_birthday_check(self):
logger.info(CONST.STRINGS["birthday_check_started"])
birthdays_today = BirthdayService.get_birthdays_today()
processed_birthdays = 0
failed_birthdays = 0
if birthdays_today:
for user_id, guild_id in birthdays_today:
try:
guild = await self.bot.fetch_guild(guild_id)
member = await guild.fetch_member(user_id)
guild_config = GuildConfig(guild.id)
if not guild_config.birthday_channel_id:
logger.debug(
CONST.STRINGS["birthday_check_skipped"].format(guild.id),
)
continue
message = random.choice(CONST.BIRTHDAY_MESSAGES)
embed = Builder.create_embed(
theme="success",
author_text="Happy Birthday!",
description=message.format(member.name),
hide_name_in_description=True,
)
embed.set_image(url=CONST.BIRTHDAY_GIF_URL)
channel = await guild.fetch_channel(guild_config.birthday_channel_id)
assert isinstance(channel, discord.TextChannel)
await channel.send(embed=embed, content=member.mention)
logger.debug(
CONST.STRINGS["birthday_check_success"].format(
member.id,
guild.id,
channel.id,
),
)
processed_birthdays += 1
except Exception as e:
logger.warning(
CONST.STRINGS["birthday_check_error"].format(user_id, guild_id, e),
)
failed_birthdays += 1
# wait one second to avoid rate limits
await asyncio.sleep(1)
logger.info(
CONST.STRINGS["birthday_check_finished"].format(
processed_birthdays,
failed_birthdays,
),
)
@app_commands.command(name="set")
@birthdays_enabled()
@app_commands.choices(
month=[discord.app_commands.Choice(name=month_name, value=month_name) for month_name in CONST.BIRTHDAY_MONTHS],
)
async def set_birthday(
self,
interaction: discord.Interaction,
month: str,
day: int,
) -> None:
"""
Set your birthday.
Parameters
----------
interaction : discord.Interaction
The interaction object.
month : Month
The month of your birthday.
day : int
The day of your birthday.
"""
assert interaction.guild
leap_year = 2020
month_index = CONST.BIRTHDAY_MONTHS.index(month) + 1
max_days = calendar.monthrange(leap_year, month_index)[1]
if not 1 <= day <= max_days:
raise commands.BadArgument(CONST.STRINGS["birthday_add_invalid_date"])
date_obj = datetime.datetime(leap_year, month_index, day)
date_obj = datetime.datetime(leap_year, month_index, day, tzinfo=ZoneInfo("US/Eastern"))
birthday = Birthday(ctx.author.id, ctx.guild.id)
birthday = BirthdayService(interaction.user.id, interaction.guild.id)
birthday.set(date_obj)
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["birthday_add_success_author"],
description=CONST.STRINGS["birthday_add_success_description"].format(
month,
day,
),
show_name=True,
)
await ctx.respond(embed=embed)
await interaction.response.send_message(embed=embed)
async def delete(ctx):
Birthday(ctx.author.id, ctx.guild.id).delete()
@app_commands.command(name="remove")
async def remove_birthday(
self,
interaction: discord.Interaction,
) -> None:
"""
Remove your birthday.
embed = EmbedBuilder.create_success_embed(
ctx,
Parameters
----------
interaction : discord.Interaction
The interaction object.
"""
assert interaction.guild
BirthdayService(interaction.user.id, interaction.guild.id).delete()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["birthday_delete_success_author"],
description=CONST.STRINGS["birthday_delete_success_description"],
show_name=True,
)
await ctx.respond(embed=embed)
await interaction.response.send_message(embed=embed)
async def upcoming(ctx):
upcoming_birthdays = Birthday.get_upcoming_birthdays(ctx.guild.id)
@app_commands.command(name="upcoming")
@birthdays_enabled()
async def upcoming_birthdays(
self,
interaction: discord.Interaction,
) -> None:
"""
View upcoming birthdays.
Parameters
----------
interaction : discord.Interaction
The interaction object.
"""
assert interaction.guild
upcoming_birthdays = BirthdayService.get_upcoming_birthdays(interaction.guild.id)
if not upcoming_birthdays:
embed = EmbedBuilder.create_warning_embed(
ctx,
embed = Builder.create_embed(
theme="warning",
user_name=interaction.user.name,
author_text=CONST.STRINGS["birthday_upcoming_no_birthdays_author"],
description=CONST.STRINGS["birthday_upcoming_no_birthdays"],
show_name=True,
)
await ctx.respond(embed=embed)
await interaction.response.send_message(embed=embed)
return
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["birthday_upcoming_author"],
description="",
show_name=False,
)
embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT)
birthday_lines = []
birthday_lines: list[str] = []
for user_id, birthday in upcoming_birthdays[:10]:
try:
member = await ctx.guild.fetch_member(user_id)
birthday_date = datetime.datetime.strptime(birthday, "%m-%d")
member = await interaction.guild.fetch_member(user_id)
birthday_date = datetime.datetime.strptime(birthday, "%m-%d").replace(tzinfo=ZoneInfo("US/Eastern"))
formatted_birthday = birthday_date.strftime("%B %-d")
birthday_lines.append(
CONST.STRINGS["birthday_upcoming_description_line"].format(
@ -82,4 +204,8 @@ async def upcoming(ctx):
continue
embed.description = "\n".join(birthday_lines)
await ctx.respond(embed=embed)
await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Birthday(bot))

View file

@ -1,65 +0,0 @@
import asyncio
import random
from loguru import logger
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.birthday_service import Birthday
from services.config_service import GuildConfig
async def daily_birthday_check(client):
logger.info(CONST.STRINGS["birthday_check_started"])
birthdays_today = Birthday.get_birthdays_today()
processed_birthdays = 0
failed_birthdays = 0
if birthdays_today:
for user_id, guild_id in birthdays_today:
try:
guild = await client.fetch_guild(guild_id)
member = await guild.fetch_member(user_id)
guild_config = GuildConfig(guild.id)
if not guild_config.birthday_channel_id:
logger.debug(
CONST.STRINGS["birthday_check_skipped"].format(guild.id),
)
continue
message = random.choice(CONST.BIRTHDAY_MESSAGES)
embed = EmbedBuilder.create_success_embed(
None,
author_text="Happy Birthday!",
description=message.format(member.name),
show_name=False,
)
embed.set_image(url=CONST.BIRTHDAY_GIF_URL)
channel = await guild.fetch_channel(guild_config.birthday_channel_id)
await channel.send(embed=embed, content=member.mention)
logger.debug(
CONST.STRINGS["birthday_check_success"].format(
member.id,
guild.id,
channel.id,
),
)
processed_birthdays += 1
except Exception as e:
logger.warning(
CONST.STRINGS["birthday_check_error"].format(user_id, guild_id, e),
)
failed_birthdays += 1
# wait one second to avoid rate limits
await asyncio.sleep(1)
logger.info(
CONST.STRINGS["birthday_check_finished"].format(
processed_birthdays,
failed_birthdays,
),
)

View file

@ -1,174 +0,0 @@
import discord
from discord.commands import SlashCommandGroup
from discord.ext import bridge, commands
from discord.ext.commands import guild_only
from modules.config import (
c_birthday,
c_boost,
c_greet,
c_level,
c_moderation,
c_prefix,
c_show,
xp_reward,
)
class Config(commands.Cog):
def __init__(self, client):
self.client = client
@bridge.bridge_command(
name="xprewards",
aliases=["xpr"],
description="Show your server's XP rewards list.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
@commands.has_permissions(manage_roles=True)
async def xp_reward_command_show(self, ctx):
await xp_reward.show(ctx)
@bridge.bridge_command(
name="addxpreward",
aliases=["axpr"],
description="Add a Lumi XP reward.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
@commands.has_permissions(manage_roles=True)
async def xp_reward_command_add(
self,
ctx,
level: int,
role: discord.Role,
persistent: bool = False,
):
await xp_reward.add_reward(ctx, level, role.id, persistent)
@bridge.bridge_command(
name="removexpreward",
aliases=["rxpr"],
description="Remove a Lumi XP reward.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
@commands.has_permissions(manage_roles=True)
async def xp_reward_command_remove(self, ctx, level: int):
await xp_reward.remove_reward(ctx, level)
"""
CONFIG GROUPS
The 'config' group consists of many different configuration types, each being guild-specific and guild-only.
All commands in this group are exclusively available as slash-commands.
Only administrators can access commands in this group.
- Birthdays
- Welcome
- Boosts
- Levels
- Prefix
- Modlog channel
- Permissions preset (coming soon)
Running '/config show' will show a list of all available configuration types.
"""
config = SlashCommandGroup(
"config",
"server config commands.",
contexts={discord.InteractionContextType.guild},
default_member_permissions=discord.Permissions(administrator=True),
)
@config.command(name="show")
async def config_command(self, ctx):
await c_show.cmd(ctx)
birthday_config = config.create_subgroup(name="birthdays")
@birthday_config.command(name="channel")
async def config_birthdays_channel(self, ctx, channel: discord.TextChannel):
await c_birthday.set_birthday_channel(ctx, channel)
@birthday_config.command(name="disable")
async def config_birthdays_disable(self, ctx):
await c_birthday.disable_birthday_module(ctx)
welcome_config = config.create_subgroup(name="greetings")
@welcome_config.command(name="channel")
async def config_welcome_channel(self, ctx, channel: discord.TextChannel):
await c_greet.set_welcome_channel(ctx, channel)
@welcome_config.command(name="disable")
async def config_welcome_disable(self, ctx):
await c_greet.disable_welcome_module(ctx)
@welcome_config.command(name="template")
@discord.commands.option(name="text", type=str, max_length=2000)
async def config_welcome_template(self, ctx, text):
await c_greet.set_welcome_template(ctx, text)
boost_config = config.create_subgroup(name="boosts")
@boost_config.command(name="channel")
async def config_boosts_channel(self, ctx, channel: discord.TextChannel):
await c_boost.set_boost_channel(ctx, channel)
@boost_config.command(name="disable")
async def config_boosts_disable(self, ctx):
await c_boost.disable_boost_module(ctx)
@boost_config.command(name="template")
@discord.commands.option(name="text", type=str, max_length=2000)
async def config_boosts_template(self, ctx, text):
await c_boost.set_boost_template(ctx, text)
@boost_config.command(name="image")
@discord.commands.option(name="url", type=str, max_length=2000)
async def config_boosts_image(self, ctx, url):
await c_boost.set_boost_image(ctx, url)
level_config = config.create_subgroup(name="levels")
@level_config.command(name="channel")
async def config_level_channel(self, ctx, channel: discord.TextChannel):
await c_level.set_level_channel(ctx, channel)
@level_config.command(name="currentchannel")
async def config_level_samechannel(self, ctx):
await c_level.set_level_current_channel(ctx)
@level_config.command(name="disable")
async def config_level_disable(self, ctx):
await c_level.disable_level_module(ctx)
@level_config.command(name="enable")
async def config_level_enable(self, ctx):
await c_level.enable_level_module(ctx)
@level_config.command(name="type")
@discord.commands.option(name="type", choices=["whimsical", "generic"])
async def config_level_type(self, ctx, type):
await c_level.set_level_type(ctx, type)
@level_config.command(name="template")
async def config_level_template(self, ctx, text: str):
await c_level.set_level_template(ctx, text)
prefix_config = config.create_subgroup(name="prefix")
@prefix_config.command(name="set")
async def config_prefix_set(self, ctx, prefix: str):
await c_prefix.set_prefix(ctx, prefix)
modlog = config.create_subgroup(name="moderation")
@modlog.command(name="log")
async def config_moderation_log_channel(self, ctx, channel: discord.TextChannel):
await c_moderation.set_mod_log_channel(ctx, channel)
def setup(client):
client.add_cog(Config(client))

View file

@ -1,43 +0,0 @@
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.config_service import GuildConfig
async def set_birthday_channel(ctx, channel: discord.TextChannel):
guild_config = GuildConfig(ctx.guild.id)
guild_config.birthday_channel_id = channel.id
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_channel_set"].format(
channel.mention,
),
)
return await ctx.respond(embed=embed)
async def disable_birthday_module(ctx):
guild_config = GuildConfig(ctx.guild.id)
if not guild_config.birthday_channel_id:
embed = EmbedBuilder().create_warning_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_module_already_disabled"],
)
else:
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_module_disabled"],
)
guild_config.birthday_channel_id = None
guild_config.push()
return await ctx.respond(embed=embed)

View file

@ -1,125 +0,0 @@
import discord
import lib.formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from services.config_service import GuildConfig
async def set_boost_channel(ctx, channel: discord.TextChannel):
guild_config = GuildConfig(ctx.guild.id)
guild_config.boost_channel_id = channel.id
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_channel_set"].format(channel.mention),
)
return await ctx.respond(embed=embed)
async def disable_boost_module(ctx):
guild_config = GuildConfig(ctx.guild.id)
if not guild_config.boost_channel_id:
embed = EmbedBuilder().create_warning_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_module_already_disabled"],
)
else:
guild_config.boost_channel_id = None
guild_config.boost_message = None
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_module_disabled"],
)
return await ctx.respond(embed=embed)
async def set_boost_template(ctx, text: str):
guild_config = GuildConfig(ctx.guild.id)
guild_config.boost_message = text
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_template_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_boost_template_field"],
value=f"```{text}```",
inline=False,
)
await ctx.respond(embed=embed)
example_embed = create_boost_embed(ctx.author, text, guild_config.boost_image_url)
return await ctx.send(embed=example_embed, content=ctx.author.mention)
async def set_boost_image(ctx, image_url: str | None):
guild_config = GuildConfig(ctx.guild.id)
if image_url is None or image_url.lower() == "original":
guild_config.boost_image_url = None
guild_config.push()
image_url = None
elif not image_url.endswith(CONST.ALLOWED_IMAGE_EXTENSIONS):
raise LumiException(CONST.STRINGS["error_boost_image_url_invalid"])
elif not image_url.startswith(("http://", "https://")):
raise LumiException(CONST.STRINGS["error_image_url_invalid"])
else:
guild_config.boost_image_url = image_url
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_image_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_boost_image_field"],
value=image_url or CONST.STRINGS["config_boost_image_original"],
inline=False,
)
await ctx.respond(embed=embed)
example_embed = create_boost_embed(
ctx.author,
guild_config.boost_message,
image_url,
)
return await ctx.send(embed=example_embed, content=ctx.author.mention)
async def create_boost_embed(
member: discord.Member,
template: str | None = None,
image_url: str | None = None,
):
embed = discord.Embed(
color=discord.Color.nitro_pink(),
title=CONST.STRINGS["boost_default_title"],
description=CONST.STRINGS["boost_default_description"].format(member.name),
)
if template:
embed.description = lib.formatter.template(template, member.name)
embed.set_author(name=member.name, icon_url=member.display_avatar)
embed.set_image(url=image_url or CONST.BOOST_ICON)
embed.set_footer(
text=f"Total server boosts: {member.guild.premium_subscription_count}",
icon_url=CONST.EXCLAIM_ICON,
)

View file

@ -1,96 +0,0 @@
from typing import Optional
import discord
from discord.ext.commands import MemberConverter
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from services.config_service import GuildConfig
async def set_welcome_channel(ctx, channel: discord.TextChannel) -> None:
if not ctx.guild:
raise LumiException()
guild_config: GuildConfig = GuildConfig(ctx.guild.id)
guild_config.welcome_channel_id = channel.id
guild_config.push()
embed: discord.Embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_channel_set"].format(channel.mention),
)
await ctx.respond(embed=embed)
async def disable_welcome_module(ctx) -> None:
guild_config: GuildConfig = GuildConfig(ctx.guild.id)
if not guild_config.welcome_channel_id:
embed: discord.Embed = EmbedBuilder().create_warning_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_module_already_disabled"],
)
else:
guild_config.welcome_channel_id = None
guild_config.welcome_message = None
guild_config.push()
embed: discord.Embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_module_disabled"],
)
await ctx.respond(embed=embed)
async def set_welcome_template(ctx, text: str) -> None:
if not ctx.guild:
raise LumiException()
guild_config: GuildConfig = GuildConfig(ctx.guild.id)
guild_config.welcome_message = text
guild_config.push()
embed: discord.Embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_template_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_welcome_template_field"],
value=f"```{text}```",
inline=False,
)
await ctx.respond(embed=embed)
greet_member: discord.Member = await MemberConverter().convert(ctx, str(ctx.author))
example_embed: discord.Embed = create_greet_embed(greet_member, text)
await ctx.send(embed=example_embed, content=ctx.author.mention)
def create_greet_embed(
member: discord.Member,
template: Optional[str] = None,
) -> discord.Embed:
embed: discord.Embed = discord.Embed(
color=discord.Color.embed_background(),
description=CONST.STRINGS["greet_default_description"].format(
member.guild.name,
),
)
if template and embed.description is not None:
embed.description += CONST.STRINGS["greet_template_description"].format(
formatter.template(template, member.name),
)
embed.set_thumbnail(url=member.display_avatar.url)
return embed

View file

@ -1,137 +0,0 @@
import discord
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.config_service import GuildConfig
async def set_level_channel(ctx, channel: discord.TextChannel):
guild_config = GuildConfig(ctx.guild.id)
guild_config.level_channel_id = channel.id
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_channel_set"].format(channel.mention),
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
return await ctx.respond(embed=embed)
async def set_level_current_channel(ctx):
guild_config = GuildConfig(ctx.guild.id)
guild_config.level_channel_id = None
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_current_channel_set"],
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
return await ctx.respond(embed=embed)
async def disable_level_module(ctx):
guild_config = GuildConfig(ctx.guild.id)
guild_config.level_message_type = 0
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_disabled"],
)
return await ctx.respond(embed=embed)
async def enable_level_module(ctx):
guild_config = GuildConfig(ctx.guild.id)
if guild_config.level_message_type != 0:
embed = EmbedBuilder().create_info_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_already_enabled"],
)
else:
guild_config.level_message_type = 1
guild_config.push()
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_enabled"],
)
return await ctx.respond(embed=embed)
async def set_level_type(ctx, type: str):
guild_config = GuildConfig(ctx.guild.id)
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
)
guild_config.level_message = None
if type == "whimsical":
guild_config.level_message_type = 1
guild_config.push()
embed.description = CONST.STRINGS["config_level_type_whimsical"]
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=CONST.STRINGS["config_level_type_whimsical_example"],
inline=False,
)
else:
guild_config.level_message_type = 2
guild_config.push()
embed.description = CONST.STRINGS["config_level_type_generic"]
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=CONST.STRINGS["config_level_type_generic_example"],
inline=False,
)
return await ctx.respond(embed=embed)
async def set_level_template(ctx, text: str):
guild_config = GuildConfig(ctx.guild.id)
guild_config.level_message = text
guild_config.push()
preview = formatter.template(text, "Lucas", 15)
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_template_updated"],
)
embed.add_field(
name=CONST.STRINGS["config_level_template"],
value=f"```{text}```",
inline=False,
)
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=preview,
inline=False,
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
return await ctx.respond(embed=embed)

View file

@ -1,44 +0,0 @@
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from services.moderation.modlog_service import ModLogService
async def set_mod_log_channel(ctx, channel: discord.TextChannel):
mod_log = ModLogService()
info_embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_modlog_info_author"],
description=CONST.STRINGS["config_modlog_info_description"].format(
ctx.guild.name,
),
show_name=False,
)
info_embed.add_field(
name=CONST.STRINGS["config_modlog_info_commands_name"],
value=CONST.STRINGS["config_modlog_info_commands_value"],
inline=False,
)
info_embed.add_field(
name=CONST.STRINGS["config_modlog_info_warning_name"],
value=CONST.STRINGS["config_modlog_info_warning_value"],
inline=False,
)
try:
await channel.send(embed=info_embed)
except discord.errors.Forbidden as e:
raise LumiException(CONST.STRINGS["config_modlog_permission_error"]) from e
mod_log.set_modlog_channel(ctx.guild.id, channel.id)
success_embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_modlog_channel_set"].format(channel.mention),
)
return await ctx.respond(embed=success_embed)

View file

@ -1,35 +0,0 @@
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.config_service import GuildConfig
async def set_prefix(ctx, prefix):
if len(prefix) > 25:
embed = EmbedBuilder().create_error_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_prefix_too_long"],
)
return await ctx.respond(embed=embed)
guild_config = GuildConfig(
ctx.guild.id,
) # generate a guild_config for if it didn't already exist
GuildConfig.set_prefix(guild_config.guild_id, prefix)
embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_prefix_set"].format(prefix),
)
await ctx.respond(embed=embed)
async def get_prefix(ctx):
prefix = GuildConfig.get_prefix_from_guild_id(ctx.guild.id) if ctx.guild else "."
embed = EmbedBuilder().create_info_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_prefix_get"].format(prefix),
)
await ctx.respond(embed=embed)

View file

@ -1,75 +0,0 @@
from typing import List, Tuple, Optional
import discord
from discord import Guild
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.config_service import GuildConfig
from services.moderation.modlog_service import ModLogService
async def cmd(ctx) -> None:
guild_config: GuildConfig = GuildConfig(ctx.guild.id)
guild: Guild = ctx.guild
embed: discord.Embed = EmbedBuilder().create_success_embed(
ctx=ctx,
author_text=CONST.STRINGS["config_show_author"].format(guild.name),
thumbnail_url=guild.icon.url if guild.icon else CONST.LUMI_LOGO_TRANSPARENT,
show_name=False,
)
config_items: List[Tuple[str, bool, bool]] = [
(
CONST.STRINGS["config_show_birthdays"],
bool(guild_config.birthday_channel_id),
False,
),
(
CONST.STRINGS["config_show_new_member_greets"],
bool(guild_config.welcome_channel_id),
False,
),
(
CONST.STRINGS["config_show_boost_announcements"],
bool(guild_config.boost_channel_id),
False,
),
(
CONST.STRINGS["config_show_level_announcements"],
guild_config.level_message_type != 0,
False,
),
]
for name, enabled, default_enabled in config_items:
status: str = (
CONST.STRINGS["config_show_enabled"]
if enabled
else CONST.STRINGS["config_show_disabled"]
)
if not enabled and default_enabled:
status = CONST.STRINGS["config_show_default_enabled"]
embed.add_field(name=name, value=status, inline=False)
modlog_service: ModLogService = ModLogService()
modlog_channel_id: Optional[int] = modlog_service.fetch_modlog_channel_id(guild.id)
modlog_channel = guild.get_channel(modlog_channel_id) if modlog_channel_id else None
modlog_status: str
if modlog_channel:
modlog_status = CONST.STRINGS["config_show_moderation_log_enabled"].format(
modlog_channel.mention,
)
elif modlog_channel_id:
modlog_status = CONST.STRINGS["config_show_moderation_log_channel_deleted"]
else:
modlog_status = CONST.STRINGS["config_show_moderation_log_not_configured"]
embed.add_field(
name=CONST.STRINGS["config_show_moderation_log"],
value=modlog_status,
inline=False,
)
await ctx.respond(embed=embed)

797
modules/config/config.py Normal file
View file

@ -0,0 +1,797 @@
import discord
from discord import app_commands
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.config_service import GuildConfig
from services.modlog_service import ModLogService
from services.xp_service import XpRewardService
from ui.config import create_boost_embed, create_greet_embed
from ui.embeds import Builder
@app_commands.guild_only()
@app_commands.default_permissions(administrator=True)
class Config(commands.GroupCog, group_name="config"):
def __init__(self, bot: commands.Bot):
self.bot = bot
birthdays = app_commands.Group(name="birthdays", description="Configure the birthdays module")
boosts = app_commands.Group(name="boosts", description="Configure the boosts module")
greets = app_commands.Group(name="greets", description="Configure the greets module")
levels = app_commands.Group(name="levels", description="Configure the levels module")
moderation = app_commands.Group(name="moderation", description="Configure the moderation module")
prefix = app_commands.Group(name="prefix", description="Configure the prefix for the bot")
xpreward = app_commands.Group(name="xpreward", description="Configure the xp reward for the bot")
@app_commands.command(name="show")
async def config_help(self, interaction: discord.Interaction) -> None:
"""
Show the current configuration for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to show the config for.
"""
assert interaction.guild
guild_config: GuildConfig = GuildConfig(interaction.guild.id)
guild: discord.Guild = interaction.guild
embed: discord.Embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_show_author"].format(guild.name),
thumbnail_url=guild.icon.url if guild.icon else CONST.LUMI_LOGO_TRANSPARENT,
hide_name_in_description=True,
)
config_items: list[tuple[str, bool, bool]] = [
(
CONST.STRINGS["config_show_birthdays"],
bool(guild_config.birthday_channel_id),
False,
),
(
CONST.STRINGS["config_show_new_member_greets"],
bool(guild_config.welcome_channel_id),
False,
),
(
CONST.STRINGS["config_show_boost_announcements"],
bool(guild_config.boost_channel_id),
False,
),
(
CONST.STRINGS["config_show_level_announcements"],
guild_config.level_message_type != 0,
False,
),
]
for name, enabled, default_enabled in config_items:
status: str = CONST.STRINGS["config_show_enabled"] if enabled else CONST.STRINGS["config_show_disabled"]
if not enabled and default_enabled:
status = CONST.STRINGS["config_show_default_enabled"]
embed.add_field(name=name, value=status, inline=False)
modlog_service: ModLogService = ModLogService()
modlog_channel_id: int | None = modlog_service.fetch_modlog_channel_id(guild.id)
modlog_channel = guild.get_channel(modlog_channel_id) if modlog_channel_id else None
modlog_status: str
if modlog_channel:
modlog_status = CONST.STRINGS["config_show_moderation_log_enabled"].format(
modlog_channel.mention,
)
elif modlog_channel_id:
modlog_status = CONST.STRINGS["config_show_moderation_log_channel_deleted"]
else:
modlog_status = CONST.STRINGS["config_show_moderation_log_not_configured"]
embed.add_field(
name=CONST.STRINGS["config_show_moderation_log"],
value=modlog_status,
inline=False,
)
await interaction.response.send_message(embed=embed)
@birthdays.command(name="channel")
async def birthday_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None:
"""
Set the birthday channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the birthday channel for.
channel : discord.TextChannel
The channel to set as the birthday channel.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.birthday_channel_id = channel.id
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_channel_set"].format(
channel.mention,
),
)
await interaction.response.send_message(embed=embed)
@birthdays.command(name="disable")
async def birthday_disable(self, interaction: discord.Interaction) -> None:
"""
Disable the birthday module for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to disable the birthday module for.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
if not guild_config.birthday_channel_id:
embed = Builder.create_embed(
theme="warning",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_module_already_disabled"],
)
else:
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_birthday_module_disabled"],
)
guild_config.birthday_channel_id = None
guild_config.push()
await interaction.response.send_message(embed=embed)
@levels.command(name="channel")
async def set_level_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None:
"""
Set the level-up announcement channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the level-up announcement channel for.
channel : discord.TextChannel
The channel to set as the level-up announcement channel.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.level_channel_id = channel.id
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_channel_set"].format(channel.mention),
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
await interaction.response.send_message(embed=embed)
@boosts.command(name="channel")
async def set_boost_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None:
"""
Set the boost announcement channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the boost announcement channel for.
channel : discord.TextChannel
The channel to set as the boost announcement channel.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.boost_channel_id = channel.id
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_channel_set"].format(channel.mention),
)
await interaction.response.send_message(embed=embed)
@boosts.command(name="disable")
async def disable_boost_module(self, interaction: discord.Interaction) -> None:
"""
Disable the boost module for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to disable the boost module for.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
if not guild_config.boost_channel_id:
embed = Builder.create_embed(
theme="warning",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_module_already_disabled"],
)
else:
guild_config.boost_channel_id = None
guild_config.boost_message = None
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_module_disabled"],
)
await interaction.response.send_message(embed=embed)
@boosts.command(name="template")
async def set_boost_template(self, interaction: discord.Interaction, text: str) -> None:
"""
Set the boost message template for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the boost message template for.
text : str
The template text to set for boost messages.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.boost_message = text
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_template_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_boost_template_field"],
value=f"```{text}```",
inline=False,
)
await interaction.response.send_message(embed=embed)
example_embed = create_boost_embed(
user_name=interaction.user.name,
user_avatar_url=interaction.user.display_avatar.url,
boost_count=interaction.guild.premium_subscription_count,
template=text,
image_url=guild_config.boost_image_url,
)
await interaction.followup.send(embed=example_embed, content=interaction.user.mention)
@boosts.command(name="image")
async def set_boost_image(self, interaction: discord.Interaction, image_url: str | None) -> None:
"""
Set the boost message image for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the boost message image for.
image_url : str | None
The image URL to set for boost messages.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
if image_url is None or image_url.lower() == "original":
guild_config.boost_image_url = None
guild_config.push()
image_url = None
elif not image_url.endswith(tuple(CONST.ALLOWED_IMAGE_EXTENSIONS)):
raise ValueError(CONST.STRINGS["error_boost_image_url_invalid"])
elif not image_url.startswith(("http://", "https://")):
raise ValueError(CONST.STRINGS["error_image_url_invalid"])
else:
guild_config.boost_image_url = image_url
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_boost_image_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_boost_image_field"],
value=image_url or CONST.STRINGS["config_boost_image_original"],
inline=False,
)
await interaction.response.send_message(embed=embed)
example_embed = create_boost_embed(
user_name=interaction.user.name,
user_avatar_url=interaction.user.display_avatar.url,
boost_count=interaction.guild.premium_subscription_count,
template=guild_config.boost_message,
image_url=image_url,
)
await interaction.followup.send(embed=example_embed, content=interaction.user.mention)
@greets.command(name="channel")
async def set_welcome_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None:
"""
Set the welcome channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the welcome channel for.
channel : discord.TextChannel
The channel to set as the welcome channel.
"""
assert interaction.guild
guild_config: GuildConfig = GuildConfig(interaction.guild.id)
guild_config.welcome_channel_id = channel.id
guild_config.push()
embed: discord.Embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_channel_set"].format(channel.mention),
)
await interaction.response.send_message(embed=embed)
@greets.command(name="disable")
async def disable_welcome_module(self, interaction: discord.Interaction) -> None:
"""
Disable the welcome module for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to disable the welcome module for.
"""
assert interaction.guild
guild_config: GuildConfig = GuildConfig(interaction.guild.id)
if not guild_config.welcome_channel_id:
embed: discord.Embed = Builder.create_embed(
theme="warning",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_module_already_disabled"],
)
else:
guild_config.welcome_channel_id = None
guild_config.welcome_message = None
guild_config.push()
embed: discord.Embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_module_disabled"],
)
await interaction.response.send_message(embed=embed)
@greets.command(name="template")
async def set_welcome_template(self, interaction: discord.Interaction, text: str) -> None:
"""
Set the welcome message template for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the welcome message template for.
text : str
The welcome message template.
"""
assert interaction.guild
guild_config: GuildConfig = GuildConfig(interaction.guild.id)
guild_config.welcome_message = text
guild_config.push()
embed: discord.Embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_welcome_template_updated"],
footer_text=CONST.STRINGS["config_example_next_footer"],
)
embed.add_field(
name=CONST.STRINGS["config_welcome_template_field"],
value=f"```{text}```",
inline=False,
)
await interaction.response.send_message(embed=embed)
example_embed: discord.Embed = create_greet_embed(
user_name=interaction.user.name,
user_avatar_url=interaction.user.display_avatar.url,
guild_name=interaction.guild.name,
template=text,
)
await interaction.followup.send(embed=example_embed, content=interaction.user.mention)
@levels.command(name="current_channel")
async def set_level_current_channel(self, interaction: discord.Interaction) -> None:
"""
Set the current channel as the level-up announcement channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the current channel as the level-up announcement channel for.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.level_channel_id = None
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_current_channel_set"],
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
await interaction.response.send_message(embed=embed)
@levels.command(name="disable")
async def disable_level_module(self, interaction: discord.Interaction) -> None:
"""
Disable the level-up module for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to disable the level-up module for.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.level_message_type = 0
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_disabled"],
)
await interaction.response.send_message(embed=embed)
@levels.command(name="enable")
async def enable_level_module(self, interaction: discord.Interaction) -> None:
"""
Enable the level-up module for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to enable the level-up module for.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
if guild_config.level_message_type != 0:
embed = Builder.create_embed(
theme="info",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_already_enabled"],
)
else:
guild_config.level_message_type = 1
guild_config.push()
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_module_enabled"],
)
await interaction.response.send_message(embed=embed)
@levels.command(name="template")
async def set_level_template(self, interaction: discord.Interaction, text: str) -> None:
"""
Set the template for level-up messages for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the template for level-up messages for.
text : str
The template text to set for level-up messages.
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
guild_config.level_message = text
guild_config.push()
preview = lib.format.template(text, "Lucas", 15)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_level_template_updated"],
)
embed.add_field(
name=CONST.STRINGS["config_level_template"],
value=f"```{text}```",
inline=False,
)
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=preview,
inline=False,
)
if guild_config.level_message_type == 0:
embed.set_footer(text=CONST.STRINGS["config_level_module_disabled_warning"])
await interaction.response.send_message(embed=embed)
@levels.command(name="type")
async def set_level_type(self, interaction: discord.Interaction, level_type: str) -> None:
"""
Set the type of level-up messages for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the type of level-up messages for.
level_type : str
The type of level-up messages to set (e.g., "whimsical" or "generic").
"""
assert interaction.guild
guild_config = GuildConfig(interaction.guild.id)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
)
guild_config.level_message = None
if level_type == "whimsical":
guild_config.level_message_type = 1
guild_config.push()
embed.description = CONST.STRINGS["config_level_type_whimsical"]
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=CONST.STRINGS["config_level_type_whimsical_example"],
inline=False,
)
else:
guild_config.level_message_type = 2
guild_config.push()
embed.description = CONST.STRINGS["config_level_type_generic"]
embed.add_field(
name=CONST.STRINGS["config_level_type_example"],
value=CONST.STRINGS["config_level_type_generic_example"],
inline=False,
)
await interaction.response.send_message(embed=embed)
@moderation.command(name="log")
async def set_mod_log_channel(self, interaction: discord.Interaction, channel: discord.TextChannel) -> None:
"""
Set the moderation log channel for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the moderation log channel for.
channel : discord.TextChannel
The channel to set as the moderation log channel.
"""
assert interaction.guild
mod_log = ModLogService()
info_embed = Builder.create_embed(
theme="info",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_modlog_info_author"],
description=CONST.STRINGS["config_modlog_info_description"].format(
interaction.guild.name,
),
hide_name_in_description=True,
)
info_embed.add_field(
name=CONST.STRINGS["config_modlog_info_commands_name"],
value=CONST.STRINGS["config_modlog_info_commands_value"],
inline=False,
)
info_embed.add_field(
name=CONST.STRINGS["config_modlog_info_warning_name"],
value=CONST.STRINGS["config_modlog_info_warning_value"],
inline=False,
)
try:
await channel.send(embed=info_embed)
except discord.errors.Forbidden as e:
raise LumiException(CONST.STRINGS["config_modlog_permission_error"]) from e
mod_log.set_modlog_channel(interaction.guild.id, channel.id)
success_embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_modlog_channel_set"].format(channel.mention),
)
await interaction.response.send_message(embed=success_embed)
@prefix.command(name="set")
async def set_prefix(self, interaction: discord.Interaction, prefix: str) -> None:
"""
Set the prefix for the bot in the server.
Parameters
----------
interaction : discord.Interaction
The interaction to set the prefix for.
prefix : str
The prefix to set for the bot.
"""
assert interaction.guild
if len(prefix) > 25:
embed = Builder.create_embed(
theme="error",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_prefix_too_long"],
)
await interaction.response.send_message(embed=embed)
return
guild_config = GuildConfig(interaction.guild.id)
GuildConfig.set_prefix(guild_config.guild_id, prefix)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_prefix_set"].format(prefix),
)
await interaction.response.send_message(embed=embed)
@xpreward.command(name="show")
async def show_xpreward(self, interaction: discord.Interaction) -> None:
"""
Show the current XP rewards for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to show the XP rewards for.
"""
assert interaction.guild
level_reward = XpRewardService(interaction.guild.id)
embed = Builder.create_embed(
theme="info",
user_name=interaction.user.name,
author_text="Level Rewards",
thumbnail_url=interaction.guild.icon.url if interaction.guild.icon else CONST.LUMI_LOGO_OPAQUE,
hide_name_in_description=True,
)
if not level_reward.rewards:
embed.description = CONST.STRINGS["config_xpreward_show_no_rewards"]
else:
for level in sorted(level_reward.rewards.keys()):
role_id, persistent = level_reward.rewards[level]
role = interaction.guild.get_role(role_id)
if embed.description is None:
embed.description = ""
embed.description += f"\n**Level {level}** -> {role.mention if role else 'Role not found'}"
if persistent:
embed.description += " (persistent)"
await interaction.response.send_message(embed=embed)
@xpreward.command(name="add")
async def add_xpreward(
self,
interaction: discord.Interaction,
level: int,
role: discord.Role,
persistent: bool,
) -> None:
"""
Add an XP reward for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to add the XP reward for.
level : int
The level to add the reward for.
role : discord.Role
The role to assign as a reward.
persistent : bool
Whether the reward is persistent.
"""
assert interaction.guild
level_reward = XpRewardService(interaction.guild.id)
level_reward.add_reward(level, role.id, persistent)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_xpreward_added"].format(level, role.mention),
)
await interaction.response.send_message(embed=embed)
@xpreward.command(name="remove")
async def remove_xpreward(self, interaction: discord.Interaction, level: int) -> None:
"""
Remove an XP reward for the server.
Parameters
----------
interaction : discord.Interaction
The interaction to remove the XP reward for.
level : int
The level to remove the reward for.
"""
assert interaction.guild
level_reward = XpRewardService(interaction.guild.id)
level_reward.remove_reward(level)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["config_author"],
description=CONST.STRINGS["config_xpreward_removed"].format(level),
)
await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Config(bot))

View file

@ -1,43 +0,0 @@
import discord
from lib.constants import CONST
from services.xp_service import XpRewardService
async def show(ctx):
level_reward = XpRewardService(ctx.guild.id)
embed = discord.Embed(
color=discord.Color.embed_background(),
description="",
)
icon = ctx.guild.icon or CONST.LUMI_LOGO_OPAQUE
embed.set_author(name="Level Rewards", icon_url=icon)
for level in sorted(level_reward.rewards.keys()):
role_id, persistent = level_reward.rewards[level]
role = ctx.guild.get_role(role_id)
if embed.description is None:
embed.description = ""
embed.description += (
f"\n**Level {level}** -> {role.mention if role else 'Role not found'}"
)
if persistent:
embed.description += " (persistent)"
await ctx.respond(embed=embed)
async def add_reward(ctx, level, role_id, persistent):
level_reward = XpRewardService(ctx.guild.id)
level_reward.add_reward(level, role_id, persistent)
await show(ctx)
async def remove_reward(ctx, level):
level_reward = XpRewardService(ctx.guild.id)
level_reward.remove_reward(level)
await show(ctx)

View file

@ -1,77 +0,0 @@
import discord
from discord.ext import bridge, commands
from discord.ext.commands import guild_only
from modules.economy import balance, blackjack, daily, give, slots
class Economy(commands.Cog):
def __init__(self, client):
self.client = client
@bridge.bridge_command(
name="balance",
aliases=["bal", "$"],
description="Shows your current Lumi balance.",
help="Shows your current Lumi balance. The economy system is global, meaning your balance will be synced in "
"all servers.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
@commands.cooldown(1, 10, commands.BucketType.user)
async def balance_command(self, ctx):
return await balance.cmd(ctx)
@bridge.bridge_command(
name="blackjack",
aliases=["bj"],
description="Start a game of blackjack.",
help="Start a game of blackjack.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
async def blackjack_command(self, ctx, *, bet: int):
return await blackjack.cmd(ctx, bet)
@bridge.bridge_command(
name="daily",
aliases=["timely"],
description="Claim your daily reward.",
help="Claim your daily reward! Reset is at 7 AM EST.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
async def daily_command(self, ctx):
return await daily.cmd(ctx)
@commands.slash_command(
name="give",
description="Give a server member some cash.",
contexts={discord.InteractionContextType.guild},
)
async def give_command(self, ctx, *, user: discord.Member, amount: int):
return await give.cmd(ctx, user, amount)
@commands.command(
name="give",
help="Give a server member some cash. You can use ID or mention them.",
)
@guild_only()
async def give_command_prefixed(self, ctx, user: discord.User, *, amount: int):
return await give.cmd(ctx, user, amount)
@bridge.bridge_command(
name="slots",
aliases=["slot"],
description="Start a slots game.",
help="Spin the slots for a chance to win the jackpot!",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
@commands.cooldown(1, 5, commands.BucketType.user)
async def slots_command(self, ctx, *, bet: int):
return await slots.cmd(self, ctx, bet)
def setup(client):
client.add_cog(Economy(client))

View file

@ -1,22 +1,50 @@
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from ui.embeds import Builder
async def cmd(ctx: commands.Context[commands.Bot]) -> None:
class Balance(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.balance.usage = lib.format.generate_usage(self.balance)
@commands.hybrid_command(
name="balance",
aliases=["bal", "$"],
)
@commands.guild_only()
async def balance(
self,
ctx: commands.Context[commands.Bot],
) -> None:
"""
Check your current balance.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
ctx_currency = Currency(ctx.author.id)
balance = Currency.format(ctx_currency.balance)
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["balance_author"].format(ctx.author.name),
author_icon_url=ctx.author.display_avatar.url,
description=CONST.STRINGS["balance_cash"].format(balance),
footer_text=CONST.STRINGS["balance_footer"],
show_name=False,
hide_timestamp=True,
hide_name_in_description=True,
hide_time=True,
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Balance(bot))

View file

@ -1,26 +1,50 @@
import random
from typing import List, Tuple
from loguru import logger
from zoneinfo import ZoneInfo
import discord
from discord.ui import View
import pytz
from discord.ext import commands
from loguru import logger
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
from services.stats_service import BlackJackStats
from lib.embed_builder import EmbedBuilder
from ui.embeds import Builder
from ui.views.blackjack import BlackJackButtons
EST = pytz.timezone("US/Eastern")
EST = ZoneInfo("US/Eastern")
ACTIVE_BLACKJACK_GAMES: dict[int, bool] = {}
Card = str
Hand = List[Card]
Hand = list[Card]
async def cmd(ctx: commands.Context, bet: int) -> None:
class Blackjack(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.blackjack.usage = lib.format.generate_usage(self.blackjack)
@commands.hybrid_command(
name="blackjack",
aliases=["bj"],
)
@commands.guild_only()
async def blackjack(
self,
ctx: commands.Context[commands.Bot],
bet: int,
) -> None:
"""
Play a game of blackjack.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
bet : int
The amount to bet.
"""
if ctx.author.id in ACTIVE_BLACKJACK_GAMES:
raise LumiException(CONST.STRINGS["error_already_playing_blackjack"])
@ -33,28 +57,28 @@ async def cmd(ctx: commands.Context, bet: int) -> None:
ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True
try:
await play_blackjack(ctx, currency, bet)
await self.play_blackjack(ctx, currency, bet)
except Exception as e:
logger.exception(f"Error in blackjack game: {e}")
raise LumiException(CONST.STRINGS["error_blackjack_game_error"]) from e
finally:
del ACTIVE_BLACKJACK_GAMES[ctx.author.id]
async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) -> None:
deck = get_new_deck()
player_hand, dealer_hand = initial_deal(deck)
async def play_blackjack(self, ctx: commands.Context[commands.Bot], currency: Currency, bet: int) -> None:
deck = self.get_new_deck()
player_hand, dealer_hand = self.initial_deal(deck)
multiplier = CONST.BLACKJACK_MULTIPLIER
player_value = calculate_hand_value(player_hand)
player_value = self.calculate_hand_value(player_hand)
status = 5 if player_value == 21 else 0
view = BlackJackButtons(ctx)
playing_embed = False
response_message: discord.Message | None = None
while status == 0:
dealer_value = calculate_hand_value(dealer_hand)
dealer_value = self.calculate_hand_value(dealer_hand)
embed = create_game_embed(
embed = self.create_game_embed(
ctx,
bet,
player_hand,
@ -63,24 +87,25 @@ async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) ->
dealer_value,
)
if not playing_embed:
await ctx.respond(embed=embed, view=view, content=ctx.author.mention)
response_message = await ctx.reply(embed=embed, view=view)
playing_embed = True
else:
await ctx.edit(embed=embed, view=view)
assert response_message
await response_message.edit(embed=embed, view=view)
await view.wait()
if view.clickedHit:
player_hand.append(deal_card(deck))
player_value = calculate_hand_value(player_hand)
player_hand.append(self.deal_card(deck))
player_value = self.calculate_hand_value(player_hand)
if player_value > 21:
status = 1
break
elif player_value == 21:
if player_value == 21:
status = 2
break
elif view.clickedStand:
status = dealer_play(deck, dealer_hand, player_value)
status = self.dealer_play(deck, dealer_hand, player_value)
break
else:
currency.take_balance(bet)
@ -89,8 +114,9 @@ async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) ->
view = BlackJackButtons(ctx)
await handle_game_end(
await self.handle_game_end(
ctx,
response_message,
currency,
bet,
player_hand,
@ -100,19 +126,18 @@ async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) ->
playing_embed,
)
def initial_deal(self, deck: list[Card]) -> tuple[Hand, Hand]:
return [self.deal_card(deck) for _ in range(2)], [self.deal_card(deck)]
def initial_deal(deck: List[Card]) -> Tuple[Hand, Hand]:
return [deal_card(deck) for _ in range(2)], [deal_card(deck)]
def dealer_play(self, deck: list[Card], dealer_hand: Hand, player_value: int) -> int:
while self.calculate_hand_value(dealer_hand) <= player_value:
dealer_hand.append(self.deal_card(deck))
return 3 if self.calculate_hand_value(dealer_hand) > 21 else 4
def dealer_play(deck: List[Card], dealer_hand: Hand, player_value: int) -> int:
while calculate_hand_value(dealer_hand) <= player_value:
dealer_hand.append(deal_card(deck))
return 3 if calculate_hand_value(dealer_hand) > 21 else 4
async def handle_game_end(
ctx: commands.Context,
async def handle_game_end(
self,
ctx: commands.Context[commands.Bot],
response_message: discord.Message | None,
currency: Currency,
bet: int,
player_hand: Hand,
@ -120,45 +145,46 @@ async def handle_game_end(
status: int,
multiplier: float,
playing_embed: bool,
) -> None:
player_value = calculate_hand_value(player_hand)
dealer_value = calculate_hand_value(dealer_hand)
) -> None:
player_value = self.calculate_hand_value(player_hand)
dealer_value = self.calculate_hand_value(dealer_hand)
payout = bet * (2 if status == 5 else multiplier)
is_won = status not in [1, 4]
embed = create_end_game_embed(ctx, bet, player_value, dealer_value, payout, status)
embed = self.create_end_game_embed(ctx, bet, player_value, dealer_value, payout, status)
if playing_embed:
await ctx.edit(embed=embed, view=None)
assert response_message
await response_message.edit(embed=embed)
else:
await ctx.respond(embed=embed, view=None, content=ctx.author.mention)
await ctx.reply(embed=embed)
currency.add_balance(payout) if is_won else currency.take_balance(bet)
if is_won:
currency.add_balance(int(payout))
else:
currency.take_balance(bet)
currency.push()
BlackJackStats(
user_id=ctx.author.id,
is_won=is_won,
bet=bet,
payout=payout if is_won else 0,
payout=int(payout) if is_won else 0,
hand_player=player_hand,
hand_dealer=dealer_hand,
).push()
def create_game_embed(
ctx: commands.Context,
def create_game_embed(
self,
ctx: commands.Context[commands.Bot],
bet: int,
player_hand: Hand,
dealer_hand: Hand,
player_value: int,
dealer_value: int,
) -> discord.Embed:
) -> discord.Embed:
player_hand_str = " + ".join(player_hand)
dealer_hand_str = f"{dealer_hand[0]} + " + (
CONST.STRINGS["blackjack_dealer_hidden"]
if len(dealer_hand) < 2
else " + ".join(dealer_hand[1:])
CONST.STRINGS["blackjack_dealer_hidden"] if len(dealer_hand) < 2 else " + ".join(dealer_hand[1:])
)
description = (
@ -171,37 +197,36 @@ def create_game_embed(
f"{CONST.STRINGS['blackjack_deck_shuffled']}"
)
return EmbedBuilder.create_embed(
ctx,
return Builder.create_embed(
theme="default",
user_name=ctx.author.name,
title=CONST.STRINGS["blackjack_title"],
color=discord.Colour.embed_background(),
description=description,
footer_text=footer_text,
footer_icon_url=CONST.MUFFIN_ART,
show_name=False,
hide_timestamp=True,
hide_name_in_description=True,
)
def create_end_game_embed(
ctx: commands.Context,
def create_end_game_embed(
self,
ctx: commands.Context[commands.Bot],
bet: int,
player_value: int,
dealer_value: int,
payout: int,
payout: int | float,
status: int,
) -> discord.Embed:
embed = EmbedBuilder.create_embed(
ctx,
) -> discord.Embed:
embed = Builder.create_embed(
theme="default",
user_name=ctx.author.name,
title=CONST.STRINGS["blackjack_title"],
color=discord.Colour.embed_background(),
description=CONST.STRINGS["blackjack_description"].format(
player_value,
dealer_value,
),
footer_text=CONST.STRINGS["blackjack_footer"],
footer_icon_url=CONST.MUFFIN_ART,
show_name=False,
hide_name_in_description=True,
)
result = {
@ -213,13 +238,13 @@ def create_end_game_embed(
),
2: (
CONST.STRINGS["blackjack_won_21"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))),
discord.Color.green(),
CONST.TROPHY_ART,
),
3: (
CONST.STRINGS["blackjack_dealer_busted"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))),
discord.Color.green(),
CONST.TROPHY_ART,
),
@ -231,7 +256,7 @@ def create_end_game_embed(
),
5: (
CONST.STRINGS["blackjack_won_natural"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))),
discord.Color.green(),
CONST.TROPHY_ART,
),
@ -253,8 +278,7 @@ def create_end_game_embed(
return embed
def get_new_deck() -> List[Card]:
def get_new_deck(self) -> list[Card]:
deck = [
rank + suit
for suit in ["", "", "", ""]
@ -263,17 +287,11 @@ def get_new_deck() -> List[Card]:
random.shuffle(deck)
return deck
def deal_card(deck: List[Card]) -> Card:
def deal_card(self, deck: list[Card]) -> Card:
return deck.pop()
def calculate_hand_value(hand: Hand) -> int:
value = sum(
10 if rank in "JQK" else 11 if rank == "A" else int(rank)
for card in hand
for rank in card[:-1]
)
def calculate_hand_value(self, hand: Hand) -> int:
value = sum(10 if rank in "JQK" else 11 if rank == "A" else int(rank) for card in hand for rank in card[:-1])
aces = sum(card[0] == "A" for card in hand)
while value > 21 and aces:
value -= 10
@ -281,44 +299,5 @@ def calculate_hand_value(hand: Hand) -> int:
return value
class BlackJackButtons(View):
def __init__(self, ctx):
super().__init__(timeout=180)
self.ctx = ctx
self.clickedHit = False
self.clickedStand = False
self.clickedDoubleDown = False
async def on_timeout(self):
for child in self.children:
child.disabled = True
await self.message.edit(view=None)
@discord.ui.button(
label=CONST.STRINGS["blackjack_hit"],
style=discord.ButtonStyle.gray,
emoji=CONST.BLACKJACK_HIT_EMOJI,
)
async def hit_button_callback(self, button, interaction):
self.clickedHit = True
await interaction.response.defer()
self.stop()
@discord.ui.button(
label=CONST.STRINGS["blackjack_stand"],
style=discord.ButtonStyle.gray,
emoji=CONST.BLACKJACK_STAND_EMOJI,
)
async def stand_button_callback(self, button, interaction):
self.clickedStand = True
await interaction.response.defer()
self.stop()
async def interaction_check(self, interaction) -> bool:
if interaction.user == self.ctx.author:
return True
await interaction.response.send_message(
CONST.STRINGS["error_cant_use_buttons"],
ephemeral=True,
)
return False
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Blackjack(bot))

View file

@ -1,43 +1,85 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import lib.time
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from discord import Embed
from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency
from services.daily_service import Dailies
from ui.embeds import Builder
tz = ZoneInfo("US/Eastern")
async def cmd(ctx) -> None:
ctx_daily = Dailies(ctx.author.id)
def seconds_until(hours: int, minutes: int) -> int:
now = datetime.now(tz)
given_time = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
if given_time < now:
given_time += timedelta(days=1)
return int((given_time - now).total_seconds())
class Daily(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.daily.usage = lib.format.generate_usage(self.daily)
@commands.hybrid_command(
name="daily",
aliases=["timely"],
)
@commands.guild_only()
async def daily(
self,
ctx: commands.Context[commands.Bot],
) -> None:
"""
Claim your daily reward.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
ctx_daily: Dailies = Dailies(ctx.author.id)
if not ctx_daily.can_be_claimed():
wait_time = datetime.now() + timedelta(seconds=lib.time.seconds_until(7, 0))
unix_time = int(round(wait_time.timestamp()))
error_embed = EmbedBuilder.create_error_embed(
ctx,
wait_time: datetime = datetime.now(tz) + timedelta(seconds=seconds_until(7, 0))
unix_time: int = int(round(wait_time.timestamp()))
error_embed: Embed = Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["daily_already_claimed_author"],
description=CONST.STRINGS["daily_already_claimed_description"].format(
unix_time,
),
footer_text=CONST.STRINGS["daily_already_claimed_footer"],
)
await ctx.respond(embed=error_embed)
await ctx.send(embed=error_embed)
return
ctx_daily.streak = ctx_daily.streak + 1 if ctx_daily.streak_check() else 1
ctx_daily.claimed_at = datetime.now(tz=ctx_daily.tz)
ctx_daily.amount = 100 * 12 * (ctx_daily.streak - 1)
ctx_daily.refresh()
embed = EmbedBuilder.create_success_embed(
ctx,
embed: Embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["daily_success_claim_author"],
description=CONST.STRINGS["daily_success_claim_description"].format(
Currency.format(ctx_daily.amount),
),
footer_text=CONST.STRINGS["daily_streak_footer"].format(ctx_daily.streak)
if ctx_daily.streak > 1
else None,
footer_text=CONST.STRINGS["daily_streak_footer"].format(ctx_daily.streak) if ctx_daily.streak > 1 else None,
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Daily(bot))

View file

@ -1,13 +1,40 @@
import discord
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from ui.embeds import Builder
async def cmd(ctx: commands.Context, user: discord.User, amount: int) -> None:
class Give(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.give.usage = lib.format.generate_usage(self.give)
@commands.hybrid_command(
name="give",
)
@commands.guild_only()
async def give(
self,
ctx: commands.Context[commands.Bot],
user: discord.User,
amount: int,
) -> None:
"""
Give currency to another user.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
user : discord.User
The user to give currency to.
amount : int
The amount of currency to give.
"""
if ctx.author.id == user.id:
raise LumiException(CONST.STRINGS["give_error_self"])
if user.bot:
@ -27,13 +54,17 @@ async def cmd(ctx: commands.Context, user: discord.User, amount: int) -> None:
ctx_currency.push()
target_currency.push()
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
description=CONST.STRINGS["give_success"].format(
ctx.author.name,
Currency.format(amount),
user.name,
),
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Give(bot))

View file

@ -2,49 +2,74 @@ import asyncio
import datetime
import random
from collections import Counter
from zoneinfo import ZoneInfo
import discord
import pytz
from discord.ext import commands
from lib.constants import CONST
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency
from services.stats_service import SlotsStats
est = pytz.timezone("US/Eastern")
est = ZoneInfo("US/Eastern")
async def cmd(self, ctx, bet):
ctx_currency = Currency(ctx.author.id)
class Slots(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.slots.usage = lib.format.generate_usage(self.slots)
player_balance = ctx_currency.balance
if bet > player_balance:
raise commands.BadArgument("you don't have enough cash.")
elif bet <= 0:
raise commands.BadArgument("the bet you entered is invalid.")
results = [random.randint(0, 6) for _ in range(3)]
calculated_results = calculate_slots_results(bet, results)
(result_type, payout, multiplier) = calculated_results
is_won = result_type != "lost"
# only get the emojis once
emojis = get_emotes(self.client)
# start with default "spinning" embed
await ctx.respond(
embed=slots_spinning(ctx, 3, Currency.format_human(bet), results, emojis),
@commands.hybrid_command(
name="slots",
aliases=["slot"],
)
@commands.guild_only()
async def slots(
self,
ctx: commands.Context[commands.Bot],
bet: int,
) -> None:
"""
Play the slots machine.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
bet : int
The amount to bet.
"""
ctx_currency: Currency = Currency(ctx.author.id)
player_balance: int = ctx_currency.balance
if bet > player_balance:
raise LumiException(CONST.STRINGS["error_not_enough_cash"])
if bet <= 0:
raise LumiException(CONST.STRINGS["error_invalid_bet"])
results: list[int] = [random.randint(0, 6) for _ in range(3)]
calculated_results: tuple[str, int, float] = self.calculate_slots_results(bet, results)
result_type, payout, _ = calculated_results
is_won: bool = result_type != "lost"
emojis: dict[str, discord.Emoji | None] = self.get_emotes()
await ctx.defer()
message: discord.Message = await ctx.reply(
embed=self.slots_spinning(ctx, 3, Currency.format_human(bet), results, emojis),
)
await asyncio.sleep(1)
for i in range(2, 0, -1):
await ctx.edit(
embed=slots_spinning(ctx, i, Currency.format_human(bet), results, emojis),
)
await asyncio.sleep(1)
await message.edit(
embed=self.slots_spinning(ctx, i, Currency.format_human(bet), results, emojis),
)
# output final result
finished_output = slots_finished(
finished_output: discord.Embed = self.slots_finished(
ctx,
result_type,
Currency.format_human(bet),
@ -53,7 +78,8 @@ async def cmd(self, ctx, bet):
emojis,
)
await ctx.edit(embed=finished_output)
await asyncio.sleep(1)
await message.edit(embed=finished_output)
# user payout
if payout > 0:
@ -61,36 +87,34 @@ async def cmd(self, ctx, bet):
else:
ctx_currency.take_balance(bet)
stats = SlotsStats(
stats: SlotsStats = SlotsStats(
user_id=ctx.author.id,
is_won=is_won,
bet=bet,
payout=payout,
spin_type=result_type,
icons=results,
icons=[str(icon) for icon in results],
)
ctx_currency.push()
stats.push()
def get_emotes(self) -> dict[str, discord.Emoji | None]:
emotes: dict[str, int] = CONST.EMOTE_IDS
return {name: self.bot.get_emoji(emoji_id) for name, emoji_id in emotes.items()}
def get_emotes(client):
emotes = CONST.EMOTE_IDS
return {name: client.get_emoji(emoji_id) for name, emoji_id in emotes.items()}
def calculate_slots_results(bet, results):
result_type = None
multiplier = None
rewards = CONST.SLOTS_MULTIPLIERS
def calculate_slots_results(self, bet: int, results: list[int]) -> tuple[str, int, float]:
result_type: str = "lost"
multiplier: float = 0.0
rewards: dict[str, float] = CONST.SLOTS_MULTIPLIERS
# count occurrences of each item in the list
counts = Counter(results)
counts: Counter[int] = Counter(results)
# no icons match
if len(counts) == 3:
result_type = "lost"
multiplier = 0
multiplier = 0.0
elif len(counts) == 2:
result_type = "pair"
@ -105,29 +129,33 @@ def calculate_slots_results(bet, results):
result_type = "three_of_a_kind"
multiplier = rewards[result_type]
payout = bet * multiplier
return result_type, int(payout), multiplier
payout: int = int(bet * multiplier)
return result_type, payout, multiplier
def slots_spinning(
self,
ctx: commands.Context[commands.Bot],
spinning_icons_amount: int,
bet: str,
results: list[int],
emojis: dict[str, discord.Emoji | None],
) -> discord.Embed:
first_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[0]}_id")
second_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[1]}_id")
slots_animated_emote: discord.Emoji | None = emojis.get("slots_animated_id")
def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis):
first_slots_emote = emojis.get(f"slots_{results[0]}_id")
second_slots_emote = emojis.get(f"slots_{results[1]}_id")
slots_animated_emote = emojis.get("slots_animated_id")
current_time: str = datetime.datetime.now(est).strftime("%I:%M %p")
one: discord.Emoji | None = slots_animated_emote
two: discord.Emoji | None = slots_animated_emote
three: discord.Emoji | None = slots_animated_emote
current_time = datetime.datetime.now(est).strftime("%I:%M %p")
one = slots_animated_emote
two = slots_animated_emote
three = slots_animated_emote
if spinning_icons_amount == 3:
pass
elif spinning_icons_amount == 2:
one = first_slots_emote
elif spinning_icons_amount == 1:
if spinning_icons_amount == 1:
one = first_slots_emote
two = second_slots_emote
description = (
elif spinning_icons_amount == 2:
one = first_slots_emote
description: str = (
f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n"
f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}"
f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n"
@ -138,10 +166,10 @@ def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis):
f"{emojis['Blank']}{emojis['Blank']}❓❓❓{emojis['Blank']}{emojis['Blank']}{emojis['Blank']}"
)
embed = discord.Embed(
embed: discord.Embed = discord.Embed(
description=description,
)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
embed.set_footer(
text=f"Bet ${bet} • jackpot = x15 • {current_time}",
icon_url="https://i.imgur.com/wFsgSnr.png",
@ -149,17 +177,24 @@ def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis):
return embed
def slots_finished(
self,
ctx: commands.Context[commands.Bot],
payout_type: str,
bet: str,
payout: str,
results: list[int],
emojis: dict[str, discord.Emoji | None],
) -> discord.Embed:
first_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[0]}_id")
second_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[1]}_id")
third_slots_emote: discord.Emoji | None = emojis.get(f"slots_{results[2]}_id")
current_time: str = datetime.datetime.now(est).strftime("%I:%M %p")
def slots_finished(ctx, payout_type, bet, payout, results, emojis):
first_slots_emote = emojis.get(f"slots_{results[0]}_id")
second_slots_emote = emojis.get(f"slots_{results[1]}_id")
third_slots_emote = emojis.get(f"slots_{results[2]}_id")
current_time = datetime.datetime.now(est).strftime("%I:%M %p")
field_name = "You lost."
field_value = f"You lost **${bet}**."
color = discord.Color.red()
is_lost = True
field_name: str = "You lost."
field_value: str = f"You lost **${bet}**."
color: discord.Color = discord.Color.red()
is_lost: bool = True
if payout_type == "pair":
field_name = "Pair"
@ -182,7 +217,7 @@ def slots_finished(ctx, payout_type, bet, payout, results, emojis):
is_lost = False
color = discord.Color.green()
description = (
description: str = (
f"🎰{emojis['S_Wide']}{emojis['L_Wide']}{emojis['O_Wide']}{emojis['T_Wide']}{emojis['S_Wide']}🎰\n"
f"{emojis['CBorderTLeft']}{emojis['HBorderT']}{emojis['HBorderT']}{emojis['HBorderT']}"
f"{emojis['HBorderT']}{emojis['HBorderT']}{emojis['CBorderTRight']}\n"
@ -198,17 +233,23 @@ def slots_finished(ctx, payout_type, bet, payout, results, emojis):
f"{emojis['ECentered']}{emojis['lost']}{emojis['Blank']}"
)
else:
description += f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}"
description += (
f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}"
)
embed = discord.Embed(
embed: discord.Embed = discord.Embed(
color=color,
description=description,
)
embed.add_field(name=field_name, value=field_value, inline=False)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
embed.set_footer(
text=f"Game finished • {current_time}",
icon_url="https://i.imgur.com/wFsgSnr.png",
)
return embed
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Slots(bot))

View file

@ -1,24 +0,0 @@
from discord.ext import commands
from lib import constants, embed_builder, formatter
class Help(commands.Cog):
def __init__(self, client: commands.Bot) -> None:
self.client = client
@commands.slash_command(
name="help",
description="Get Lumi help.",
)
async def help_command(self, ctx) -> None:
prefix = formatter.get_prefix(ctx)
embed = embed_builder.EmbedBuilder.create_warning_embed(
ctx=ctx,
description=constants.CONST.STRINGS["help_use_prefix"].format(prefix),
)
await ctx.respond(embed=embed, ephemeral=True)
def setup(client: commands.Bot) -> None:
client.add_cog(Help(client))

View file

@ -1,36 +0,0 @@
import discord
from discord.ext import bridge, commands
from discord.ext.commands import guild_only
from modules.levels import leaderboard, level
class Levels(commands.Cog):
def __init__(self, client: commands.Bot) -> None:
self.client = client
@bridge.bridge_command(
name="level",
aliases=["rank", "xp"],
description="Displays your level and server rank.",
help="Displays your level and server rank.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
async def level_command(self, ctx) -> None:
await level.rank(ctx)
@bridge.bridge_command(
name="leaderboard",
aliases=["lb", "xplb"],
description="See the Lumi leaderboards.",
help="Shows three different leaderboards: levels, currency and daily streaks.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
async def leaderboard_command(self, ctx) -> None:
await leaderboard.cmd(ctx)
def setup(client: commands.Bot) -> None:
client.add_cog(Levels(client))

View file

@ -1,202 +1,52 @@
from datetime import datetime
from typing import cast
import discord
from discord.ext import bridge
from discord import Embed, Guild, Member
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.currency_service import Currency
from services.daily_service import Dailies
from services.xp_service import XpService
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView
async def cmd(ctx: bridge.Context) -> None:
if not ctx.guild:
class Leaderboard(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.leaderboard.usage = lib.format.generate_usage(self.leaderboard)
@commands.hybrid_command(
name="leaderboard",
aliases=["lb"],
)
async def leaderboard(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Get the leaderboard for the server.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
guild: Guild | None = ctx.guild
if not guild:
return
options = LeaderboardCommandOptions()
view = LeaderboardCommandView(ctx, options)
options: LeaderboardCommandOptions = LeaderboardCommandOptions()
view: LeaderboardCommandView = LeaderboardCommandView(ctx, options)
# default leaderboard
embed = EmbedBuilder.create_success_embed(
ctx=ctx,
thumbnail_url=CONST.FLOWERS_ART,
show_name=False,
author: Member = cast(Member, ctx.author)
embed: Embed = Builder.create_embed(
theme="info",
user_name=author.name,
thumbnail_url=author.display_avatar.url,
hide_name_in_description=True,
)
icon = ctx.guild.icon.url if ctx.guild.icon else CONST.FLOWERS_ART
icon: str = guild.icon.url if guild.icon else CONST.FLOWERS_ART
await view.populate_leaderboard("xp", embed, icon)
await ctx.respond(embed=embed, view=view)
await ctx.send(embed=embed, view=view)
class LeaderboardCommandOptions(discord.ui.Select):
"""
This class specifies the options for the leaderboard command:
- XP
- Currency
- Daily streak
"""
def __init__(self) -> None:
super().__init__(
placeholder="Select a leaderboard",
min_values=1,
max_values=1,
options=[
discord.SelectOption(
label="Levels",
description="See the top chatters of this server!",
emoji="🆙",
value="xp",
),
discord.SelectOption(
label="Currency",
description="Who is the richest Lumi user?",
value="currency",
emoji="💸",
),
discord.SelectOption(
label="Dailies",
description="See who has the biggest streak!",
value="dailies",
emoji="📅",
),
],
)
async def callback(self, interaction: discord.Interaction) -> None:
if self.view:
await self.view.on_select(self.values[0], interaction)
class LeaderboardCommandView(discord.ui.View):
"""
This view represents a dropdown menu to choose
what kind of leaderboard to show.
"""
def __init__(self, ctx: bridge.Context, options: LeaderboardCommandOptions) -> None:
self.ctx = ctx
self.options = options
super().__init__(timeout=180)
self.add_item(self.options)
async def on_timeout(self) -> None:
if self.message:
await self.message.edit(view=None)
self.stop()
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user and interaction.user != self.ctx.author:
embed = EmbedBuilder.create_error_embed(
ctx=self.ctx,
author_text=interaction.user.name,
description=CONST.STRINGS["xp_lb_cant_use_dropdown"],
show_name=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return False
return True
async def on_select(self, item: str, interaction: discord.Interaction) -> None:
if not self.ctx.guild:
return
embed = EmbedBuilder.create_success_embed(
ctx=self.ctx,
thumbnail_url=CONST.FLOWERS_ART,
show_name=False,
)
icon = self.ctx.guild.icon.url if self.ctx.guild.icon else CONST.FLOWERS_ART
await self.populate_leaderboard(item, embed, icon)
await interaction.response.edit_message(embed=embed)
async def populate_leaderboard(self, item: str, embed, icon):
leaderboard_methods = {
"xp": self._populate_xp_leaderboard,
"currency": self._populate_currency_leaderboard,
"dailies": self._populate_dailies_leaderboard,
}
await leaderboard_methods[item](embed, icon)
async def _populate_xp_leaderboard(self, embed, icon):
if not self.ctx.guild:
return
xp_lb = XpService.load_leaderboard(self.ctx.guild.id)
embed.set_author(name=CONST.STRINGS["xp_lb_author"], icon_url=icon)
for rank, (user_id, xp, level, xp_needed_for_next_level) in enumerate(
xp_lb[:5],
start=1,
):
try:
member = await self.ctx.guild.fetch_member(user_id)
except discord.HTTPException:
continue # skip user if not in guild
embed.add_field(
name=CONST.STRINGS["xp_lb_field_name"].format(rank, member.name),
value=CONST.STRINGS["xp_lb_field_value"].format(
level,
xp,
xp_needed_for_next_level,
),
inline=False,
)
async def _populate_currency_leaderboard(self, embed, icon):
if not self.ctx.guild:
return
cash_lb = Currency.load_leaderboard()
embed.set_author(name=CONST.STRINGS["xp_lb_currency_author"], icon_url=icon)
embed.set_thumbnail(url=CONST.TEAPOT_ART)
for user_id, balance, rank in cash_lb[:5]:
try:
member = await self.ctx.guild.fetch_member(user_id)
except discord.HTTPException:
member = None
name = member.name if member else str(user_id)
embed.add_field(
name=f"#{rank} - {name}",
value=CONST.STRINGS["xp_lb_currency_field_value"].format(
Currency.format(balance),
),
inline=False,
)
async def _populate_dailies_leaderboard(self, embed, icon):
if not self.ctx.guild:
return
daily_lb = Dailies.load_leaderboard()
embed.set_author(name=CONST.STRINGS["xp_lb_dailies_author"], icon_url=icon)
embed.set_thumbnail(url=CONST.MUFFIN_ART)
for user_id, streak, claimed_at, rank in daily_lb[:5]:
try:
member = await self.ctx.guild.fetch_member(user_id)
except discord.HTTPException:
member = None
name = member.name if member else user_id
claimed_at = datetime.fromisoformat(claimed_at).date()
embed.add_field(
name=f"#{rank} - {name}",
value=CONST.STRINGS["xp_lb_dailies_field_value"].format(
streak,
claimed_at,
),
inline=False,
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Leaderboard(bot))

View file

@ -1,26 +1,47 @@
from discord import Embed
from discord.ext import bridge
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from services.xp_service import XpService
from ui.embeds import Builder
async def rank(ctx: bridge.Context) -> None:
class Level(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.level.usage = lib.format.generate_usage(self.level)
@commands.hybrid_command(
name="level",
aliases=["rank", "lvl", "xp"],
)
async def level(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Get the level of the user.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
if not ctx.guild:
return
xp_data: XpService = XpService(ctx.author.id, ctx.guild.id)
rank: str = str(xp_data.calculate_rank())
needed_xp_for_next_level: int = XpService.xp_needed_for_next_level(xp_data.level)
needed_xp_for_next_level: int = XpService.xp_needed_for_next_level(
xp_data.level,
)
embed: Embed = EmbedBuilder.create_success_embed(
ctx=ctx,
embed: Embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
title=CONST.STRINGS["xp_level"].format(xp_data.level),
footer_text=CONST.STRINGS["xp_server_rank"].format(rank or "NaN"),
show_name=False,
thumbnail_url=ctx.author.display_avatar.url,
hide_name_in_description=True,
)
embed.add_field(
name=CONST.STRINGS["xp_progress"],
@ -28,4 +49,8 @@ async def rank(ctx: bridge.Context) -> None:
inline=False,
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Level(bot))

View file

@ -1,135 +0,0 @@
from datetime import datetime
import discord
from discord.commands import SlashCommandGroup
from discord.ext import bridge, commands, tasks
from discord.ext.commands import guild_only
from Client import LumiBot
from modules.config import c_prefix
from modules.misc import avatar, backup, info, introduction, invite, ping, xkcd
class Misc(commands.Cog):
def __init__(self, client: LumiBot) -> None:
self.client: LumiBot = client
self.start_time: datetime = datetime.now()
self.do_backup.start()
@tasks.loop(hours=1)
async def do_backup(self) -> None:
await backup.backup()
@bridge.bridge_command(
name="avatar",
aliases=["av"],
description="Get a user's avatar.",
help="Get a user's avatar.",
contexts={discord.InteractionContextType.guild},
)
@guild_only()
async def avatar(self, ctx, user: discord.Member) -> None:
return await avatar.get_avatar(ctx, user)
@bridge.bridge_command(
name="ping",
aliases=["p", "status"],
description="Simple status check.",
help="Simple status check.",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
async def ping(self, ctx) -> None:
await ping.ping(self, ctx)
@bridge.bridge_command(
name="uptime",
description="See Lumi's uptime since the last update.",
help="See how long Lumi has been online since his last update.",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
async def uptime(self, ctx) -> None:
await ping.uptime(self, ctx, self.start_time)
@bridge.bridge_command(
name="invite",
description="Generate an invite link.",
help="Generate a link to invite Lumi to your own server!",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
async def invite_command(self, ctx) -> None:
await invite.cmd(ctx)
@bridge.bridge_command(
name="prefix",
description="See the server's current prefix.",
help="See the server's current prefix.",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
async def prefix_command(self, ctx) -> None:
await c_prefix.get_prefix(ctx)
@bridge.bridge_command(
name="info",
aliases=["stats"],
description="Shows basic Lumi stats.",
help="Shows basic Lumi stats.",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
async def info_command(self, ctx) -> None:
unix_timestamp: int = int(round(self.start_time.timestamp()))
await info.cmd(self, ctx, unix_timestamp)
@bridge.bridge_command(
name="introduction",
aliases=["intro", "introduce"],
description="This command can only be used in DMs.",
help="Introduce yourself. For now this command "
"can only be done in ONE server and only in Lumi's DMs.",
contexts={discord.InteractionContextType.bot_dm},
)
@commands.dm_only()
async def intro_command(self, ctx) -> None:
await introduction.cmd(self, ctx)
"""
xkcd submodule - slash command only
"""
xkcd: SlashCommandGroup = SlashCommandGroup(
"xkcd",
"A web comic of romance, sarcasm, math, and language.",
contexts={
discord.InteractionContextType.guild,
discord.InteractionContextType.bot_dm,
},
)
@xkcd.command(name="latest", description="Get the latest xkcd comic.")
async def xkcd_latest(self, ctx) -> None:
await xkcd.print_comic(ctx, latest=True)
@xkcd.command(name="random", description="Get a random xkcd comic.")
async def xkcd_random(self, ctx) -> None:
await xkcd.print_comic(ctx)
@xkcd.command(name="search", description="Search for a xkcd comic by ID.")
async def xkcd_search(self, ctx, *, id: int) -> None:
await xkcd.print_comic(ctx, number=id)
def setup(client: LumiBot) -> None:
client.add_cog(Misc(client))

View file

@ -1,39 +1,11 @@
from io import BytesIO
from typing import Optional
import discord
import httpx
from discord import File, Member
from discord.ext import bridge
from discord import File
from discord.ext import commands
client: httpx.AsyncClient = httpx.AsyncClient()
async def get_avatar(ctx: bridge.Context, member: Member) -> None:
"""
Get the avatar of a member.
Parameters:
-----------
ctx : ApplicationContext
The discord context object.
member : Member
The member to get the avatar of.
"""
guild_avatar: Optional[str] = (
member.guild_avatar.url if member.guild_avatar else None
)
profile_avatar: Optional[str] = member.avatar.url if member.avatar else None
files: list[File] = [
await create_avatar_file(avatar)
for avatar in [guild_avatar, profile_avatar]
if avatar
]
if files:
await ctx.respond(files=files)
else:
await ctx.respond(content="member has no avatar.")
import lib.format
async def create_avatar_file(url: str) -> File:
@ -50,9 +22,52 @@ async def create_avatar_file(url: str) -> File:
File
The discord file.
"""
client: httpx.AsyncClient = httpx.AsyncClient()
response: httpx.Response = await client.get(url, timeout=10)
response.raise_for_status()
image_data: bytes = response.content
image_file: BytesIO = BytesIO(image_data)
image_file.seek(0)
return File(image_file, filename="avatar.png")
class Avatar(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.avatar.usage = lib.format.generate_usage(self.avatar)
@commands.hybrid_command(
name="avatar",
aliases=["av"],
)
async def avatar(
self,
ctx: commands.Context[commands.Bot],
member: discord.Member | None = None,
) -> None:
"""
Get the avatar of a member.
Parameters
-----------
ctx : ApplicationContext
The discord context object.
member : Member
The member to get the avatar of.
"""
if member is None:
member = await commands.MemberConverter().convert(ctx, str(ctx.author.id))
guild_avatar: str | None = member.guild_avatar.url if member.guild_avatar else None
profile_avatar: str | None = member.avatar.url if member.avatar else None
files: list[File] = [await create_avatar_file(avatar) for avatar in [guild_avatar, profile_avatar] if avatar]
if files:
await ctx.send(files=files)
else:
await ctx.send(content="member has no avatar.")
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Avatar(bot))

View file

@ -1,19 +1,22 @@
import asyncio
import subprocess
from datetime import datetime
from typing import List, Optional
from pathlib import Path
from zoneinfo import ZoneInfo
import dropbox
from dropbox.files import FileMetadata
import dropbox # type: ignore
from discord.ext import commands, tasks
from dropbox.files import FileMetadata # type: ignore
from loguru import logger
from lib.constants import CONST
from lib.const import CONST
# Initialize Dropbox client if instance is "main"
_dbx: Optional[dropbox.Dropbox] = None
_dbx: dropbox.Dropbox | None = None
if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
_app_key: Optional[str] = CONST.DBX_APP_KEY
_dbx_token: Optional[str] = CONST.DBX_TOKEN
_app_secret: Optional[str] = CONST.DBX_APP_SECRET
_app_key: str | None = CONST.DBX_APP_KEY
_dbx_token: str | None = CONST.DBX_TOKEN
_app_secret: str | None = CONST.DBX_APP_SECRET
_dbx = dropbox.Dropbox(
app_key=_app_key,
@ -22,36 +25,42 @@ if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
)
async def create_db_backup() -> None:
if not _dbx:
raise ValueError("Dropbox client is not initialized")
backup_name: str = datetime.today().strftime("%Y-%m-%d_%H%M") + "_lumi.sql"
def run_db_dump() -> None:
command: str = (
f"mariadb-dump --user={CONST.MARIADB_USER} --password={CONST.MARIADB_PASSWORD} "
f"--host=db --single-transaction --all-databases > ./db/migrations/100-dump.sql"
)
subprocess.check_output(command, shell=True)
with open("./db/migrations/100-dump.sql", "rb") as f:
_dbx.files_upload(f.read(), f"/{backup_name}")
def upload_backup_to_dropbox(backup_name: str) -> None:
with Path("./db/migrations/100-dump.sql").open("rb") as f:
if _dbx:
_dbx.files_upload(f.read(), f"/{backup_name}") # type: ignore
async def create_db_backup() -> None:
if not _dbx:
msg = "Dropbox client is not initialized"
raise ValueError(msg)
backup_name: str = datetime.now(ZoneInfo("US/Eastern")).strftime("%Y-%m-%d_%H%M") + "_lumi.sql"
run_db_dump()
upload_backup_to_dropbox(backup_name)
async def backup_cleanup() -> None:
if not _dbx:
raise ValueError("Dropbox client is not initialized")
msg = "Dropbox client is not initialized"
raise ValueError(msg)
result = _dbx.files_list_folder("")
result = _dbx.files_list_folder("") # type: ignore
all_backup_files: List[str] = [
entry.name
for entry in result.entries
if isinstance(entry, FileMetadata) # type: ignore
]
all_backup_files: list[str] = [entry.name for entry in result.entries if isinstance(entry, FileMetadata)] # type: ignore
for file in sorted(all_backup_files)[:-48]:
_dbx.files_delete_v2("/" + file)
_dbx.files_delete_v2(f"/{file}") # type: ignore
async def backup() -> None:
@ -65,3 +74,22 @@ async def backup() -> None:
logger.error(f"Backup failed: {error}")
else:
logger.debug('No backup, instance not "MAIN".')
class Backup(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.do_backup.start()
@tasks.loop(hours=1)
async def do_backup(self) -> None:
await backup()
@do_backup.before_loop
async def before_do_backup(self) -> None:
await self.bot.wait_until_ready()
await asyncio.sleep(30)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Backup(bot))

View file

@ -3,40 +3,48 @@ import platform
import discord
import psutil
from discord.ext import bridge
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.currency_service import Currency
from services.stats_service import BlackJackStats
import lib.format
from lib.const import CONST
from ui.embeds import Builder
async def cmd(self, ctx: bridge.Context, unix_timestamp: int) -> None:
class Info(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.info.usage = lib.format.generate_usage(self.info)
@commands.hybrid_command(
name="info",
)
async def info(self, ctx: commands.Context[commands.Bot]) -> None:
memory_usage_in_mb: float = psutil.Process().memory_info().rss / (1024 * 1024)
total_rows: str = Currency.format(BlackJackStats.get_total_rows_count())
# total_rows: str = Currency.format(BlackJackStats.get_total_rows_count())
description: str = "".join(
[
CONST.STRINGS["info_uptime"].format(unix_timestamp),
CONST.STRINGS["info_latency"].format(round(1000 * self.client.latency)),
CONST.STRINGS["info_latency"].format(round(1000 * self.bot.latency)),
CONST.STRINGS["info_memory"].format(memory_usage_in_mb),
CONST.STRINGS["info_system"].format(platform.system(), os.name),
CONST.STRINGS["info_api_version"].format(discord.__version__),
CONST.STRINGS["info_database_records"].format(total_rows),
# CONST.STRINGS["info_database_records"].format(total_rows),
],
)
embed: discord.Embed = EmbedBuilder.create_success_embed(
ctx,
embed: discord.Embed = Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=f"{CONST.TITLE} v{CONST.VERSION}",
author_url=CONST.REPO_URL,
description=description,
footer_text=CONST.STRINGS["info_service_footer"],
show_name=False,
thumbnail_url=CONST.LUMI_LOGO_OPAQUE,
hide_name_in_description=True,
)
embed.set_author(
name=f"{CONST.TITLE} v{CONST.VERSION}",
url=CONST.REPO_URL,
icon_url=CONST.CHECK_ICON,
)
embed.set_thumbnail(url=CONST.LUMI_LOGO_OPAQUE)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Info(bot))

View file

@ -1,27 +1,40 @@
import asyncio
from typing import Dict, Optional
import discord
from discord.ext import bridge
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.interactions.introduction import (
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.introduction import (
IntroductionFinishButtons,
IntroductionStartButtons,
)
async def cmd(self, ctx: bridge.Context) -> None:
guild: Optional[discord.Guild] = self.client.get_guild(CONST.KRC_GUILD_ID)
member: Optional[discord.Member] = (
guild.get_member(ctx.author.id) if guild else None
class Introduction(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.introduction.usage = lib.format.generate_usage(self.introduction)
@commands.hybrid_command(name="introduction", aliases=["intro"])
async def introduction(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Introduction command.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
guild: discord.Guild | None = self.bot.get_guild(
CONST.INTRODUCTIONS_GUILD_ID,
)
member: discord.Member | None = guild.get_member(ctx.author.id) if guild else None
if not guild or not member:
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
await ctx.send(
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_no_guild_author"],
description=CONST.STRINGS["intro_no_guild"],
footer_text=CONST.STRINGS["intro_service_name"],
@ -29,18 +42,19 @@ async def cmd(self, ctx: bridge.Context) -> None:
)
return
question_mapping: Dict[str, str] = CONST.KRC_QUESTION_MAPPING
channel: Optional[discord.abc.GuildChannel] = guild.get_channel(
CONST.KRC_INTRO_CHANNEL_ID,
question_mapping: dict[str, str] = CONST.INTRODUCTIONS_QUESTION_MAPPING
channel: discord.abc.GuildChannel | None = guild.get_channel(
CONST.INTRODUCTIONS_CHANNEL_ID,
)
if not channel or isinstance(
channel,
(discord.ForumChannel, discord.CategoryChannel),
discord.ForumChannel | discord.CategoryChannel,
):
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
await ctx.send(
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_no_channel_author"],
description=CONST.STRINGS["intro_no_channel"],
footer_text=CONST.STRINGS["intro_service_name"],
@ -48,24 +62,25 @@ async def cmd(self, ctx: bridge.Context) -> None:
)
return
view: IntroductionStartButtons | IntroductionFinishButtons = (
IntroductionStartButtons(ctx)
)
await ctx.respond(
embed=EmbedBuilder.create_embed(
ctx,
view: IntroductionStartButtons | IntroductionFinishButtons = IntroductionStartButtons(ctx)
await ctx.send(
embed=Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_service_name"],
description=CONST.STRINGS["intro_start"].format(channel.mention),
footer_text=CONST.STRINGS["intro_start_footer"],
),
view=view,
)
await view.wait()
if view.clickedStop:
if view.clicked_stop:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_stopped_author"],
description=CONST.STRINGS["intro_stopped"],
footer_text=CONST.STRINGS["intro_service_name"],
@ -73,7 +88,7 @@ async def cmd(self, ctx: bridge.Context) -> None:
)
return
if view.clickedStart:
if view.clicked_start:
def check(message: discord.Message) -> bool:
return message.author == ctx.author and isinstance(
@ -81,12 +96,13 @@ async def cmd(self, ctx: bridge.Context) -> None:
discord.DMChannel,
)
answer_mapping: Dict[str, str] = {}
answer_mapping: dict[str, str] = {}
for key, question in question_mapping.items():
await ctx.send(
embed=EmbedBuilder.create_embed(
ctx,
embed=Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=key,
description=question,
footer_text=CONST.STRINGS["intro_question_footer"],
@ -94,7 +110,7 @@ async def cmd(self, ctx: bridge.Context) -> None:
)
try:
answer: discord.Message = await self.client.wait_for(
answer: discord.Message = await self.bot.wait_for(
"message",
check=check,
timeout=300,
@ -103,8 +119,9 @@ async def cmd(self, ctx: bridge.Context) -> None:
if len(answer_content) > 200:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_too_long_author"],
description=CONST.STRINGS["intro_too_long"],
footer_text=CONST.STRINGS["intro_service_name"],
@ -114,10 +131,11 @@ async def cmd(self, ctx: bridge.Context) -> None:
answer_mapping[key] = answer_content
except asyncio.TimeoutError:
except TimeoutError:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_timeout_author"],
description=CONST.STRINGS["intro_timeout"],
footer_text=CONST.STRINGS["intro_service_name"],
@ -126,12 +144,12 @@ async def cmd(self, ctx: bridge.Context) -> None:
return
description: str = "".join(
CONST.STRINGS["intro_preview_field"].format(key, value)
for key, value in answer_mapping.items()
CONST.STRINGS["intro_preview_field"].format(key, value) for key, value in answer_mapping.items()
)
preview: discord.Embed = EmbedBuilder.create_embed(
ctx,
preview: discord.Embed = Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=ctx.author.name,
author_icon_url=ctx.author.display_avatar.url,
description=description,
@ -142,14 +160,16 @@ async def cmd(self, ctx: bridge.Context) -> None:
await ctx.send(embed=preview, view=view)
await view.wait()
if view.clickedConfirm:
if view.clicked_confirm:
await channel.send(
embed=preview,
content=CONST.STRINGS["intro_content"].format(ctx.author.mention),
)
await ctx.send(
embed=EmbedBuilder.create_embed(
ctx,
embed=Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_post_confirmation_author"],
description=CONST.STRINGS["intro_post_confirmation"].format(
channel.mention,
),
@ -157,10 +177,15 @@ async def cmd(self, ctx: bridge.Context) -> None:
)
else:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["intro_stopped_author"],
description=CONST.STRINGS["intro_stopped"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Introduction(bot))

View file

@ -1,27 +1,36 @@
from discord import ButtonStyle
from discord.ext import bridge
from discord.ui import Button, View
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from ui.embeds import Builder
from ui.views.invite import InviteButton
async def cmd(ctx: bridge.BridgeContext) -> None:
await ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
class Invite(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.invite.usage = lib.format.generate_usage(self.invite)
@commands.hybrid_command(name="invite", aliases=["inv"])
async def invite(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Invite command.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
await ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["invite_author"],
description=CONST.STRINGS["invite_description"],
),
view=InviteButton(),
)
class InviteButton(View):
def __init__(self) -> None:
super().__init__(timeout=None)
invite_button: Button = Button(
label=CONST.STRINGS["invite_button_text"],
style=ButtonStyle.url,
url=CONST.INVITE_LINK,
)
self.add_item(invite_button)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Invite(bot))

View file

@ -1,32 +1,37 @@
from datetime import datetime
from discord.ext import commands
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from ui.embeds import Builder
async def ping(self, ctx: bridge.BridgeContext) -> None:
embed = EmbedBuilder.create_success_embed(
ctx,
class Ping(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.ping.usage = lib.format.generate_usage(self.ping)
@commands.hybrid_command(name="ping")
async def ping(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Ping command.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["ping_author"],
description=CONST.STRINGS["ping_pong"],
footer_text=CONST.STRINGS["ping_footer"].format(
round(1000 * self.client.latency),
round(1000 * self.bot.latency),
),
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
async def uptime(self, ctx: bridge.BridgeContext, start_time: datetime) -> None:
unix_timestamp: int = int(round(self.start_time.timestamp()))
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["ping_author"],
description=CONST.STRINGS["ping_uptime"].format(unix_timestamp),
footer_text=CONST.STRINGS["ping_footer"].format(
round(1000 * self.client.latency),
),
)
await ctx.respond(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Ping(bot))

43
modules/misc/uptime.py Normal file
View file

@ -0,0 +1,43 @@
from datetime import datetime
import discord
from discord import Embed
from discord.ext import commands
import lib.format
from lib.const import CONST
from ui.embeds import Builder
class Uptime(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot: commands.Bot = bot
self.start_time: datetime = discord.utils.utcnow()
self.uptime.usage = lib.format.generate_usage(self.uptime)
@commands.hybrid_command(name="uptime")
async def uptime(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Uptime command.
Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
unix_timestamp: int = int(self.start_time.timestamp())
embed: Embed = Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=CONST.STRINGS["ping_author"],
description=CONST.STRINGS["ping_uptime"].format(unix_timestamp),
footer_text=CONST.STRINGS["ping_footer"].format(
int(self.bot.latency * 1000),
),
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Uptime(bot))

View file

@ -1,18 +1,18 @@
from typing import Optional
import discord
from discord import app_commands
from discord.ext import commands
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.xkcd_service import Client, HttpError
from lib.const import CONST
from ui.embeds import Builder
from wrappers.xkcd import Client, HttpError
_xkcd = Client()
async def print_comic(
ctx: bridge.Context,
interaction: discord.Interaction,
latest: bool = False,
number: Optional[int] = None,
number: int | None = None,
) -> None:
try:
if latest:
@ -22,9 +22,9 @@ async def print_comic(
else:
comic = _xkcd.get_random_comic(raw_comic_image=True)
await ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
await interaction.response.send_message(
embed=Builder.create_embed(
theme="info",
author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title),
description=CONST.STRINGS["xkcd_description"].format(
comic.explanation_url,
@ -32,16 +32,64 @@ async def print_comic(
),
footer_text=CONST.STRINGS["xkcd_footer"],
image_url=comic.image_url,
show_name=False,
),
)
except HttpError:
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
await interaction.response.send_message(
embed=Builder.create_embed(
theme="error",
author_text=CONST.STRINGS["xkcd_not_found_author"],
description=CONST.STRINGS["xkcd_not_found"],
footer_text=CONST.STRINGS["xkcd_footer"],
),
)
class Xkcd(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
xkcd = app_commands.Group(name="xkcd", description="Get the latest xkcd comic")
@xkcd.command(name="latest")
async def xkcd_latest(self, interaction: discord.Interaction) -> None:
"""
Get the latest xkcd comic.
Parameters
----------
interaction : discord.Interaction
The interaction to get the latest comic for.
"""
await print_comic(interaction, latest=True)
@xkcd.command(name="random")
async def xkcd_random(self, interaction: discord.Interaction) -> None:
"""
Get a random xkcd comic.
Parameters
----------
interaction : discord.Interaction
The interaction to get the random comic for.
"""
await print_comic(interaction)
@xkcd.command(name="search")
async def xkcd_search(self, interaction: discord.Interaction, comic_id: int) -> None:
"""
Get a specific xkcd comic.
Parameters
----------
interaction : discord.Interaction
The interaction to get the comic for.
comic_id : int
The ID of the comic to get.
"""
await print_comic(interaction, number=comic_id)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Xkcd(bot))

View file

@ -1,195 +0,0 @@
import discord
from discord.ext import bridge, commands
from discord.ext.commands import guild_only
from modules.moderation import ban, cases, kick, softban, timeout, warn
class Moderation(commands.Cog):
def __init__(self, client):
self.client = client
@bridge.bridge_command(
name="ban",
aliases=["b"],
description="Ban a user from the server.",
help="Bans a user from the server, you can use ID or mention them.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@guild_only()
async def ban_command(
self,
ctx,
target: discord.User,
*,
reason: str | None = None,
):
await ban.ban_user(self, ctx, target, reason)
@bridge.bridge_command(
name="case",
aliases=["c"],
description="View a case by its number.",
help="Views a case by its number in the server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(view_audit_log=True)
@guild_only()
async def case_command(self, ctx, case_number: int):
await cases.view_case_by_number(ctx, ctx.guild.id, case_number)
@bridge.bridge_command(
name="cases",
aliases=["caselist"],
description="View all cases in the server.",
help="Lists all moderation cases for the current server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(view_audit_log=True)
@guild_only()
async def cases_command(self, ctx):
await cases.view_all_cases_in_guild(ctx, ctx.guild.id)
@bridge.bridge_command(
name="editcase",
aliases=["uc", "ec"],
description="Edit the reason for a case.",
help="Updates the reason for a specific case in the server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(view_audit_log=True)
@guild_only()
async def edit_case_command(self, ctx, case_number: int, *, new_reason: str):
await cases.edit_case_reason(ctx, ctx.guild.id, case_number, new_reason)
@bridge.bridge_command(
name="kick",
aliases=["k"],
description="Kick a user from the server.",
help="Kicks a user from the server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(kick_members=True)
@commands.bot_has_permissions(kick_members=True)
@guild_only()
async def kick_command(
self,
ctx,
target: discord.Member,
*,
reason: str | None = None,
):
await kick.kick_user(self, ctx, target, reason)
@bridge.bridge_command(
name="modcases",
aliases=["moderatorcases", "mc"],
description="View all cases by a specific moderator.",
help="Lists all moderation cases handled by a specific moderator in the current server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(view_audit_log=True)
@guild_only()
async def moderator_cases_command(self, ctx, moderator: discord.Member):
await cases.view_all_cases_by_mod(ctx, ctx.guild.id, moderator)
@bridge.bridge_command(
name="softban",
aliases=["sb"],
description="Softban a user from the server.",
help="Softbans a user from the server (ban and immediately unban to delete messages).",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@guild_only()
async def softban_command(
self,
ctx,
target: discord.Member,
*,
reason: str | None = None,
):
await softban.softban_user(ctx, target, reason)
@bridge.bridge_command(
name="timeout",
aliases=["t", "to"],
description="Timeout a user.",
help="Timeouts a user in the server for a specified duration.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(moderate_members=True)
@commands.bot_has_permissions(moderate_members=True)
@guild_only()
async def timeout_command(
self,
ctx,
target: discord.Member,
duration: str,
*,
reason: str | None = None,
):
await timeout.timeout_user(self, ctx, target, duration, reason)
@bridge.bridge_command(
name="unban",
aliases=["ub", "pardon"],
description="Unbans a user from the server.",
help="Unbans a user from the server, you can use ID or provide their username.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@guild_only()
async def unban_command(
self,
ctx,
target: discord.User,
*,
reason: str | None = None,
):
await ban.unban_user(ctx, target, reason)
@bridge.bridge_command(
name="untimeout",
aliases=["removetimeout", "rto", "uto"],
description="Remove timeout from a user.",
help="Removes the timeout from a user in the server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(moderate_members=True)
@commands.bot_has_permissions(moderate_members=True)
@guild_only()
async def untimeout_command(
self,
ctx,
target: discord.Member,
*,
reason: str | None = None,
):
await timeout.untimeout_user(ctx, target, reason)
@bridge.bridge_command(
name="warn",
aliases=["w"],
description="Warn a user.",
help="Warns a user in the server.",
contexts={discord.InteractionContextType.guild},
)
@bridge.has_permissions(kick_members=True)
@guild_only()
async def warn_command(
self,
ctx,
target: discord.Member,
*,
reason: str | None = None,
):
await warn.warn_user(ctx, target, reason)
def setup(client):
client.add_cog(Moderation(client))

View file

@ -1,88 +1,117 @@
import asyncio
from typing import Optional
import contextlib
from typing import cast
import discord
from discord.ext.commands import MemberConverter
from discord.ext import commands
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
from ui.embeds import Builder
async def ban_user(cog, ctx, target: discord.User, reason: Optional[str] = None):
# see if user is in guild
member = await MemberConverter().convert(ctx, str(target.id))
class Ban(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.ban.usage = lib.format.generate_usage(self.ban)
self.unban.usage = lib.format.generate_usage(self.unban)
@commands.hybrid_command(name="ban", aliases=["b"])
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@commands.guild_only()
async def ban(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member | discord.User,
*,
reason: str | None = None,
) -> None:
"""
Ban a user from the guild.
Parameters
----------
target: discord.Member | discord.User
The user to ban.
reason: str | None
The reason for the ban.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
output_reason = reason or CONST.STRINGS["mod_no_reason"]
formatted_reason = CONST.STRINGS["mod_reason"].format(
ctx.author.name,
lib.format.shorten(output_reason, 200),
)
# member -> user is in the guild, check role hierarchy
if member:
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(member, ctx.author, bot_member)
dm_sent = False
if isinstance(target, discord.Member):
bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
try:
await member.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
with contextlib.suppress(discord.HTTPException, discord.Forbidden):
await target.send(
embed=Builder.create_embed(
theme="warning",
user_name=target.name,
author_text=CONST.STRINGS["mod_banned_author"],
description=CONST.STRINGS["mod_ban_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
hide_name_in_description=True,
),
)
dm_sent = True
except (discord.HTTPException, discord.Forbidden):
dm_sent = False
await ctx.guild.ban(target, reason=formatted_reason)
await member.ban(
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_banned_author"],
description=CONST.STRINGS["mod_banned_user"].format(target.id),
footer_text=CONST.STRINGS["mod_dm_sent"]
if dm_sent
else CONST.STRINGS["mod_dm_not_sent"],
),
description=CONST.STRINGS["mod_banned_user"].format(target.name),
)
create_case_task = create_case(ctx, target, "BAN", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
if isinstance(target, discord.Member):
embed.set_footer(text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"])
# not a member in this guild, so ban right away
else:
await ctx.guild.ban(
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
await asyncio.gather(
ctx.send(embed=embed),
create_case(ctx, cast(discord.User, target), "BAN", reason),
return_exceptions=True,
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_banned_author"],
description=CONST.STRINGS["mod_banned_user"].format(target.id),
),
)
create_case_task = create_case(ctx, target, "BAN", reason)
await asyncio.gather(respond_task, create_case_task)
@commands.hybrid_command(name="unban")
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@commands.guild_only()
async def unban(
self,
ctx: commands.Context[commands.Bot],
target: discord.User,
*,
reason: str | None = None,
) -> None:
"""
Unban a user from the guild.
Parameters
----------
target: discord.User
The user to unban.
reason: str | None
The reason for the unban.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
async def unban_user(ctx, target: discord.User, reason: Optional[str] = None):
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
@ -90,25 +119,31 @@ async def unban_user(ctx, target: discord.User, reason: Optional[str] = None):
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_unbanned_author"],
description=CONST.STRINGS["mod_unbanned"].format(target.id),
description=CONST.STRINGS["mod_unbanned"].format(target.name),
),
)
create_case_task = create_case(ctx, target, "UNBAN", reason)
await asyncio.gather(respond_task, create_case_task)
except (discord.NotFound, discord.HTTPException):
return await ctx.respond(
embed=EmbedBuilder.create_warning_embed(
ctx,
await ctx.send(
embed=Builder.create_embed(
theme="error",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_not_banned_author"],
description=CONST.STRINGS["mod_not_banned"].format(target.id),
),
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Ban(bot))

View file

@ -1,34 +1,90 @@
import asyncio
import discord
from discord.ext import pages
from discord.ext.commands import UserConverter
from discord.ext import commands
from reactionmenu import ViewButton, ViewMenu
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import format_case_number
from modules.moderation.utils.case_embed import (
import lib.format
from lib.case_handler import edit_case_modlog
from lib.const import CONST
from lib.exceptions import LumiException
from lib.format import format_case_number
from services.case_service import CaseService
from ui.cases import (
create_case_embed,
create_case_list_embed,
)
from modules.moderation.utils.case_handler import edit_case_modlog
from services.moderation.case_service import CaseService
from ui.embeds import Builder
case_service = CaseService()
async def view_case_by_number(ctx, guild_id: int, case_number: int):
def create_no_cases_embed(ctx: commands.Context[commands.Bot], author_text: str, description: str) -> discord.Embed:
return Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=author_text,
description=description,
)
def create_case_view_menu(ctx: commands.Context[commands.Bot]) -> ViewMenu:
menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed, all_can_click=True, delete_on_timeout=True)
buttons = [
(ViewButton.ID_GO_TO_FIRST_PAGE, "⏮️"),
(ViewButton.ID_PREVIOUS_PAGE, ""),
(ViewButton.ID_NEXT_PAGE, ""),
(ViewButton.ID_GO_TO_LAST_PAGE, "⏭️"),
]
for custom_id, emoji in buttons:
menu.add_button(ViewButton(style=discord.ButtonStyle.secondary, custom_id=custom_id, emoji=emoji))
return menu
class Cases(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.view_case_by_number.usage = lib.format.generate_usage(self.view_case_by_number)
self.view_all_cases_in_guild.usage = lib.format.generate_usage(self.view_all_cases_in_guild)
self.view_all_cases_by_mod.usage = lib.format.generate_usage(self.view_all_cases_by_mod)
self.edit_case_reason.usage = lib.format.generate_usage(self.edit_case_reason)
@commands.hybrid_command(name="case", aliases=["c", "ca"])
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def view_case_by_number(
self,
ctx: commands.Context[commands.Bot],
case_number: int | None = None,
) -> None:
"""
View a specific case by number or all cases if no number is provided.
Parameters
----------
case_number: int | None
The case number to view. If None, view all cases.
"""
if case_number is None:
await ctx.invoke(self.view_all_cases_in_guild)
return
guild_id = ctx.guild.id if ctx.guild else 0
case = case_service.fetch_case_by_guild_and_number(guild_id, case_number)
if not case:
embed = EmbedBuilder.create_error_embed(
embed = create_no_cases_embed(
ctx,
author_text=CONST.STRINGS["error_no_case_found_author"],
description=CONST.STRINGS["error_no_case_found_description"],
CONST.STRINGS["error_no_case_found_author"],
CONST.STRINGS["error_no_case_found_description"],
)
return await ctx.respond(embed=embed)
await ctx.send(embed=embed)
return
target = await UserConverter().convert(ctx, str(case["target_id"]))
target = await commands.UserConverter().convert(ctx, str(case["target_id"]))
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
@ -38,21 +94,40 @@ async def view_case_by_number(ctx, guild_id: int, case_number: int):
timestamp=case["created_at"],
duration=case["duration"] or None,
)
await ctx.respond(embed=embed)
await ctx.send(embed=embed)
@commands.hybrid_command(name="cases")
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def view_all_cases_in_guild(
self,
ctx: commands.Context[commands.Bot],
) -> None:
"""
View all cases in the guild.
async def view_all_cases_in_guild(ctx, guild_id: int):
Parameters
----------
ctx: commands.Context[commands.Bot]
The context of the command.
"""
if not ctx.guild:
raise LumiException(CONST.STRINGS["error_not_in_guild"])
guild_id = ctx.guild.id
cases = case_service.fetch_cases_by_guild(guild_id)
if not cases:
embed = EmbedBuilder.create_error_embed(
embed = create_no_cases_embed(
ctx,
author_text=CONST.STRINGS["case_guild_no_cases_author"],
description=CONST.STRINGS["case_guild_no_cases"],
CONST.STRINGS["case_guild_no_cases_author"],
CONST.STRINGS["case_guild_no_cases"],
)
return await ctx.respond(embed=embed)
await ctx.send(embed=embed)
return
menu = create_case_view_menu(ctx)
pages_list = []
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
@ -60,24 +135,43 @@ async def view_all_cases_in_guild(ctx, guild_id: int):
chunk,
CONST.STRINGS["case_guild_cases_author"],
)
pages_list.append(embed)
menu.add_page(embed)
paginator = pages.Paginator(pages=pages_list)
await paginator.respond(ctx)
await menu.start()
@commands.hybrid_command(name="modcases", aliases=["mc", "modc"])
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def view_all_cases_by_mod(
self,
ctx: commands.Context[commands.Bot],
moderator: discord.Member,
) -> None:
"""
View all cases by a specific moderator.
async def view_all_cases_by_mod(ctx, guild_id: int, moderator: discord.Member):
Parameters
----------
moderator: discord.Member
The moderator to view cases for.
"""
if not ctx.guild:
raise LumiException(CONST.STRINGS["error_not_in_guild"])
guild_id = ctx.guild.id
cases = case_service.fetch_cases_by_moderator(guild_id, moderator.id)
if not cases:
embed = EmbedBuilder.create_error_embed(
embed = create_no_cases_embed(
ctx,
author_text=CONST.STRINGS["case_mod_no_cases_author"],
description=CONST.STRINGS["case_mod_no_cases"],
CONST.STRINGS["case_mod_no_cases_author"],
CONST.STRINGS["case_mod_no_cases"],
)
return await ctx.respond(embed=embed)
await ctx.send(embed=embed)
return
menu = create_case_view_menu(ctx)
pages_list = []
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
@ -85,21 +179,44 @@ async def view_all_cases_by_mod(ctx, guild_id: int, moderator: discord.Member):
chunk,
CONST.STRINGS["case_mod_cases_author"].format(moderator.name),
)
pages_list.append(embed)
menu.add_page(embed)
paginator = pages.Paginator(pages=pages_list)
await paginator.respond(ctx)
await menu.start()
@commands.hybrid_command(name="editcase", aliases=["ec", "uc"])
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def edit_case_reason(
self,
ctx: commands.Context[commands.Bot],
case_number: int,
*,
new_reason: str,
) -> None:
"""
Edit the reason for a specific case.
Parameters
----------
case_number: int
The case number to edit.
new_reason: str
The new reason for the case.
"""
if not ctx.guild:
raise LumiException(CONST.STRINGS["error_not_in_guild"])
guild_id = ctx.guild.id
async def edit_case_reason(ctx, guild_id: int, case_number: int, new_reason: str):
case_service.edit_case_reason(
guild_id,
case_number,
new_reason,
)
embed = EmbedBuilder.create_success_embed(
ctx,
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["case_reason_update_author"],
description=CONST.STRINGS["case_reason_update_description"].format(
format_case_number(case_number),
@ -108,8 +225,12 @@ async def edit_case_reason(ctx, guild_id: int, case_number: int, new_reason: str
async def update_tasks():
await asyncio.gather(
ctx.respond(embed=embed),
ctx.send(embed=embed),
edit_case_modlog(ctx, guild_id, case_number, new_reason),
)
await update_tasks()
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Cases(bot))

View file

@ -1,33 +1,63 @@
import asyncio
from typing import Optional
from typing import cast
import discord
from discord.ext.commands import UserConverter, MemberConverter
from discord.ext import commands
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
from ui.embeds import Builder
async def kick_user(cog, ctx, target: discord.Member, reason: Optional[str] = None):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
class Kick(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.kick.usage = lib.format.generate_usage(self.kick)
@commands.hybrid_command(name="kick", aliases=["k"])
@commands.has_permissions(kick_members=True)
@commands.bot_has_permissions(kick_members=True)
@commands.guild_only()
async def kick(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
*,
reason: str | None = None,
) -> None:
"""
Kick a user from the guild.
Parameters
----------
target: discord.Member
The user to kick.
reason: str | None
The reason for the kick. Defaults to None.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
embed=Builder.create_embed(
theme="warning",
user_name=target.name,
author_text=CONST.STRINGS["mod_kicked_author"],
description=CONST.STRINGS["mod_kick_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
hide_name_in_description=True,
),
)
dm_sent = True
@ -38,21 +68,23 @@ async def kick_user(cog, ctx, target: discord.Member, reason: Optional[str] = No
await target.kick(
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_kicked_author"],
description=CONST.STRINGS["mod_kicked_user"].format(target.name),
footer_text=CONST.STRINGS["mod_dm_sent"]
if dm_sent
else CONST.STRINGS["mod_dm_not_sent"],
footer_text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"],
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "KICK", reason)
create_case_task = create_case(ctx, cast(discord.User, target), "KICK", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Kick(bot))

View file

@ -0,0 +1,123 @@
import contextlib
import discord
from discord import app_commands
from discord.ext import commands
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from lib.format import format_duration_to_seconds
class Slowmode(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.slowmode.usage = lib.format.generate_usage(self.slowmode)
async def _set_slowmode(
self,
ctx: commands.Context[commands.Bot] | discord.Interaction,
channel: discord.TextChannel,
duration: str | None,
) -> None:
if duration is None:
await self._send_response(
ctx,
CONST.STRINGS["slowmode_current_value"].format(channel.mention, channel.slowmode_delay),
)
return
try:
seconds = format_duration_to_seconds(duration)
except LumiException:
await self._send_response(ctx, CONST.STRINGS["slowmode_invalid_duration"], ephemeral=True)
return
if not 0 <= seconds <= 21600: # 21600 seconds = 6 hours (Discord's max slowmode)
await self._send_response(ctx, CONST.STRINGS["slowmode_invalid_duration"], ephemeral=True)
return
try:
await channel.edit(slowmode_delay=seconds)
await self._send_response(ctx, CONST.STRINGS["slowmode_success"].format(seconds, channel.mention))
except discord.Forbidden:
await self._send_response(ctx, CONST.STRINGS["slowmode_forbidden"], ephemeral=True)
async def _send_response(
self,
ctx: commands.Context[commands.Bot] | discord.Interaction,
content: str,
ephemeral: bool = False,
) -> None:
if isinstance(ctx, commands.Context):
await ctx.send(content)
else:
await ctx.response.send_message(content, ephemeral=ephemeral)
@commands.command(
name="slowmode",
aliases=["sm"],
)
@commands.has_permissions(manage_channels=True)
@commands.bot_has_permissions(manage_channels=True)
@commands.guild_only()
async def slowmode(
self,
ctx: commands.Context[commands.Bot],
arg1: str | None = None,
arg2: str | None = None,
) -> None:
"""
Set or view the slowmode for a channel.
Parameters
----------
arg1: str | None
The first argument. Defaults to None.
arg2: str | None
The second argument. Defaults to None.
"""
channel, duration = None, None
for arg in (arg1, arg2):
if not channel and arg:
with contextlib.suppress(commands.BadArgument):
channel = await commands.TextChannelConverter().convert(ctx, arg)
continue
if arg:
duration = arg
if not channel:
await ctx.send(CONST.STRINGS["slowmode_channel_not_found"])
return
await self._set_slowmode(ctx, channel, duration)
@app_commands.command(
name="slowmode",
)
@app_commands.checks.has_permissions(manage_channels=True)
@app_commands.checks.bot_has_permissions(manage_channels=True)
@app_commands.guild_only()
async def slowmode_slash(
self,
interaction: discord.Interaction,
channel: discord.TextChannel,
duration: str | None = None,
) -> None:
"""
Set or view the slowmode for a channel.
Parameters
----------
channel: discord.TextChannel
The channel to set the slowmode for.
duration: str | None
The duration of the slowmode. Defaults to None.
"""
await self._set_slowmode(interaction, channel, duration)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Slowmode(bot))

View file

@ -1,33 +1,63 @@
import asyncio
from typing import Optional
from typing import cast
import discord
from discord.ext.commands import MemberConverter, UserConverter
from discord.ext import commands
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
from ui.embeds import Builder
async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
class Softban(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.softban.usage = lib.format.generate_usage(self.softban)
@commands.hybrid_command(name="softban", aliases=["sb"])
@commands.has_permissions(ban_members=True)
@commands.bot_has_permissions(ban_members=True)
@commands.guild_only()
async def softban(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
*,
reason: str | None = None,
) -> None:
"""
Softban a user from the guild.
Parameters
----------
target: discord.Member
The user to softban.
reason: str | None
The reason for the softban. Defaults to None.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
embed=Builder.create_embed(
theme="warning",
user_name=target.name,
author_text=CONST.STRINGS["mod_softbanned_author"],
description=CONST.STRINGS["mod_softban_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
hide_name_in_description=True,
),
)
dm_sent = True
@ -38,7 +68,7 @@ async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)
@ -50,17 +80,19 @@ async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=target.name,
author_text=CONST.STRINGS["mod_softbanned_author"],
description=CONST.STRINGS["mod_softbanned_user"].format(target.name),
footer_text=CONST.STRINGS["mod_dm_sent"]
if dm_sent
else CONST.STRINGS["mod_dm_not_sent"],
footer_text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"],
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "SOFTBAN", reason)
create_case_task = create_case(ctx, cast(discord.User, target), "SOFTBAN", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Softban(bot))

View file

@ -1,45 +1,76 @@
import asyncio
import datetime
from typing import Optional
from typing import cast
import discord
from discord.ext.commands import UserConverter, MemberConverter
from discord.ext import commands
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import format_duration_to_seconds, format_seconds_to_duration_string
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
from lib.exceptions import LumiException
from ui.embeds import Builder
async def timeout_user(
cog,
ctx,
class Timeout(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.timeout.usage = lib.format.generate_usage(self.timeout)
self.untimeout.usage = lib.format.generate_usage(self.untimeout)
@commands.hybrid_command(name="timeout", aliases=["t", "to"])
@commands.has_permissions(moderate_members=True)
@commands.bot_has_permissions(moderate_members=True)
@commands.guild_only()
async def timeout(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
duration: str,
reason: Optional[str] = None,
):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
reason: str | None = None,
) -> None:
"""
Timeout a user in the guild.
Parameters
----------
target: discord.Member
The member to timeout.
duration: str
The duration of the timeout. Can be in the format of "1d2h3m4s".
reason: str | None
The reason for the timeout. Defaults to None.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
# Parse duration to minutes and validate
duration_int = lib.format.format_duration_to_seconds(duration)
duration_str = lib.format.format_seconds_to_duration_string(duration_int)
# if longer than 27 days, return LumiException
if duration_int > 2332800:
raise LumiException(CONST.STRINGS["mod_timeout_too_long"])
bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
# Parse duration to minutes and validate
duration_int = format_duration_to_seconds(duration)
duration_str = format_seconds_to_duration_string(duration_int)
await target.timeout_for(
duration=datetime.timedelta(seconds=duration_int),
await target.timeout(
datetime.timedelta(seconds=duration_int),
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
)
dm_task = target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
embed=Builder.create_embed(
theme="warning",
user_name=target.name,
author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timeout_dm"].format(
target.name,
@ -47,20 +78,20 @@ async def timeout_user(
duration_str,
output_reason,
),
show_name=False,
hide_name_in_description=True,
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=target.name,
author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timed_out_user"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "TIMEOUT", reason, duration_int)
create_case_task = create_case(ctx, cast(discord.User, target), "TIMEOUT", reason, duration_int)
await asyncio.gather(
dm_task,
@ -69,35 +100,64 @@ async def timeout_user(
return_exceptions=True,
)
@commands.hybrid_command(name="untimeout", aliases=["ut", "rto"])
@commands.has_permissions(moderate_members=True)
@commands.bot_has_permissions(moderate_members=True)
@commands.guild_only()
async def untimeout(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
reason: str | None = None,
) -> None:
"""
Untimeout a user in the guild.
Parameters
----------
target: discord.Member
The member to untimeout.
reason: str | None
The reason for the untimeout. Defaults to None.
"""
assert ctx.guild
assert ctx.author
assert ctx.bot.user
async def untimeout_user(ctx, target: discord.Member, reason: Optional[str] = None):
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.remove_timeout(
await target.timeout(
None,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
lib.format.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_untimed_out_author"],
description=CONST.STRINGS["mod_untimed_out"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
target_user = await commands.UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
except discord.HTTPException:
return await ctx.respond(
embed=EmbedBuilder.create_warning_embed(
ctx,
await ctx.send(
embed=Builder.create_embed(
theme="warning",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_not_timed_out_author"],
description=CONST.STRINGS["mod_not_timed_out"].format(target.name),
),
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Timeout(bot))

View file

@ -1,44 +1,74 @@
import asyncio
from typing import Optional
from typing import cast
import discord
from discord.ext.commands import UserConverter, MemberConverter
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
import lib.format
from lib.actionable import async_actionable
from lib.case_handler import create_case
from lib.const import CONST
from lib.exceptions import LumiException
from ui.embeds import Builder
async def warn_user(ctx, target: discord.Member, reason: Optional[str]):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
class Warn(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.warn.usage = lib.format.generate_usage(self.warn)
@commands.hybrid_command(name="warn", aliases=["w"])
@commands.has_permissions(manage_messages=True)
@commands.guild_only()
async def warn(
self,
ctx: commands.Context[commands.Bot],
target: discord.Member,
*,
reason: str | None = None,
) -> None:
"""
Warn a user.
Parameters
----------
target: discord.Member
The user to warn.
reason: str | None
The reason for the warn. Defaults to None.
"""
if not ctx.guild or not ctx.author or not ctx.bot.user:
raise LumiException
bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user))
await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
dm_task = target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
embed=Builder.create_embed(
theme="info",
user_name=target.name,
author_text=CONST.STRINGS["mod_warned_author"],
description=CONST.STRINGS["mod_warn_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
hide_name_in_description=True,
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
respond_task = ctx.send(
embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["mod_warned_author"],
description=CONST.STRINGS["mod_warned_user"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "WARN", reason)
create_case_task = create_case(ctx, cast(discord.User, target), "WARN", reason)
await asyncio.gather(
dm_task,
@ -46,3 +76,7 @@ async def warn_user(ctx, target: discord.Member, reason: Optional[str]):
create_case_task,
return_exceptions=True,
)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Warn(bot))

View file

@ -1,81 +0,0 @@
import discord
from discord.commands import SlashCommandGroup
from discord.ext import commands
from discord.ext.commands import guild_only
from Client import LumiBot
from modules.triggers.add import add_reaction
from modules.triggers.delete import delete_reaction
from modules.triggers.list import list_reactions
class Triggers(commands.Cog):
def __init__(self, client: LumiBot):
self.client = client
trigger = SlashCommandGroup(
"trigger",
"Manage custom reactions.",
default_member_permissions=discord.Permissions(manage_guild=True),
contexts={discord.InteractionContextType.guild},
)
add = trigger.create_subgroup("add", "Add new custom reactions.")
@add.command(
name="response",
description="Add a new custom text reaction.",
help="Add a new custom text reaction to the database.",
)
@guild_only()
async def add_text_reaction_command(
self,
ctx,
trigger_text: str,
response: str,
is_full_match: bool,
):
await add_reaction(ctx, trigger_text, response, None, False, is_full_match)
@add.command(
name="emoji",
description="Add a new custom emoji reaction.",
help="Add a new custom emoji reaction to the database.",
)
@guild_only()
async def add_emoji_reaction_command(
self,
ctx,
trigger_text: str,
emoji: discord.Emoji,
is_full_match: bool,
):
await add_reaction(ctx, trigger_text, None, emoji.id, True, is_full_match)
@trigger.command(
name="delete",
description="Delete an existing custom reaction.",
help="Delete an existing custom reaction from the database.",
)
@guild_only()
async def delete_reaction_command(
self,
ctx,
reaction_id: int,
):
await delete_reaction(ctx, reaction_id)
@trigger.command(
name="list",
description="List all custom reactions.",
help="List all custom reactions for the current guild.",
)
@guild_only()
async def list_reactions_command(
self,
ctx,
):
await list_reactions(ctx)
def setup(client: LumiBot):
client.add_cog(Triggers(client))

View file

@ -1,107 +0,0 @@
from typing import Optional
from discord.ext import bridge
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from services.reactions_service import CustomReactionsService
async def add_reaction(
ctx: bridge.Context,
trigger_text: str,
response: Optional[str],
emoji_id: Optional[int],
is_emoji: bool,
is_full_match: bool,
) -> None:
if ctx.guild is None:
return
reaction_service = CustomReactionsService()
guild_id: int = ctx.guild.id
creator_id: int = ctx.author.id
if not await check_reaction_limit(
reaction_service,
guild_id,
):
return
if not await check_existing_trigger(
reaction_service,
guild_id,
trigger_text,
):
return
success: bool = await reaction_service.create_custom_reaction(
guild_id=guild_id,
creator_id=creator_id,
trigger_text=trigger_text,
response=response,
emoji_id=emoji_id,
is_emoji=is_emoji,
is_full_match=is_full_match,
is_global=False,
)
if not success:
raise LumiException(CONST.STRINGS["triggers_not_added"])
trigger_text = formatter.shorten(trigger_text, 50)
if response:
response = formatter.shorten(response, 50)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["triggers_add_author"],
description="",
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
show_name=False,
)
embed.description += CONST.STRINGS["triggers_add_description"].format(
trigger_text,
CONST.STRINGS["triggers_type_emoji"]
if is_emoji
else CONST.STRINGS["triggers_type_text"],
is_full_match,
)
if is_emoji:
embed.description += CONST.STRINGS["triggers_add_emoji_details"].format(
emoji_id,
)
else:
embed.description += CONST.STRINGS["triggers_add_text_details"].format(response)
await ctx.respond(embed=embed)
async def check_reaction_limit(
reaction_service: CustomReactionsService,
guild_id: int,
) -> bool:
limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100
if limit_reached:
raise LumiException(CONST.STRINGS["trigger_limit_reached"])
return True
async def check_existing_trigger(
reaction_service: CustomReactionsService,
guild_id: int,
trigger_text: str,
) -> bool:
existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text)
if existing_trigger:
raise LumiException(CONST.STRINGS["trigger_already_exists"])
return True

View file

@ -1,29 +0,0 @@
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
from services.reactions_service import CustomReactionsService
async def delete_reaction(ctx: bridge.Context, reaction_id: int) -> None:
if ctx.guild is None:
return
reaction_service = CustomReactionsService()
guild_id: int = ctx.guild.id
reaction = await reaction_service.find_id(reaction_id)
if reaction is None or reaction["guild_id"] != guild_id or reaction["is_global"]:
raise LumiException(CONST.STRINGS["triggers_not_found"])
await reaction_service.delete_custom_reaction(reaction_id)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["triggers_delete_author"],
description=CONST.STRINGS["triggers_delete_description"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
)
await ctx.respond(embed=embed)

View file

@ -1,80 +0,0 @@
from typing import Any, Dict, List
import discord
from discord.ext import bridge, pages
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.reactions_service import CustomReactionsService
async def list_reactions(ctx: bridge.Context) -> None:
if ctx.guild is None:
return
reaction_service: CustomReactionsService = CustomReactionsService()
guild_id: int = ctx.guild.id
reactions: List[Dict[str, Any]] = await reaction_service.find_all_by_guild(guild_id)
if not reactions:
embed: discord.Embed = EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["triggers_no_reactions_title"],
description=CONST.STRINGS["triggers_no_reactions_description"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
show_name=False,
)
await ctx.respond(embed=embed)
return
pages_list = []
for reaction in reactions:
embed = EmbedBuilder.create_success_embed(
ctx,
title=CONST.STRINGS["triggers_list_custom_reaction_id"].format(
reaction["id"],
),
author_text=CONST.STRINGS["triggers_list_custom_reactions_title"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
show_name=False,
)
description_lines = [
CONST.STRINGS["triggers_list_trigger_text"].format(
formatter.shorten(reaction["trigger_text"], 50),
),
CONST.STRINGS["triggers_list_reaction_type"].format(
CONST.STRINGS["triggers_type_emoji"]
if reaction["is_emoji"]
else CONST.STRINGS["triggers_type_text"],
),
]
if reaction["is_emoji"]:
description_lines.append(
CONST.STRINGS["triggers_list_emoji_id"].format(reaction["emoji_id"]),
)
else:
description_lines.append(
CONST.STRINGS["triggers_list_response"].format(
formatter.shorten(reaction["response"], 50),
),
)
description_lines.extend(
[
CONST.STRINGS["triggers_list_full_match"].format(
"True" if reaction["is_full_match"] else "False",
),
CONST.STRINGS["triggers_list_usage_count"].format(
reaction["usage_count"],
),
],
)
embed.description = "\n".join(description_lines)
pages_list.append(embed)
paginator: pages.Paginator = pages.Paginator(pages=pages_list, timeout=180.0)
await paginator.respond(ctx, ephemeral=False)

View file

@ -0,0 +1,286 @@
from typing import Any
import discord
from discord import app_commands
from discord.ext import commands
from reactionmenu import ViewButton, ViewMenu
import lib.format
from lib.const import CONST
from lib.exceptions import LumiException
from services.reactions_service import CustomReactionsService
from ui.embeds import Builder
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
class Triggers(commands.GroupCog, group_name="trigger"):
def __init__(self, bot: commands.Bot):
self.bot = bot
add = app_commands.Group(
name="add",
description="Add a trigger",
allowed_contexts=app_commands.AppCommandContext(
guild=True,
dm_channel=False,
private_channel=False,
),
default_permissions=discord.Permissions(manage_guild=True),
)
@add.command(name="response")
async def add_text_response(
self,
interaction: discord.Interaction,
trigger_text: str,
response: str,
is_full_match: bool = False,
) -> None:
"""
Add a custom reaction that uses text.
Parameters
----------
trigger_text: str
The text that triggers the reaction.
response: str
The text to respond with.
"""
assert interaction.guild
reaction_service = CustomReactionsService()
guild_id: int = interaction.guild.id
creator_id: int = interaction.user.id
limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100
if limit_reached:
raise LumiException(CONST.STRINGS["trigger_limit_reached"])
existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text)
if existing_trigger:
raise LumiException(CONST.STRINGS["trigger_already_exists"])
success: bool = await reaction_service.create_custom_reaction(
guild_id=guild_id,
creator_id=creator_id,
trigger_text=trigger_text,
response=response,
emoji_id=None,
is_emoji=False,
is_full_match=is_full_match,
is_global=False,
)
if not success:
raise LumiException(CONST.STRINGS["triggers_not_added"])
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["triggers_add_author"],
description="",
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
hide_name_in_description=True,
)
embed.description += CONST.STRINGS["triggers_add_description"].format(
lib.format.shorten(trigger_text, 50),
CONST.STRINGS["triggers_type_text"],
is_full_match,
)
embed.description += CONST.STRINGS["triggers_add_text_details"].format(lib.format.shorten(response, 50))
await interaction.response.send_message(embed=embed)
@add.command(name="emoji")
async def add_emoji_response(
self,
interaction: discord.Interaction,
trigger_text: str,
emoji_id: int,
is_full_match: bool = False,
) -> None:
"""
Add a custom reaction that uses an emoji.
Parameters
----------
trigger_text: str
The text that triggers the reaction.
emoji_id: int
The ID of the emoji to use.
"""
assert interaction.guild
reaction_service = CustomReactionsService()
guild_id: int = interaction.guild.id
creator_id: int = interaction.user.id
limit_reached = await reaction_service.count_custom_reactions(guild_id) >= 100
if limit_reached:
raise LumiException(CONST.STRINGS["trigger_limit_reached"])
existing_trigger = await reaction_service.find_trigger(guild_id, trigger_text)
if existing_trigger:
raise LumiException(CONST.STRINGS["trigger_already_exists"])
success: bool = await reaction_service.create_custom_reaction(
guild_id=guild_id,
creator_id=creator_id,
trigger_text=trigger_text,
response=None,
emoji_id=emoji_id,
is_emoji=True,
is_full_match=is_full_match,
is_global=False,
)
if not success:
raise LumiException(CONST.STRINGS["triggers_not_added"])
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["triggers_add_author"],
description="",
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
hide_name_in_description=True,
)
embed.description += CONST.STRINGS["triggers_add_description"].format(
lib.format.shorten(trigger_text, 50),
CONST.STRINGS["triggers_type_emoji"],
is_full_match,
)
embed.description += CONST.STRINGS["triggers_add_emoji_details"].format(emoji_id)
await interaction.response.send_message(embed=embed)
@app_commands.command(name="delete")
async def remove_text_response(
self,
interaction: discord.Interaction,
reaction_id: int,
) -> None:
"""
Delete a custom reaction by its ID.
Parameters
----------
reaction_id: int
The ID of the reaction to delete.
"""
assert interaction.guild
reaction_service = CustomReactionsService()
guild_id: int = interaction.guild.id
reaction = await reaction_service.find_id(reaction_id)
if reaction is None or reaction["guild_id"] != guild_id or reaction["is_global"]:
raise LumiException(CONST.STRINGS["triggers_not_found"])
await reaction_service.delete_custom_reaction(reaction_id)
embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["triggers_delete_author"],
description=CONST.STRINGS["triggers_delete_description"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
)
await interaction.response.send_message(embed=embed)
@app_commands.command(name="list")
async def list_reactions(self, interaction: discord.Interaction) -> None:
"""
List all custom reactions for the current guild.
Parameters
----------
interaction: discord.Interaction
The interaction to list the reactions for.
"""
assert interaction.guild
reaction_service: CustomReactionsService = CustomReactionsService()
guild_id: int = interaction.guild.id
reactions: list[dict[str, Any]] = await reaction_service.find_all_by_guild(guild_id)
if not reactions:
embed: discord.Embed = Builder.create_embed(
theme="warning",
user_name=interaction.user.name,
author_text=CONST.STRINGS["triggers_no_reactions_title"],
description=CONST.STRINGS["triggers_no_reactions_description"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
hide_name_in_description=True,
)
await interaction.response.send_message(embed=embed)
return
menu = ViewMenu(interaction, menu_type=ViewMenu.TypeEmbed, all_can_click=True, remove_items_on_timeout=True)
for reaction in reactions:
embed: discord.Embed = Builder.create_embed(
theme="success",
user_name=interaction.user.name,
title=CONST.STRINGS["triggers_list_custom_reaction_id"].format(
reaction["id"],
),
author_text=CONST.STRINGS["triggers_list_custom_reactions_title"],
footer_text=CONST.STRINGS["triggers_reaction_service_footer"],
hide_name_in_description=True,
)
description_lines = [
CONST.STRINGS["triggers_list_trigger_text"].format(
lib.format.shorten(reaction["trigger_text"], 50),
),
CONST.STRINGS["triggers_list_reaction_type"].format(
CONST.STRINGS["triggers_type_emoji"]
if reaction["is_emoji"]
else CONST.STRINGS["triggers_type_text"],
),
]
if reaction["is_emoji"]:
description_lines.append(
CONST.STRINGS["triggers_list_emoji_id"].format(reaction["emoji_id"]),
)
else:
description_lines.append(
CONST.STRINGS["triggers_list_response"].format(
lib.format.shorten(reaction["response"], 50),
),
)
description_lines.extend(
[
CONST.STRINGS["triggers_list_full_match"].format(
"True" if reaction["is_full_match"] else "False",
),
CONST.STRINGS["triggers_list_usage_count"].format(
reaction["usage_count"],
),
],
)
embed.description = "\n".join(description_lines)
menu.add_page(embed)
buttons = [
(ViewButton.ID_GO_TO_FIRST_PAGE, "⏮️"),
(ViewButton.ID_PREVIOUS_PAGE, ""),
(ViewButton.ID_NEXT_PAGE, ""),
(ViewButton.ID_GO_TO_LAST_PAGE, "⏭️"),
]
for custom_id, emoji in buttons:
menu.add_button(ViewButton(style=discord.ButtonStyle.secondary, custom_id=custom_id, emoji=emoji))
await menu.start()
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Triggers(bot))

486
poetry.lock generated
View file

@ -1,5 +1,43 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiocache"
version = "0.12.2"
description = "multi backend asyncio cache"
optional = false
python-versions = "*"
files = [
{file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"},
{file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"},
]
[package.extras]
memcached = ["aiomcache (>=0.5.2)"]
msgpack = ["msgpack (>=0.5.5)"]
redis = ["redis (>=4.2.0)"]
[[package]]
name = "aioconsole"
version = "0.7.1"
description = "Asynchronous console and interfaces for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "aioconsole-0.7.1-py3-none-any.whl", hash = "sha256:1867a7cc86897a87398e6e6fba302738548f1cf76cbc6c76e06338e091113bdc"},
{file = "aioconsole-0.7.1.tar.gz", hash = "sha256:a3e52428d32623c96746ec3862d97483c61c12a2f2dfba618886b709415d4533"},
]
[[package]]
name = "aiofiles"
version = "24.1.0"
description = "File support for asyncio."
optional = false
python-versions = ">=3.8"
files = [
{file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"},
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
]
[[package]]
name = "aiohappyeyeballs"
version = "2.4.0"
@ -136,6 +174,17 @@ files = [
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]]
name = "anyio"
version = "4.4.0"
@ -177,13 +226,13 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]]
name = "certifi"
version = "2024.7.4"
version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
]
[[package]]
@ -307,6 +356,26 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "discord-py"
version = "2.4.0"
description = "A Python wrapper for the Discord API"
optional = false
python-versions = ">=3.8"
files = [
{file = "discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d"},
{file = "discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5"},
]
[package.dependencies]
aiohttp = ">=3.7.4,<4"
[package.extras]
docs = ["sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "typing-extensions (>=4.3,<5)"]
speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"]
test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata"]
voice = ["PyNaCl (>=1.3.0,<1.6)"]
[[package]]
name = "distlib"
version = "0.3.8"
@ -471,13 +540,13 @@ trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
version = "0.27.2"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
{file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
{file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
]
[package.dependencies]
@ -492,6 +561,7 @@ brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "identify"
@ -762,33 +832,137 @@ files = [
test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
[[package]]
name = "py-cord"
version = "2.6.0"
description = "A Python wrapper for the Discord API"
name = "pydantic"
version = "2.8.2"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "py_cord-2.6.0-py3-none-any.whl", hash = "sha256:906cc077904a4d478af0264ae4374b0412ed9f9ab950d0162c4f31239a906d26"},
{file = "py_cord-2.6.0.tar.gz", hash = "sha256:bbc0349542965d05e4b18cc4424136206430a8cc911fda12a0a57df6fdf9cd9c"},
{file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
]
[package.dependencies]
aiohttp = ">=3.6.0,<4.0"
annotated-types = ">=0.4.0"
pydantic-core = "2.20.1"
typing-extensions = [
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
]
[package.extras]
docs = ["furo (==2023.3.23)", "myst-parser (==1.0.0)", "sphinx (==5.3.0)", "sphinx-autodoc-typehints (==1.23.0)", "sphinx-copybutton (==0.5.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "sphinxext-opengraph (==0.9.1)"]
speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"]
voice = ["PyNaCl (>=1.3.0,<1.6)"]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.20.1"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"},
{file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"},
{file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"},
{file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"},
{file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"},
{file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"},
{file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"},
{file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"},
{file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"},
{file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"},
{file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"},
{file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"},
{file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"},
{file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"},
{file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"},
{file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"},
{file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"},
{file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"},
{file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"},
{file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"},
{file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"},
{file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"},
{file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"},
{file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"},
{file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"},
{file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"},
{file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"},
{file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"},
{file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"},
{file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"},
{file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"},
{file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"},
{file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pyright"
version = "1.1.377"
version = "1.1.378"
description = "Command line wrapper for pyright"
optional = false
python-versions = ">=3.7"
files = [
{file = "pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162"},
{file = "pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf"},
{file = "pyright-1.1.378-py3-none-any.whl", hash = "sha256:8853776138b01bc284da07ac481235be7cc89d3176b073d2dba73636cb95be79"},
{file = "pyright-1.1.378.tar.gz", hash = "sha256:78a043be2876d12d0af101d667e92c7734f3ebb9db71dccc2c220e7e7eb89ca2"},
]
[package.dependencies]
@ -798,20 +972,6 @@ nodeenv = ">=1.6.0"
all = ["twine (>=3.4.1)"]
dev = ["twine (>=3.4.1)"]
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pytimeparse"
version = "1.1.8"
@ -823,17 +983,6 @@ files = [
{file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"},
]
[[package]]
name = "pytz"
version = "2024.1"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
files = [
{file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
{file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@ -896,6 +1045,20 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
]
[[package]]
name = "reactionmenu"
version = "3.1.7"
description = "A library to create a discord.py 2.0+ paginator. Supports pagination with buttons, reactions, and category selection using selects."
optional = false
python-versions = ">=3.8"
files = [
{file = "reactionmenu-3.1.7-py3-none-any.whl", hash = "sha256:51a217c920382dfecbb2f05d60bd20b79ed9895e9f5663f6c0edb75e806f863a"},
{file = "reactionmenu-3.1.7.tar.gz", hash = "sha256:10da3c1966de2b6264fcdf72537348923c5e151501644375c25f430bfd870463"},
]
[package.dependencies]
"discord.py" = ">=2.0.0"
[[package]]
name = "requests"
version = "2.32.3"
@ -919,29 +1082,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "ruff"
version = "0.5.7"
version = "0.6.3"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"},
{file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"},
{file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"},
{file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"},
{file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"},
{file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"},
{file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"},
{file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"},
{file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"},
{file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"},
{file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"},
{file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"},
{file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"},
{file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"},
{file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"},
{file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"},
{file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"},
{file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"},
{file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"},
]
[[package]]
@ -982,6 +1145,17 @@ files = [
ply = ">=3.4"
six = ">=1.12.0"
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[[package]]
name = "urllib3"
version = "2.2.2"
@ -1035,101 +1209,103 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[[package]]
name = "yarl"
version = "1.9.4"
version = "1.9.7"
description = "Yet another URL library"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
{file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"},
{file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"},
{file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"},
{file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"},
{file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"},
{file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"},
{file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"},
{file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"},
{file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"},
{file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"},
{file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"},
{file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"},
{file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"},
{file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"},
{file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"},
{file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"},
{file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"},
{file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"},
{file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"},
{file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"},
{file = "yarl-1.9.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:60c04415b31a1611ef5989a6084dd6f6b95652c6a18378b58985667b65b2ecb6"},
{file = "yarl-1.9.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1787dcfdbe730207acb454548a6e19f80ae75e6d2d1f531c5a777bc1ab6f7952"},
{file = "yarl-1.9.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5ddad20363f9f1bbedc95789c897da62f939e6bc855793c3060ef8b9f9407bf"},
{file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdb156a06208fc9645ae7cc0fca45c40dd40d7a8c4db626e542525489ca81a9"},
{file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522fa3d300d898402ae4e0fa7c2c21311248ca43827dc362a667de87fdb4f1be"},
{file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7f9cabfb8b980791b97a3ae3eab2e38b2ba5eab1af9b7495bdc44e1ce7c89e3"},
{file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fc728857df4087da6544fc68f62d7017fa68d74201d5b878e18ed4822c31fb3"},
{file = "yarl-1.9.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dba2ebac677184d56374fa3e452b461f5d6a03aa132745e648ae8859361eb6b"},
{file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a95167ae34667c5cc7d9206c024f793e8ffbadfb307d5c059de470345de58a21"},
{file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9d319ac113ca47352319cbea92d1925a37cb7bd61a8c2f3e3cd2e96eb33cccae"},
{file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2d71a5d818d82586ac46265ae01466e0bda0638760f18b21f1174e0dd58a9d2f"},
{file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ff03f1c1ac474c66d474929ae7e4dd195592c1c7cc8c36418528ed81b1ca0a79"},
{file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78250f635f221dde97d02c57aade3313310469bc291888dfe32acd1012594441"},
{file = "yarl-1.9.7-cp310-cp310-win32.whl", hash = "sha256:f3aaf9fa960d55bd7876d55d7ea3cc046f3660df1ff73fc1b8c520a741ed1f21"},
{file = "yarl-1.9.7-cp310-cp310-win_amd64.whl", hash = "sha256:e8362c941e07fbcde851597672a5e41b21dc292b7d5a1dc439b7a93c9a1af5d9"},
{file = "yarl-1.9.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:596069ddeaf72b5eb36cd714dcd2b5751d0090d05a8d65113b582ed9e1c801fb"},
{file = "yarl-1.9.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb870907e8b86b2f32541403da9455afc1e535ce483e579bea0e6e79a0cc751c"},
{file = "yarl-1.9.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ca5e86be84492fa403c4dcd4dcaf8e1b1c4ffc747b5176f7c3d09878c45719b0"},
{file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a99cecfb51c84d00132db909e83ae388793ca86e48df7ae57f1be0beab0dcce5"},
{file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25508739e9b44d251172145f54c084b71747b09e4d237dc2abb045f46c36a66e"},
{file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60f3b5aec3146b6992640592856414870f5b20eb688c1f1d5f7ac010a7f86561"},
{file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1557456afce5db3d655b5f8a31cdcaae1f47e57958760525c44b76e812b4987"},
{file = "yarl-1.9.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71bb1435a84688ed831220c5305d96161beb65cac4a966374475348aa3de4575"},
{file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f87d8645a7a806ec8f66aac5e3b1dcb5014849ff53ffe2a1f0b86ca813f534c7"},
{file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:58e3f01673873b8573da3abe138debc63e4e68541b2104a55df4c10c129513a4"},
{file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8af0bbd4d84f8abdd9b11be9488e32c76b1501889b73c9e2292a15fb925b378b"},
{file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7fc441408ed0d9c6d2d627a02e281c21f5de43eb5209c16636a17fc704f7d0f8"},
{file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a9552367dc440870556da47bb289a806f08ad06fbc4054072d193d9e5dd619ba"},
{file = "yarl-1.9.7-cp311-cp311-win32.whl", hash = "sha256:628619008680a11d07243391271b46f07f13b75deb9fe92ef342305058c70722"},
{file = "yarl-1.9.7-cp311-cp311-win_amd64.whl", hash = "sha256:bc23d870864971c8455cfba17498ccefa53a5719ea9f5fce5e7e9c1606b5755f"},
{file = "yarl-1.9.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d8cf3d0b67996edc11957aece3fbce4c224d0451c7c3d6154ec3a35d0e55f6b"},
{file = "yarl-1.9.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a7748cd66fef49c877e59503e0cc76179caf1158d1080228e67e1db14554f08"},
{file = "yarl-1.9.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a6fa3aeca8efabb0fbbb3b15e0956b0cb77f7d9db67c107503c30af07cd9e00"},
{file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf37dd0008e5ac5c3880198976063c491b6a15b288d150d12833248cf2003acb"},
{file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87aa5308482f248f8c3bd9311cd6c7dfd98ea1a8e57e35fb11e4adcac3066003"},
{file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:867b13c1b361f9ba5d2f84dc5408082f5d744c83f66de45edc2b96793a9c5e48"},
{file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ce93947554c2c85fe97fc4866646ec90840bc1162e4db349b37d692a811755"},
{file = "yarl-1.9.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcd3d94b848cba132f39a5b40d80b0847d001a91a6f35a2204505cdd46afe1b2"},
{file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d06d6a8f98dd87646d98f0c468be14b201e47ec6092ad569adf835810ad0dffb"},
{file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:91567ff4fce73d2e7ac67ed5983ad26ba2343bc28cb22e1e1184a9677df98d7c"},
{file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d5594512541e63188fea640b7f066c218d2176203d6e6f82abf702ae3dca3b2"},
{file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c2743e43183e4afbb07d5605693299b8756baff0b086c25236c761feb0e3c56"},
{file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:daa69a3a2204355af39f4cfe7f3870d87c53d77a597b5100b97e3faa9460428b"},
{file = "yarl-1.9.7-cp312-cp312-win32.whl", hash = "sha256:36b16884336c15adf79a4bf1d592e0c1ffdb036a760e36a1361565b66785ec6c"},
{file = "yarl-1.9.7-cp312-cp312-win_amd64.whl", hash = "sha256:2ead2f87a1174963cc406d18ac93d731fbb190633d3995fa052d10cefae69ed8"},
{file = "yarl-1.9.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:808eddabcb6f7b2cdb6929b3e021ac824a2c07dc7bc83f7618e18438b1b65781"},
{file = "yarl-1.9.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:395ab0d8ce6d104a988da429bcbfd445e03fb4c911148dfd523f69d13f772e47"},
{file = "yarl-1.9.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49827dfccbd59c4499605c13805e947349295466e490860a855b7c7e82ec9c75"},
{file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b8bbdd425d0978311520ea99fb6c0e9e04e64aee84fac05f3157ace9f81b05"},
{file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71d33fd1c219b5b28ee98cd76da0c9398a4ed4792fd75c94135237db05ba5ca8"},
{file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62440431741d0b7d410e5cbad800885e3289048140a43390ecab4f0b96dde3bb"},
{file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db97210433366dfba55590e48285b89ad0146c52bf248dd0da492dd9f0f72cf"},
{file = "yarl-1.9.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:653597b615809f2e5f4dba6cd805608b6fd3597128361a22cc612cf7c7a4d1bf"},
{file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df47612129e66f7ce7c9994d4cd4e6852f6e3bf97699375d86991481796eeec8"},
{file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5e338b6febbae6c9fe86924bac3ea9c1944e33255c249543cd82a4af6df6047b"},
{file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e649d37d04665dddb90994bbf0034331b6c14144cc6f3fbce400dc5f28dc05b7"},
{file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0a1b8fd849567be56342e988e72c9d28bd3c77b9296c38b9b42d2fe4813c9d3f"},
{file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9d715b2175dff9a49c6dafdc2ab3f04850ba2f3d4a77f69a5a1786b057a9d45"},
{file = "yarl-1.9.7-cp313-cp313-win32.whl", hash = "sha256:bc9233638b07c2e4a3a14bef70f53983389bffa9e8cb90a2da3f67ac9c5e1842"},
{file = "yarl-1.9.7-cp313-cp313-win_amd64.whl", hash = "sha256:62e110772330d7116f91e79cd83fef92545cb2f36414c95881477aa01971f75f"},
{file = "yarl-1.9.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a564155cc2194ecd9c0d8f8dc57059b822a507de5f08120063675eb9540576aa"},
{file = "yarl-1.9.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03e917cc44a01e1be60a83ee1a17550b929490aaa5df2a109adc02137bddf06b"},
{file = "yarl-1.9.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eefda67ba0ba44ab781e34843c266a76f718772b348f7c5d798d8ea55b95517f"},
{file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316c82b499b6df41444db5dea26ee23ece9356e38cea43a8b2af9e6d8a3558e4"},
{file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10452727843bc847596b75e30a7fe92d91829f60747301d1bd60363366776b0b"},
{file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:050f3e4d886be55728fef268587d061c5ce6f79a82baba71840801b63441c301"},
{file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0aabe557446aa615693a82b4d3803c102fd0e7a6a503bf93d744d182a510184"},
{file = "yarl-1.9.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23404842228e6fa8ace235024519df37f3f8e173620407644d40ddca571ff0f4"},
{file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:34736fcc9d6d7080ebbeb0998ecb91e4f14ad8f18648cf0b3099e2420a225d86"},
{file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:48f7a158f3ca67509d21cb02a96964e4798b6f133691cc0c86cf36e26e26ec8f"},
{file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6639444d161c693cdabb073baaed1945c717d3982ecedf23a219bc55a242e728"},
{file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1cd450e10cb53d63962757c3f6f7870be49a3e448c46621d6bd46f8088d532de"},
{file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74d3ef5e81f81507cea04bf5ae22f18ef538607a7c754aac2b6e3029956a2842"},
{file = "yarl-1.9.7-cp38-cp38-win32.whl", hash = "sha256:4052dbd0c900bece330e3071c636f99dff06e4628461a29b38c6e222a427cf98"},
{file = "yarl-1.9.7-cp38-cp38-win_amd64.whl", hash = "sha256:dd08da4f2d171e19bd02083c921f1bef89f8f5f87000d0ffc49aa257bc5a9802"},
{file = "yarl-1.9.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ab906a956d2109c6ea11e24c66592b06336e2743509290117f0f7f47d2c1dd3"},
{file = "yarl-1.9.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d8ad761493d5aaa7ab2a09736e62b8a220cb0b10ff8ccf6968c861cd8718b915"},
{file = "yarl-1.9.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d35f9cdab0ec5e20cf6d2bd46456cf599052cf49a1698ef06b9592238d1cf1b1"},
{file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a48d2b9f0ae29a456fb766ae461691378ecc6cf159dd9f938507d925607591c3"},
{file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf85599c9336b89b92c313519bcaa223d92fa5d98feb4935a47cce2e8722b4b8"},
{file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e8916b1ff7680b1f2b1608c82dc15c569b9f2cb2da100c747c291f1acf18a14"},
{file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29c80890e0a64fb0e5f71350d48da330995073881f8b8e623154aef631febfb0"},
{file = "yarl-1.9.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9163d21aa40ff8528db2aee2b0b6752efe098055b41ab8e5422b2098457199fe"},
{file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:65e3098969baf221bb45e3b2f60735fc2b154fc95902131ebc604bae4c629ea6"},
{file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cddebd096effe4be90fd378e4224cd575ac99e1c521598a6900e94959006e02e"},
{file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8525f955a2dcc281573b6aadeb8ab9c37e2d3428b64ca6a2feec2a794a69c1da"},
{file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:5d585c7d834c13f24c7e3e0efaf1a4b7678866940802e11bd6c4d1f99c935e6b"},
{file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78805148e780a9ca66f3123e04741e344b66cf06b4fb13223e3a209f39a6da55"},
{file = "yarl-1.9.7-cp39-cp39-win32.whl", hash = "sha256:3f53df493ec80b76969d6e1ae6e4411a55ab1360e02b80c84bd4b33d61a567ba"},
{file = "yarl-1.9.7-cp39-cp39-win_amd64.whl", hash = "sha256:c81c28221a85add23a0922a6aeb2cdda7f9723e03e2dfae06fee5c57fe684262"},
{file = "yarl-1.9.7-py3-none-any.whl", hash = "sha256:49935cc51d272264358962d050d726c3e5603a616f53e52ea88e9df1728aa2ee"},
{file = "yarl-1.9.7.tar.gz", hash = "sha256:f28e602edeeec01fc96daf7728e8052bc2e12a672e2a138561a1ebaf30fd9df7"},
]
[package.dependencies]
@ -1139,4 +1315,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "541857707095fb0b5c439aedbfacd91ca3582f110f12d786dc29e7c70f989b3e"
content-hash = "70d489a46ab888e4ed82b7447d5a02cde51e9062b735715d98cc3e4f089aadb6"

View file

@ -1,27 +1,156 @@
[tool.poetry]
authors = ["wlinator <dokimakimaki@gmail.com>"]
authors = ["wlinator <git@wlinator.org>"]
description = "A Discord application, can serve as a template for your own bot."
license = "GNU General Public License v3.0"
name = "lumi"
name = "luminara"
package-mode = false
readme = "README.md"
version = "0.1.0"
version = "3"
[tool.poetry.dependencies]
aiocache = "^0.12.2"
aioconsole = "^0.7.1"
aiofiles = "^24.1.0"
discord-py = "^2.4.0"
dropbox = "^12.0.2"
httpx = "^0.27.0"
httpx = "^0.27.2"
loguru = "^0.7.2"
mysql-connector-python = "^9.0.0"
pre-commit = "^3.7.1"
pre-commit = "^3.8.0"
psutil = "^6.0.0"
py-cord = "^2.5.0"
pyright = "^1.1.371"
pydantic = "^2.8.2"
pyright = "^1.1.377"
python = "^3.12"
python-dotenv = "^1.0.1"
pytimeparse = "^1.1.8"
pytz = "^2024.1"
ruff = "^0.5.2"
pyyaml = "^6.0.2"
reactionmenu = "^3.1.7"
ruff = "^0.6.2"
typing-extensions = "^4.12.2"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]
[tool.ruff]
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
"examples",
"tmp",
"tests",
".archive",
"stubs",
]
indent-width = 4
line-length = 120
target-version = "py312"
# Ruff Linting Configuration
[tool.ruff.lint]
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
fixable = ["ALL"]
ignore = ["E501", "N814", "PLR0913", "PLR2004"]
select = [
"I", # isort
"E", # pycodestyle-error
"F", # pyflakes
"PERF", # perflint
"N", # pep8-naming
"TRY", # tryceratops
"UP", # pyupgrade
"FURB", # refurb
"PL", # pylint
"B", # flake8-bugbear
"SIM", # flake8-simplify
"ASYNC", # flake8-async
"A", # flake8-builtins
"C4", # flake8-comprehensions
"DTZ", # flake8-datetimez
"EM", # flake8-errmsg
"PIE", # flake8-pie
"T20", # flake8-print
"Q", # flake8-quotes
"RET", # flake8-return
"PTH", # flake8-use-pathlib
"INP", # flake8-no-pep420
"RSE", # flake8-raise
"ICN", # flake8-import-conventions
"RUF", # ruff
]
unfixable = []
# Ruff Formatting Configuration
[tool.ruff.format]
docstring-code-format = true
docstring-code-line-length = "dynamic"
indent-style = "space"
line-ending = "lf"
quote-style = "double"
skip-magic-trailing-comma = false
# Pyright Configuration
[tool.pyright]
defineConstant = {DEBUG = true}
exclude = [
".direnv",
".eggs",
".git",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
"examples",
"tests",
".archive",
"stubs",
]
include = ["**/*.py"]
pythonPlatform = "Linux"
pythonVersion = "3.12"
reportMissingTypeStubs = true
reportShadowedImports = false
stubPath = "./stubs"
typeCheckingMode = "strict"
venv = ".venv"
venvPath = "."

0
services/__init__.py Normal file
View file

View file

@ -1,17 +1,16 @@
import datetime
import pytz
from zoneinfo import ZoneInfo
from db import database
class Birthday:
def __init__(self, user_id, guild_id):
self.user_id = user_id
self.guild_id = guild_id
class BirthdayService:
def __init__(self, user_id: int, guild_id: int) -> None:
self.user_id: int = user_id
self.guild_id: int = guild_id
def set(self, birthday):
query = """
def set(self, birthday: datetime.date) -> None:
query: str = """
INSERT INTO birthdays (user_id, guild_id, birthday)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE birthday = VALUES(birthday);
@ -19,8 +18,8 @@ class Birthday:
database.execute_query(query, (self.user_id, self.guild_id, birthday))
def delete(self):
query = """
def delete(self) -> None:
query: str = """
DELETE FROM birthdays
WHERE user_id = %s AND guild_id = %s;
"""
@ -28,27 +27,26 @@ class Birthday:
database.execute_query(query, (self.user_id, self.guild_id))
@staticmethod
def get_birthdays_today():
query = """
def get_birthdays_today() -> list[tuple[int, int]]:
query: str = """
SELECT user_id, guild_id
FROM birthdays
WHERE DATE_FORMAT(birthday, '%m-%d') = %s
"""
tz = pytz.timezone("US/Eastern")
today = datetime.datetime.now(tz).strftime("%m-%d")
today: str = datetime.datetime.now(ZoneInfo("US/Eastern")).strftime("%m-%d")
return database.select_query(query, (today,))
@staticmethod
def get_upcoming_birthdays(guild_id):
query = """
def get_upcoming_birthdays(guild_id: int) -> list[tuple[int, str]]:
query: str = """
SELECT user_id, DATE_FORMAT(birthday, '%m-%d') AS upcoming_birthday
FROM birthdays
WHERE guild_id = %s
ORDER BY (DAYOFYEAR(birthday) - DAYOFYEAR(now()) + 366) % 366;
"""
data = database.select_query(query, (guild_id,))
data: list[tuple[int, str]] = database.select_query(query, (guild_id,))
return [(row[0], row[1]) for row in data]
return [(int(row[0]), str(row[1])) for row in data]

View file

@ -1,5 +1,3 @@
from typing import List, Optional, Tuple
from db import database
@ -7,7 +5,7 @@ class BlacklistUserService:
def __init__(self, user_id: int) -> None:
self.user_id: int = user_id
def add_to_blacklist(self, reason: Optional[str] = None) -> None:
def add_to_blacklist(self, reason: str | None = None) -> None:
"""
Adds a user to the blacklist with the given reason.
@ -37,5 +35,5 @@ class BlacklistUserService:
FROM blacklist_user
WHERE user_id = %s
"""
result: List[Tuple[bool]] = database.select_query(query, (user_id,))
result: list[tuple[bool]] = database.select_query(query, (user_id,))
return any(active for (active,) in result)

View file

@ -1,10 +1,10 @@
from typing import Optional, Dict, Any, List
from typing import Any
from db.database import execute_query, select_query_one, select_query_dict
from db.database import execute_query, select_query_dict, select_query_one
class CaseService:
def __init__(self):
def __init__(self) -> None:
pass
def create_case(
@ -13,10 +13,10 @@ class CaseService:
target_id: int,
moderator_id: int,
action_type: str,
reason: Optional[str] = None,
duration: Optional[int] = None,
expires_at: Optional[str] = None,
modlog_message_id: Optional[int] = None,
reason: str | None = None,
duration: int | None = None,
expires_at: str | None = None,
modlog_message_id: int | None = None,
) -> int:
# Get the next case number for the guild
query: str = """
@ -24,10 +24,11 @@ class CaseService:
FROM cases
WHERE guild_id = %s
"""
case_number = select_query_one(query, (guild_id,))
case_number: int | None = select_query_one(query, (guild_id,))
if case_number is None:
raise ValueError("Failed to retrieve the next case number.")
msg: str = "Failed to retrieve the next case number."
raise ValueError(msg)
# Insert the new case
query: str = """
@ -54,8 +55,8 @@ class CaseService:
return int(case_number)
def close_case(self, guild_id, case_number):
query = """
def close_case(self, guild_id: int, case_number: int) -> None:
query: str = """
UPDATE cases
SET is_closed = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE guild_id = %s AND case_number = %s
@ -66,9 +67,9 @@ class CaseService:
self,
guild_id: int,
case_number: int,
new_reason: Optional[str] = None,
new_reason: str | None = None,
) -> bool:
query = """
query: str = """
UPDATE cases
SET reason = COALESCE(%s, reason),
updated_at = CURRENT_TIMESTAMP
@ -84,88 +85,84 @@ class CaseService:
)
return True
def edit_case(self, guild_id, case_number, changes: dict):
set_clause = ", ".join([f"{key} = %s" for key in changes.keys()])
query = f"""
def edit_case(self, guild_id: int, case_number: int, changes: dict[str, Any]) -> None:
set_clause: str = ", ".join([f"{key} = %s" for key in changes])
query: str = f"""
UPDATE cases
SET {set_clause}, updated_at = CURRENT_TIMESTAMP
WHERE guild_id = %s AND case_number = %s
"""
execute_query(query, (*changes.values(), guild_id, case_number))
def fetch_case_by_id(self, case_id: int) -> Optional[Dict[str, Any]]:
def _fetch_cases(self, query: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = select_query_dict(query, params)
return results
def _fetch_single_case(self, query: str, params: tuple[Any, ...]) -> dict[str, Any] | None:
result = self._fetch_cases(query, params)
return result[0] if result else None
def fetch_case_by_id(self, case_id: int) -> dict[str, Any] | None:
query: str = """
SELECT * FROM cases
WHERE id = %s
LIMIT 1
"""
result: List[Dict[str, Any]] = select_query_dict(query, (case_id,))
return result[0] if result else None
return self._fetch_single_case(query, (case_id,))
def fetch_case_by_guild_and_number(
self,
guild_id: int,
case_number: int,
) -> Optional[Dict[str, Any]]:
) -> dict[str, Any] | None:
query: str = """
SELECT * FROM cases
WHERE guild_id = %s AND case_number = %s
ORDER BY case_number DESC
LIMIT 1
"""
result: List[Dict[str, Any]] = select_query_dict(query, (guild_id, case_number))
return result[0] if result else None
return self._fetch_single_case(query, (guild_id, case_number))
def fetch_cases_by_guild(self, guild_id: int) -> List[Dict[str, Any]]:
def fetch_cases_by_guild(self, guild_id: int) -> list[dict[str, Any]]:
query: str = """
SELECT * FROM cases
WHERE guild_id = %s
ORDER BY case_number DESC
"""
results: List[Dict[str, Any]] = select_query_dict(query, (guild_id,))
return results
return self._fetch_cases(query, (guild_id,))
def fetch_cases_by_target(
self,
guild_id: int,
target_id: int,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
query: str = """
SELECT * FROM cases
WHERE guild_id = %s AND target_id = %s
ORDER BY case_number DESC
"""
results: List[Dict[str, Any]] = select_query_dict(query, (guild_id, target_id))
return results
return self._fetch_cases(query, (guild_id, target_id))
def fetch_cases_by_moderator(
self,
guild_id: int,
moderator_id: int,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
query: str = """
SELECT * FROM cases
WHERE guild_id = %s AND moderator_id = %s
ORDER BY case_number DESC
"""
results: List[Dict[str, Any]] = select_query_dict(
query,
(guild_id, moderator_id),
)
return results
return self._fetch_cases(query, (guild_id, moderator_id))
def fetch_cases_by_action_type(
self,
guild_id: int,
action_type: str,
) -> List[Dict[str, Any]]:
) -> list[dict[str, Any]]:
query: str = """
SELECT * FROM cases
WHERE guild_id = %s AND action_type = %s
ORDER BY case_number DESC
"""
results: List[Dict[str, Any]] = select_query_dict(
query,
(guild_id, action_type.upper()),
)
return results
return self._fetch_cases(query, (guild_id, action_type.upper()))

View file

@ -1,28 +1,30 @@
from typing import Any
from db import database
class GuildConfig:
def __init__(self, guild_id):
self.guild_id = guild_id
self.birthday_channel_id = None
self.command_channel_id = None
self.intro_channel_id = None
self.welcome_channel_id = None
self.welcome_message = None
self.boost_channel_id = None
self.boost_message = None
self.boost_image_url = None
self.level_channel_id = None
self.level_message = None
self.level_message_type = 1
def __init__(self, guild_id: int) -> None:
self.guild_id: int = guild_id
self.birthday_channel_id: int | None = None
self.command_channel_id: int | None = None
self.intro_channel_id: int | None = None
self.welcome_channel_id: int | None = None
self.welcome_message: str | None = None
self.boost_channel_id: int | None = None
self.boost_message: str | None = None
self.boost_image_url: str | None = None
self.level_channel_id: int | None = None
self.level_message: str | None = None
self.level_message_type: int = 1
self.fetch_or_create_config()
def fetch_or_create_config(self):
def fetch_or_create_config(self) -> None:
"""
Gets a Guild Config from the database or inserts a new row if it doesn't exist yet.
"""
query = """
query: str = """
SELECT birthday_channel_id, command_channel_id, intro_channel_id,
welcome_channel_id, welcome_message, boost_channel_id,
boost_message, boost_image_url, level_channel_id,
@ -38,35 +40,24 @@ class GuildConfig:
database.execute_query(query, (self.guild_id,))
# TODO Rename this here and in `fetch_or_create_config`
def _extracted_from_fetch_or_create_config_14(self, query):
def _extracted_from_fetch_or_create_config_14(self, query: str) -> None:
result: tuple[Any, ...] = database.select_query(query, (self.guild_id,))[0]
(
birthday_channel_id,
command_channel_id,
intro_channel_id,
welcome_channel_id,
welcome_message,
boost_channel_id,
boost_message,
boost_image_url,
level_channel_id,
level_message,
level_message_type,
) = database.select_query(query, (self.guild_id,))[0]
self.birthday_channel_id,
self.command_channel_id,
self.intro_channel_id,
self.welcome_channel_id,
self.welcome_message,
self.boost_channel_id,
self.boost_message,
self.boost_image_url,
self.level_channel_id,
self.level_message,
self.level_message_type,
) = result
self.birthday_channel_id = birthday_channel_id
self.command_channel_id = command_channel_id
self.intro_channel_id = intro_channel_id
self.welcome_channel_id = welcome_channel_id
self.welcome_message = welcome_message
self.boost_channel_id = boost_channel_id
self.boost_message = boost_message
self.boost_image_url = boost_image_url
self.level_channel_id = level_channel_id
self.level_message = level_message
self.level_message_type = level_message_type
def push(self):
query = """
def push(self) -> None:
query: str = """
UPDATE guild_config
SET
birthday_channel_id = %s,
@ -102,18 +93,18 @@ class GuildConfig:
)
@staticmethod
def get_prefix(message):
def get_prefix(message: Any) -> str:
"""
Gets the prefix from a given guild.
This function is done as static method to make the prefix fetch process faster.
"""
query = """
query: str = """
SELECT prefix
FROM guild_config
WHERE guild_id = %s
"""
prefix = database.select_query_one(
prefix: str | None = database.select_query_one(
query,
(message.guild.id if message.guild else None,),
)
@ -121,8 +112,8 @@ class GuildConfig:
return prefix or "."
@staticmethod
def get_prefix_from_guild_id(guild_id):
query = """
def get_prefix_from_guild_id(guild_id: int) -> str:
query: str = """
SELECT prefix
FROM guild_config
WHERE guild_id = %s
@ -131,11 +122,11 @@ class GuildConfig:
return database.select_query_one(query, (guild_id,)) or "."
@staticmethod
def set_prefix(guild_id, prefix):
def set_prefix(guild_id: int, prefix: str) -> None:
"""
Sets the prefix for a given guild.
"""
query = """
query: str = """
UPDATE guild_config
SET prefix = %s
WHERE guild_id = %s;

Some files were not shown because too many files have changed in this diff Show more