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= TOKEN=
INSTANCE= INSTANCE=
OWNER_IDS= OWNER_IDS=
XP_GAIN_PER_MESSAGE= XP_GAIN_PER_MESSAGE=
XP_GAIN_COOLDOWN= XP_GAIN_COOLDOWN=
DBX_OAUTH2_REFRESH_TOKEN= DBX_OAUTH2_REFRESH_TOKEN=
DBX_APP_KEY= DBX_APP_KEY=
DBX_APP_SECRET= DBX_APP_SECRET=
MARIADB_USER= MARIADB_USER=
MARIADB_PASSWORD= MARIADB_PASSWORD=
MARIADB_ROOT_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 rev: v4.6.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: sort-simple-yaml
files: settings.yaml
- id: check-json - id: check-json
- id: pretty-format-json
args: [--autofix]
- id: check-toml - id: check-toml
- repo: https://github.com/asottile/add-trailing-comma - repo: https://github.com/asottile/add-trailing-comma
@ -16,7 +20,7 @@ repos:
hooks: hooks:
# Run the linter. # Run the linter.
- id: ruff - id: ruff
args: [ --fix ] args: [--fix]
# Run the formatter. # Run the formatter.
- id: ruff-format - id: ruff-format
@ -25,11 +29,6 @@ repos:
hooks: hooks:
- id: gitleaks - id: gitleaks
- repo: https://github.com/hija/clean-dotenv
rev: v0.0.7
hooks:
- id: clean-dotenv
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.16.0 rev: v3.16.0
hooks: 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 LANG=en_US.UTF-8
ENV LC_ALL=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. 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. 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 ### Requirements

0
db/__init__.py Normal file
View file

View file

@ -1,12 +1,13 @@
import os import os
import pathlib import pathlib
import re import re
from typing import Any
import mysql.connector import mysql.connector
from loguru import logger from loguru import logger
from mysql.connector import pooling 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: def create_connection_pool(name: str, size: int) -> pooling.MySQLConnectionPool:
@ -27,37 +28,33 @@ try:
_cnxpool = create_connection_pool("core-pool", 25) _cnxpool = create_connection_pool("core-pool", 25)
except mysql.connector.Error as e: except mysql.connector.Error as e:
logger.critical(f"Couldn't create the MySQL connection pool: {e}") logger.critical(f"Couldn't create the MySQL connection pool: {e}")
raise e raise
def execute_query(query, values=None): def execute_query(query: str, values: tuple[Any, ...] | None = None) -> None:
with _cnxpool.get_connection() as conn: with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
with conn.cursor() as cursor: cursor.execute(query, values)
cursor.execute(query, values) conn.commit()
conn.commit() return cursor
return cursor
def select_query(query, values=None): def select_query(query: str, values: tuple[Any, ...] | None = None) -> list[Any]:
with _cnxpool.get_connection() as conn: with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
with conn.cursor() as cursor: cursor.execute(query, values)
cursor.execute(query, values) return cursor.fetchall()
return cursor.fetchall()
def select_query_one(query, values=None): def select_query_one(query: str, values: tuple[Any, ...] | None = None) -> Any | None:
with _cnxpool.get_connection() as conn: with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
with conn.cursor() as cursor: cursor.execute(query, values)
cursor.execute(query, values) output = cursor.fetchone()
output = cursor.fetchone() return output[0] if output else None
return output[0] if output else None
def select_query_dict(query, values=None): def select_query_dict(query: str, values: tuple[Any, ...] | None = None) -> list[dict[str, Any]]:
with _cnxpool.get_connection() as conn: with _cnxpool.get_connection() as conn, conn.cursor(dictionary=True) as cursor:
with conn.cursor(dictionary=True) as cursor: cursor.execute(query, values)
cursor.execute(query, values) return cursor.fetchall()
return cursor.fetchall()
def run_migrations(): def run_migrations():
@ -66,10 +63,9 @@ def run_migrations():
[f for f in os.listdir(migrations_dir) if f.endswith(".sql")], [f for f in os.listdir(migrations_dir) if f.endswith(".sql")],
) )
with _cnxpool.get_connection() as conn: with _cnxpool.get_connection() as conn, conn.cursor() as cursor:
with conn.cursor() as cursor: # Create migrations table if it doesn't exist
# Create migrations table if it doesn't exist cursor.execute("""
cursor.execute("""
CREATE TABLE IF NOT EXISTS migrations ( CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
@ -77,39 +73,38 @@ def run_migrations():
) )
""") """)
for migration_file in migration_files: for migration_file in migration_files:
# Check if migration has already been applied # Check if migration has already been applied
cursor.execute(
"SELECT COUNT(*) FROM migrations WHERE filename = %s",
(migration_file,),
)
if cursor.fetchone()[0] > 0:
logger.debug(
f"Migration {migration_file} already applied, skipping.",
)
continue
# Read and execute migration file
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)
for statement in statements:
if statement.strip():
cursor.execute(statement)
# Record successful migration
cursor.execute( cursor.execute(
"SELECT COUNT(*) FROM migrations WHERE filename = %s", "INSERT INTO migrations (filename) VALUES (%s)",
(migration_file,), (migration_file,),
) )
if cursor.fetchone()[0] > 0: conn.commit()
logger.debug( logger.debug(f"Successfully applied migration: {migration_file}")
f"Migration {migration_file} already applied, skipping.", except mysql.connector.Error as e:
) conn.rollback()
continue logger.error(f"Error applying migration {migration_file}: {e}")
raise
# Read and execute migration file logger.success("All database migrations completed.")
migration_sql = pathlib.Path(
os.path.join(migrations_dir, migration_file),
).read_text()
try:
# Split the migration file into individual statements
statements = re.split(r";\s*$", migration_sql, flags=re.MULTILINE)
for statement in statements:
if statement.strip():
cursor.execute(statement)
# Record successful migration
cursor.execute(
"INSERT INTO migrations (filename) VALUES (%s)",
(migration_file,),
)
conn.commit()
logger.debug(f"Successfully applied migration: {migration_file}")
except mysql.connector.Error as e:
conn.rollback()
logger.error(f"Error applying migration {migration_file}: {e}")
raise
logger.debug("All migrations completed.")

View file

View file

@ -106,4 +106,4 @@ CREATE TABLE IF NOT EXISTS blacklist_user (
timestamp TIMESTAMP NOT NULL DEFAULT NOW(), timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
active BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE,
PRIMARY KEY (user_id) PRIMARY KEY (user_id)
); );

View file

@ -18,4 +18,4 @@ CREATE TABLE IF NOT EXISTS custom_reactions (
-- Create indexes to speed up lookups -- Create indexes to speed up lookups
CREATE OR REPLACE INDEX idx_custom_reactions_guild_id ON custom_reactions(guild_id); CREATE OR REPLACE INDEX idx_custom_reactions_guild_id ON custom_reactions(guild_id);
CREATE OR REPLACE INDEX idx_custom_reactions_creator_id ON custom_reactions(creator_id); CREATE OR REPLACE INDEX idx_custom_reactions_creator_id ON custom_reactions(creator_id);
CREATE OR REPLACE INDEX idx_custom_reactions_trigger_text ON custom_reactions(trigger_text); CREATE OR REPLACE INDEX idx_custom_reactions_trigger_text ON custom_reactions(trigger_text);

View file

@ -1,6 +1,6 @@
services: services:
core: 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 container_name: lumi-core
restart: always restart: always
env_file: env_file:
@ -25,4 +25,4 @@ services:
test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ] test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ]
interval: 5s interval: 5s
timeout: 10s timeout: 10s
retries: 5 retries: 5

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

View file

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

0
lib/__init__.py Normal file
View file

View file

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

View file

@ -1,24 +1,23 @@
from typing import Optional
import discord import discord
from discord.ext.commands import TextChannelConverter, UserConverter from discord.ext import commands
from loguru import logger from loguru import logger
from modules.moderation.utils.case_embed import create_case_embed from lib.exceptions import LumiException
from services.moderation.case_service import CaseService from services.case_service import CaseService
from services.moderation.modlog_service import ModLogService from services.modlog_service import ModLogService
from ui.cases import create_case_embed
case_service = CaseService() case_service = CaseService()
modlog_service = ModLogService() modlog_service = ModLogService()
async def create_case( async def create_case(
ctx, ctx: commands.Context[commands.Bot],
target: discord.User, target: discord.User,
action_type: str, action_type: str,
reason: Optional[str] = None, reason: str | None = None,
duration: Optional[int] = None, duration: int | None = None,
expires_at: Optional[str] = None, expires_at: str | None = None,
): ):
""" """
Creates a new moderation case and logs it to the modlog channel if configured. 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. 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. 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 guild_id = ctx.guild.id
moderator_id = ctx.author.id moderator_id = ctx.author.id
target_id = target.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): if mod_log_channel_id := modlog_service.fetch_modlog_channel_id(guild_id):
try: try:
mod_log_channel = await TextChannelConverter().convert( mod_log_channel = await commands.TextChannelConverter().convert(
ctx, ctx,
str(mod_log_channel_id), str(mod_log_channel_id),
) )
@ -90,7 +93,7 @@ async def create_case(
async def edit_case_modlog( async def edit_case_modlog(
ctx, ctx: commands.Context[commands.Bot],
guild_id: int, guild_id: int,
case_number: int, case_number: int,
new_reason: str, 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) case = case_service.fetch_case_by_guild_and_number(guild_id, case_number)
if not case: 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") modlog_message_id = case.get("modlog_message_id")
if not modlog_message_id: if not modlog_message_id:
@ -121,12 +125,12 @@ async def edit_case_modlog(
return False return False
try: try:
mod_log_channel = await TextChannelConverter().convert( mod_log_channel = await commands.TextChannelConverter().convert(
ctx, ctx,
str(mod_log_channel_id), str(mod_log_channel_id),
) )
message = await mod_log_channel.fetch_message(modlog_message_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( updated_embed: discord.Embed = create_case_embed(
ctx=ctx, 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 from services.config_service import GuildConfig
def birthdays_enabled(): def birthdays_enabled():
async def predicate(ctx): async def predicate(interaction: discord.Interaction) -> bool:
if ctx.guild is None: if interaction.guild is None:
return True return True
guild_config = GuildConfig(ctx.guild.id) guild_config = GuildConfig(interaction.guild.id)
if guild_config.birthday_channel_id is None:
if not guild_config.birthday_channel_id: raise BirthdaysDisabled
raise LumiExceptions.BirthdaysDisabled
return True 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 import textwrap
from typing import Any
import discord import discord
from discord.ext import commands from discord.ext import commands
from pytimeparse import parse from pytimeparse import parse # type: ignore
from lib.constants import CONST from lib import exceptions
from lib.exceptions.LumiExceptions import LumiException from lib.const import CONST
from services.config_service import GuildConfig 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) 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. Attempts to retrieve the prefix for the given guild context.
@ -90,7 +92,7 @@ def get_prefix(ctx: commands.Context) -> str:
return "." 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. 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: try:
return ctx.invoked_with return ctx.invoked_with
except (discord.ApplicationCommandInvokeError, AttributeError):
except (discord.app_commands.CommandInvokeError, AttributeError):
return ctx.command.name if ctx.command else None return ctx.command.name if ctx.command else None
def format_duration_to_seconds(duration: str) -> int: 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): try:
return parsed_duration parsed_duration: int = parse(duration) # type: ignore
else: return max(0, parsed_duration)
raise LumiException(CONST.STRINGS["error_invalid_duration"].format(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: 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: if days > 0:
return f"{days}d{hours}h" if hours > 0 else f"{days}d" 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" return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
else:
return f"{minutes}m" 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.", "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.", "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 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.", "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.", "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.", "At **Level {}**, you're like a firework that fizzles out before it even begins.",
@ -39,7 +39,7 @@
"*elevator music* Welcome to **level {}**." "*elevator music* Welcome to **level {}**."
], ],
"21-40": [ "21-40": [
"**Level {}** 👍", "**Level {}** \ud83d\udc4d",
"Look who's slacking off work to level up on Discord. **Level {}** and counting!", "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?", "**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?", "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 {}**.", "Lol it took you this long to reach **Level {}**.",
"**{}**.", "**{}**.",
"**Level {}**???? Who are you? Gear?", "**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.", "Conragulasions your level **{}** now.",
"Hey man congrats on reaching **Level {}**. I mean it. GG.", "Hey man congrats on reaching **Level {}**. I mean it. GG.",
"You reached **Level {}**!! What's it like being a loser?", "You reached **Level {}**!! What's it like being a loser?",
@ -76,4 +76,4 @@
"CONGRATIONS LEVE **{}**", "CONGRATIONS LEVE **{}**",
"Hahahahahahahahahhahahahaahahah. **Level {}**." "Hahahahahahahahahhahahahaahahah. **Level {}**."
] ]
} }

View file

@ -16,7 +16,10 @@
"admin_sync_error_description": "An error occurred while syncing: {0}", "admin_sync_error_description": "An error occurred while syncing: {0}",
"admin_sync_error_title": "Sync Error", "admin_sync_error_title": "Sync Error",
"admin_sync_title": "Sync Successful", "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_invalid_date": "The date you entered is invalid.",
"birthday_add_success_author": "Birthday Set", "birthday_add_success_author": "Birthday Set",
"birthday_add_success_description": "your birthday has been set to **{0} {1}**.", "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_delete_success_description": "your birthday has been deleted from this server.",
"birthday_leap_year": "February 29", "birthday_leap_year": "February 29",
"birthday_upcoming_author": "Upcoming Birthdays!", "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": "there are no upcoming birthdays in this server.",
"birthday_upcoming_no_birthdays_author": "No Upcoming Birthdays", "birthday_upcoming_no_birthdays_author": "No Upcoming Birthdays",
"blackjack_bet": "Bet ${0}", "blackjack_bet": "Bet ${0}",
@ -42,15 +45,15 @@
"blackjack_error": "I.. don't know if you won?", "blackjack_error": "I.. don't know if you won?",
"blackjack_error_description": "This is an error, please report it.", "blackjack_error_description": "This is an error, please report it.",
"blackjack_footer": "Game finished", "blackjack_footer": "Game finished",
"blackjack_hit": "hit",
"blackjack_lost": "You lost **${0}**.", "blackjack_lost": "You lost **${0}**.",
"blackjack_lost_generic": "You lost..", "blackjack_lost_generic": "You lost..",
"blackjack_player_hand": "**You**\nScore: {0}\n*Hand: {1}*", "blackjack_player_hand": "**You**\nScore: {0}\n*Hand: {1}*",
"blackjack_stand": "stand",
"blackjack_title": "BlackJack", "blackjack_title": "BlackJack",
"blackjack_won_21": "You won with a score of 21!", "blackjack_won_21": "You won with a score of 21!",
"blackjack_won_natural": "You won with a natural hand!", "blackjack_won_natural": "You won with a natural hand!",
"blackjack_won_payout": "You won **${0}**.", "blackjack_won_payout": "You won **${0}**.",
"blackjack_hit": "hit",
"blackjack_stand": "stand",
"boost_default_description": "Thanks for boosting, **{0}**!!", "boost_default_description": "Thanks for boosting, **{0}**!!",
"boost_default_title": "New Booster", "boost_default_title": "New Booster",
"case_case_field": "Case:", "case_case_field": "Case:",
@ -71,7 +74,7 @@
"case_reason_update_author": "Case Reason Updated", "case_reason_update_author": "Case Reason Updated",
"case_reason_update_description": "case `{0}` reason has been updated.", "case_reason_update_description": "case `{0}` reason has been updated.",
"case_target_field": "Target:", "case_target_field": "Target:",
"case_target_field_value": "`{0}` 🎯", "case_target_field_value": "`{0}` \ud83c\udfaf",
"case_type_field": "Type:", "case_type_field": "Type:",
"case_type_field_value": "`{0}`", "case_type_field_value": "`{0}`",
"case_type_field_value_with_duration": "`{0} ({1})`", "case_type_field_value_with_duration": "`{0} ({1})`",
@ -87,6 +90,7 @@
"config_boost_module_disabled": "the boost module was successfully disabled.", "config_boost_module_disabled": "the boost module was successfully disabled.",
"config_boost_template_field": "New Template:", "config_boost_template_field": "New Template:",
"config_boost_template_updated": "the boost message template has been updated.", "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_example_next_footer": "An example will be sent next.",
"config_level_channel_set": "all level announcements will be sent in {0}.", "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.", "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_template_updated": "the level template was successfully updated.",
"config_level_type_example": "Example:", "config_level_type_example": "Example:",
"config_level_type_generic": "level announcements will be **generic messages**.", "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": "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_channel_set": "moderation logs will be sent in {0}.",
"config_modlog_info_author": "Moderation Log Configuration", "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_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_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_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_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}`", "config_prefix_get": "the current prefix for this server is `{0}`",
@ -115,21 +119,24 @@
"config_show_author": "{0} Configuration", "config_show_author": "{0} Configuration",
"config_show_birthdays": "Birthdays", "config_show_birthdays": "Birthdays",
"config_show_boost_announcements": "Boost announcements", "config_show_boost_announcements": "Boost announcements",
"config_show_default_enabled": " Enabled (default)", "config_show_default_enabled": "\u2705 Enabled (default)",
"config_show_disabled": " Disabled", "config_show_disabled": "\u274c Disabled",
"config_show_enabled": " Enabled", "config_show_enabled": "\u2705 Enabled",
"config_show_guide": "Guide: {0}", "config_show_guide": "Guide: {0}",
"config_show_level_announcements": "Level announcements", "config_show_level_announcements": "Level announcements",
"config_show_moderation_log": "Moderation Log", "config_show_moderation_log": "Moderation Log",
"config_show_moderation_log_channel_deleted": "⚠️ **Not configured** (channel deleted?)", "config_show_moderation_log_channel_deleted": "\u26a0\ufe0f **Not configured** (channel deleted?)",
"config_show_moderation_log_enabled": " {0}", "config_show_moderation_log_enabled": "\u2705 {0}",
"config_show_moderation_log_not_configured": "⚠️ **Not configured yet**", "config_show_moderation_log_not_configured": "\u26a0\ufe0f **Not configured yet**",
"config_show_new_member_greets": "New member greets", "config_show_new_member_greets": "New member greets",
"config_welcome_channel_set": "I will announce new members in {0}.", "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_already_disabled": "the greeting module was already disabled.",
"config_welcome_module_disabled": "the greeting module was successfully disabled.", "config_welcome_module_disabled": "the greeting module was successfully disabled.",
"config_welcome_template_field": "New Template:", "config_welcome_template_field": "New Template:",
"config_welcome_template_updated": "the welcome message template has been updated.", "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_author": "Already Claimed",
"daily_already_claimed_description": "you can claim your daily reward again <t:{0}:R>.", "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", "daily_already_claimed_footer": "Daily reset is at 7 AM EST",
@ -137,12 +144,12 @@
"daily_success_claim_author": "Reward Claimed", "daily_success_claim_author": "Reward Claimed",
"daily_success_claim_description": "you claimed your reward of **${0}**!", "daily_success_claim_description": "you claimed your reward of **${0}**!",
"default_level_up_message": "**{0}** you have reached **Level {1}**.", "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_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_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_actionable_self": "you can't perform this action on yourself.",
"error_already_playing_blackjack": "you already have a game of blackjack running.", "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_author": "Birthdays Disabled",
"error_birthdays_disabled_description": "birthdays are disabled in this server.", "error_birthdays_disabled_description": "birthdays are disabled in this server.",
"error_birthdays_disabled_footer": "Contact a mod to enable them.", "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_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_author": "Bot Missing Permissions",
"error_bot_missing_permissions_description": "Lumi lacks the required permissions to run this command.", "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_author": "Command Cooldown",
"error_command_cooldown_description": "try again in **{0:02d}:{1:02d}**.", "error_command_cooldown_description": "try again in **{0:02d}:{1:02d}**.",
"error_command_not_found": "No command called \"{0}\" found.", "error_command_not_found": "No command called \"{0}\" found.",
@ -167,18 +175,24 @@
"error_no_private_message_author": "Guild Only", "error_no_private_message_author": "Guild Only",
"error_no_private_message_description": "this command can only be used in servers.", "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_enough_cash": "you don't have enough cash.",
"error_not_owner_author": "Owner Only", "error_not_owner": "{0} tried to use a bot admin command ({1})",
"error_not_owner_description": "this command requires Lumi ownership permissions.", "error_not_owner_unknown": "Unknown",
"error_out_of_time": "you ran out of time.", "error_out_of_time": "you ran out of time.",
"error_out_of_time_economy": "you ran out of time. Your bet was forfeited.", "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_author": "Private Message Only",
"error_private_message_only_description": "this command can only be used in private messages.", "error_private_message_only_description": "this command can only be used in private messages.",
"error_unknown_error_author": "Unknown Error", "error_unknown_error_author": "Unknown Error",
"error_unknown_error_description": "an unknown error occurred. Please try again later.", "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_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`", "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_database_records": "**Database:** {0} records",
"info_latency": "**Latency:** {0}ms\n", "info_latency": "**Latency:** {0}ms\n",
"info_memory": "**Memory:** {0:.2f} MB\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": "you're not in a server that supports introductions.",
"intro_no_guild_author": "Server Not Supported", "intro_no_guild_author": "Server Not Supported",
"intro_post_confirmation": "your introduction has been posted in {0}!", "intro_post_confirmation": "your introduction has been posted in {0}!",
"intro_post_confirmation_author": "Introduction Posted",
"intro_preview_field": "**{0}:** {1}\n\n", "intro_preview_field": "**{0}:** {1}\n\n",
"intro_question_footer": "Type your answer below.", "intro_question_footer": "Type your answer below.",
"intro_service_name": "Introduction Service", "intro_service_name": "Introduction Service",
@ -203,15 +218,16 @@
"intro_timeout_author": "Timeout", "intro_timeout_author": "Timeout",
"intro_too_long": "your answer was too long, please keep it below 200 characters.", "intro_too_long": "your answer was too long, please keep it below 200 characters.",
"intro_too_long_author": "Answer Too Long", "intro_too_long_author": "Answer Too Long",
"invite_author": "Invite Lumi",
"invite_button_text": "Invite Lumi", "invite_button_text": "Invite Lumi",
"invite_description": "Thanks for inviting me to your server!", "invite_description": "thanks for inviting me to your server!",
"level_up": "📈 | **{0}** you have reached **Level {1}**.", "level_up": "\ud83d\udcc8 | **{0}** you have reached **Level {1}**.",
"level_up_prefix": "📈 | **{0}** ", "level_up_prefix": "\ud83d\udcc8 | **{0}** ",
"lumi_exception_blacklisted": "User is blacklisted", "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_ban_dm": "**{0}** you have been banned from `{1}`.\n\n**Reason:** `{2}`",
"mod_banned_author": "User Banned", "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_not_sent": "Failed to notify them in DM",
"mod_dm_sent": "notified them in DM", "mod_dm_sent": "notified them in DM",
"mod_kick_dm": "**{0}** you have been kicked from `{1}`.\n\n**Reason:** `{2}`", "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_author": "User Timed Out",
"mod_timed_out_user": "user `{0}` has been 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_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_unbanned_author": "User Unbanned",
"mod_untimed_out": "timeout has been removed for user `{0}`.", "mod_untimed_out": "timeout has been removed for user `{0}`.",
"mod_untimed_out_author": "User Timeout Removed", "mod_untimed_out_author": "User Timeout Removed",
@ -239,10 +256,15 @@
"mod_warned_user": "user `{0}` has been warned.", "mod_warned_user": "user `{0}` has been warned.",
"ping_author": "I'm online!", "ping_author": "I'm online!",
"ping_footer": "Latency: {0}ms", "ping_footer": "Latency: {0}ms",
"ping_pong": "Pong!", "ping_pong": "pong!",
"ping_uptime": "I've been online since <t:{0}:R>.", "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}**.", "slowmode_channel_not_found": "Channel not found.",
"stats_slots": "🎰 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.", "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_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.", "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", "triggers_add_author": "Custom Reaction Created",
@ -283,14 +305,5 @@
"xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`", "xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`",
"xp_level": "Level {0}", "xp_level": "Level {0}",
"xp_progress": "Progress to next level", "xp_progress": "Progress to next level",
"xp_server_rank": "Server Rank: #{0}", "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!"
}

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,23 +1,53 @@
import discord import discord
from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.currency_service import Currency from services.currency_service import Currency
from ui.embeds import Builder
async def cmd(ctx, user: discord.User, amount: int): class Award(commands.Cog):
# Currency handler def __init__(self, bot: commands.Bot):
curr = Currency(user.id) self.bot = bot
curr.add_balance(amount) self.award_command.usage = lib.format.generate_usage(self.award_command)
curr.push()
embed = EmbedBuilder.create_success_embed( @commands.command(name="award", aliases=["aw"])
ctx, @commands.is_owner()
author_text=CONST.STRINGS["admin_award_title"], async def award_command(
description=CONST.STRINGS["admin_award_description"].format( self,
Currency.format(amount), ctx: commands.Context[commands.Bot],
user.name, user: discord.User,
), amount: int,
) ) -> None:
"""
Award a user with a specified amount of currency.
await ctx.respond(embed=embed) 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 = 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),
user.name,
),
)
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 import discord
from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.blacklist_service import BlacklistUserService from services.blacklist_service import BlacklistUserService
from ui.embeds import Builder
async def blacklist_user( class Blacklist(commands.Cog):
ctx, def __init__(self, bot: commands.Bot):
user: discord.User, self.bot = bot
reason: Optional[str] = None, self.blacklist_command.usage = lib.format.generate_usage(self.blacklist_command)
) -> None:
blacklist_service = BlacklistUserService(user.id)
blacklist_service.add_to_blacklist(reason)
embed = EmbedBuilder.create_success_embed( @commands.command(name="blacklist")
ctx, @commands.is_owner()
author_text=CONST.STRINGS["admin_blacklist_author"], async def blacklist_command(
description=CONST.STRINGS["admin_blacklist_description"].format(user.name), self,
footer_text=CONST.STRINGS["admin_blacklist_footer"], ctx: commands.Context[commands.Bot],
hide_timestamp=True, user: discord.User,
) *,
reason: str | None = None,
) -> None:
"""
Blacklist a user from the bot.
await ctx.send(embed=embed) 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 = 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_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,85 +1,211 @@
import asyncio
import calendar import calendar
import datetime import datetime
import random
from zoneinfo import ZoneInfo
import discord 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.checks import birthdays_enabled
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.birthday_service import Birthday 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()
leap_year = 2020 @app_commands.default_permissions(manage_guild=True)
max_days = calendar.monthrange(leap_year, month_index)[1] class Birthday(commands.GroupCog, group_name="birthday"):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.daily_birthday_check.start()
if not 1 <= day <= max_days: @tasks.loop(time=datetime.time(hour=12, minute=0, tzinfo=ZoneInfo("UTC")))
raise commands.BadArgument(CONST.STRINGS["birthday_add_invalid_date"]) 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
date_obj = datetime.datetime(leap_year, month_index, day) 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)
birthday = Birthday(ctx.author.id, ctx.guild.id) if not guild_config.birthday_channel_id:
birthday.set(date_obj) logger.debug(
CONST.STRINGS["birthday_check_skipped"].format(guild.id),
)
continue
embed = EmbedBuilder.create_success_embed( message = random.choice(CONST.BIRTHDAY_MESSAGES)
ctx, embed = Builder.create_embed(
author_text=CONST.STRINGS["birthday_add_success_author"], theme="success",
description=CONST.STRINGS["birthday_add_success_description"].format( author_text="Happy Birthday!",
month, description=message.format(member.name),
day, hide_name_in_description=True,
), )
show_name=True, embed.set_image(url=CONST.BIRTHDAY_GIF_URL)
)
await ctx.respond(embed=embed)
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
async def delete(ctx): except Exception as e:
Birthday(ctx.author.id, ctx.guild.id).delete() logger.warning(
CONST.STRINGS["birthday_check_error"].format(user_id, guild_id, e),
)
failed_birthdays += 1
embed = EmbedBuilder.create_success_embed( # wait one second to avoid rate limits
ctx, await asyncio.sleep(1)
author_text=CONST.STRINGS["birthday_delete_success_author"],
description=CONST.STRINGS["birthday_delete_success_description"],
show_name=True,
)
await ctx.respond(embed=embed)
logger.info(
async def upcoming(ctx): CONST.STRINGS["birthday_check_finished"].format(
upcoming_birthdays = Birthday.get_upcoming_birthdays(ctx.guild.id) processed_birthdays,
failed_birthdays,
if not upcoming_birthdays: ),
embed = EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["birthday_upcoming_no_birthdays_author"],
description=CONST.STRINGS["birthday_upcoming_no_birthdays"],
show_name=True,
) )
await ctx.respond(embed=embed)
return
embed = EmbedBuilder.create_success_embed( @app_commands.command(name="set")
ctx, @birthdays_enabled()
author_text=CONST.STRINGS["birthday_upcoming_author"], @app_commands.choices(
description="", month=[discord.app_commands.Choice(name=month_name, value=month_name) for month_name in CONST.BIRTHDAY_MONTHS],
show_name=False,
) )
embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT) async def set_birthday(
self,
interaction: discord.Interaction,
month: str,
day: int,
) -> None:
"""
Set your birthday.
birthday_lines = [] Parameters
for user_id, birthday in upcoming_birthdays[:10]: ----------
try: interaction : discord.Interaction
member = await ctx.guild.fetch_member(user_id) The interaction object.
birthday_date = datetime.datetime.strptime(birthday, "%m-%d") month : Month
formatted_birthday = birthday_date.strftime("%B %-d") The month of your birthday.
birthday_lines.append( day : int
CONST.STRINGS["birthday_upcoming_description_line"].format( The day of your birthday.
member.name, """
formatted_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, tzinfo=ZoneInfo("US/Eastern"))
birthday = BirthdayService(interaction.user.id, interaction.guild.id)
birthday.set(date_obj)
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,
),
)
await interaction.response.send_message(embed=embed)
@app_commands.command(name="remove")
async def remove_birthday(
self,
interaction: discord.Interaction,
) -> None:
"""
Remove your birthday.
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"],
)
await interaction.response.send_message(embed=embed)
@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 = 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"],
) )
except (discord.HTTPException, ValueError): await interaction.response.send_message(embed=embed)
continue return
embed.description = "\n".join(birthday_lines) embed = Builder.create_embed(
await ctx.respond(embed=embed) theme="success",
user_name=interaction.user.name,
author_text=CONST.STRINGS["birthday_upcoming_author"],
description="",
)
embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT)
birthday_lines: list[str] = []
for user_id, birthday in upcoming_birthdays[:10]:
try:
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(
member.name,
formatted_birthday,
),
)
except (discord.HTTPException, ValueError):
continue
embed.description = "\n".join(birthday_lines)
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 from discord.ext import commands
import lib.format
from lib.const import CONST
from services.currency_service import Currency from services.currency_service import Currency
from lib.constants import CONST from ui.embeds import Builder
from lib.embed_builder import EmbedBuilder
async def cmd(ctx: commands.Context[commands.Bot]) -> None: class Balance(commands.Cog):
ctx_currency = Currency(ctx.author.id) def __init__(self, bot: commands.Bot) -> None:
balance = Currency.format(ctx_currency.balance) self.bot: commands.Bot = bot
self.balance.usage = lib.format.generate_usage(self.balance)
embed = EmbedBuilder.create_success_embed( @commands.hybrid_command(
ctx, name="balance",
author_text=CONST.STRINGS["balance_author"].format(ctx.author.name), aliases=["bal", "$"],
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,
) )
@commands.guild_only()
async def balance(
self,
ctx: commands.Context[commands.Bot],
) -> None:
"""
Check your current balance.
await ctx.respond(embed=embed) Parameters
----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
ctx_currency = Currency(ctx.author.id)
balance = Currency.format(ctx_currency.balance)
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"],
hide_name_in_description=True,
hide_time=True,
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Balance(bot))

View file

@ -1,324 +1,303 @@
import random import random
from typing import List, Tuple from zoneinfo import ZoneInfo
from loguru import logger
import discord import discord
from discord.ui import View
import pytz
from discord.ext import commands from discord.ext import commands
from loguru import logger
from lib.constants import CONST import lib.format
from lib.exceptions.LumiExceptions import LumiException from lib.const import CONST
from lib.exceptions import LumiException
from services.currency_service import Currency from services.currency_service import Currency
from services.stats_service import BlackJackStats 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] = {} ACTIVE_BLACKJACK_GAMES: dict[int, bool] = {}
Card = str Card = str
Hand = List[Card] Hand = list[Card]
async def cmd(ctx: commands.Context, bet: int) -> None: class Blackjack(commands.Cog):
if ctx.author.id in ACTIVE_BLACKJACK_GAMES: def __init__(self, bot: commands.Bot) -> None:
raise LumiException(CONST.STRINGS["error_already_playing_blackjack"]) self.bot: commands.Bot = bot
self.blackjack.usage = lib.format.generate_usage(self.blackjack)
currency = Currency(ctx.author.id) @commands.hybrid_command(
if bet > currency.balance: name="blackjack",
raise LumiException(CONST.STRINGS["error_not_enough_cash"]) aliases=["bj"],
if bet <= 0: )
raise LumiException(CONST.STRINGS["error_invalid_bet"]) @commands.guild_only()
async def blackjack(
self,
ctx: commands.Context[commands.Bot],
bet: int,
) -> None:
"""
Play a game of blackjack.
ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True 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"])
try: currency = Currency(ctx.author.id)
await play_blackjack(ctx, currency, bet) if bet > currency.balance:
except Exception as e: raise LumiException(CONST.STRINGS["error_not_enough_cash"])
logger.exception(f"Error in blackjack game: {e}") if bet <= 0:
raise LumiException(CONST.STRINGS["error_blackjack_game_error"]) from e raise LumiException(CONST.STRINGS["error_invalid_bet"])
finally:
del ACTIVE_BLACKJACK_GAMES[ctx.author.id]
ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True
async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) -> None: try:
deck = get_new_deck() await self.play_blackjack(ctx, currency, bet)
player_hand, dealer_hand = initial_deal(deck) except Exception as e:
multiplier = CONST.BLACKJACK_MULTIPLIER 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]
player_value = calculate_hand_value(player_hand) async def play_blackjack(self, ctx: commands.Context[commands.Bot], currency: Currency, bet: int) -> None:
status = 5 if player_value == 21 else 0 deck = self.get_new_deck()
view = BlackJackButtons(ctx) player_hand, dealer_hand = self.initial_deal(deck)
playing_embed = False multiplier = CONST.BLACKJACK_MULTIPLIER
while status == 0: player_value = self.calculate_hand_value(player_hand)
dealer_value = calculate_hand_value(dealer_hand) status = 5 if player_value == 21 else 0
view = BlackJackButtons(ctx)
playing_embed = False
response_message: discord.Message | None = None
embed = create_game_embed( while status == 0:
dealer_value = self.calculate_hand_value(dealer_hand)
embed = self.create_game_embed(
ctx,
bet,
player_hand,
dealer_hand,
player_value,
dealer_value,
)
if not playing_embed:
response_message = await ctx.reply(embed=embed, view=view)
playing_embed = True
else:
assert response_message
await response_message.edit(embed=embed, view=view)
await view.wait()
if view.clickedHit:
player_hand.append(self.deal_card(deck))
player_value = self.calculate_hand_value(player_hand)
if player_value > 21:
status = 1
break
if player_value == 21:
status = 2
break
elif view.clickedStand:
status = self.dealer_play(deck, dealer_hand, player_value)
break
else:
currency.take_balance(bet)
currency.push()
raise LumiException(CONST.STRINGS["error_out_of_time_economy"])
view = BlackJackButtons(ctx)
await self.handle_game_end(
ctx, ctx,
response_message,
currency,
bet, bet,
player_hand, player_hand,
dealer_hand, dealer_hand,
player_value, status,
dealer_value, multiplier,
playing_embed,
) )
if not playing_embed:
await ctx.respond(embed=embed, view=view, content=ctx.author.mention) def initial_deal(self, deck: list[Card]) -> tuple[Hand, Hand]:
playing_embed = True return [self.deal_card(deck) for _ in range(2)], [self.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
async def handle_game_end(
self,
ctx: commands.Context[commands.Bot],
response_message: discord.Message | None,
currency: Currency,
bet: int,
player_hand: Hand,
dealer_hand: Hand,
status: int,
multiplier: float,
playing_embed: bool,
) -> 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 = self.create_end_game_embed(ctx, bet, player_value, dealer_value, payout, status)
if playing_embed:
assert response_message
await response_message.edit(embed=embed)
else: else:
await ctx.edit(embed=embed, view=view) await ctx.reply(embed=embed)
await view.wait() if is_won:
currency.add_balance(int(payout))
if view.clickedHit:
player_hand.append(deal_card(deck))
player_value = calculate_hand_value(player_hand)
if player_value > 21:
status = 1
break
elif player_value == 21:
status = 2
break
elif view.clickedStand:
status = dealer_play(deck, dealer_hand, player_value)
break
else: else:
currency.take_balance(bet) currency.take_balance(bet)
currency.push() currency.push()
raise LumiException(CONST.STRINGS["error_out_of_time_economy"])
view = BlackJackButtons(ctx) BlackJackStats(
user_id=ctx.author.id,
is_won=is_won,
bet=bet,
payout=int(payout) if is_won else 0,
hand_player=player_hand,
hand_dealer=dealer_hand,
).push()
await handle_game_end( def create_game_embed(
ctx, self,
currency, ctx: commands.Context[commands.Bot],
bet, bet: int,
player_hand, player_hand: Hand,
dealer_hand, dealer_hand: Hand,
status, player_value: int,
multiplier, dealer_value: int,
playing_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:])
def initial_deal(deck: List[Card]) -> Tuple[Hand, Hand]:
return [deal_card(deck) for _ in range(2)], [deal_card(deck)]
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,
currency: Currency,
bet: int,
player_hand: Hand,
dealer_hand: Hand,
status: int,
multiplier: float,
playing_embed: bool,
) -> None:
player_value = calculate_hand_value(player_hand)
dealer_value = 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)
if playing_embed:
await ctx.edit(embed=embed, view=None)
else:
await ctx.respond(embed=embed, view=None, content=ctx.author.mention)
currency.add_balance(payout) if is_won 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,
hand_player=player_hand,
hand_dealer=dealer_hand,
).push()
def create_game_embed(
ctx: commands.Context,
bet: int,
player_hand: Hand,
dealer_hand: Hand,
player_value: int,
dealer_value: int,
) -> 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:])
)
description = (
f"{CONST.STRINGS['blackjack_player_hand'].format(player_value, player_hand_str)}\n\n"
f"{CONST.STRINGS['blackjack_dealer_hand'].format(dealer_value, dealer_hand_str)}"
)
footer_text = (
f"{CONST.STRINGS['blackjack_bet'].format(Currency.format_human(bet))}"
f"{CONST.STRINGS['blackjack_deck_shuffled']}"
)
return EmbedBuilder.create_embed(
ctx,
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,
)
def create_end_game_embed(
ctx: commands.Context,
bet: int,
player_value: int,
dealer_value: int,
payout: int,
status: int,
) -> discord.Embed:
embed = EmbedBuilder.create_embed(
ctx,
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,
)
result = {
1: (
CONST.STRINGS["blackjack_busted"],
CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)),
discord.Color.red(),
CONST.CLOUD_ART,
),
2: (
CONST.STRINGS["blackjack_won_21"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
discord.Color.green(),
CONST.TROPHY_ART,
),
3: (
CONST.STRINGS["blackjack_dealer_busted"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
discord.Color.green(),
CONST.TROPHY_ART,
),
4: (
CONST.STRINGS["blackjack_lost_generic"],
CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)),
discord.Color.red(),
CONST.CLOUD_ART,
),
5: (
CONST.STRINGS["blackjack_won_natural"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(payout)),
discord.Color.green(),
CONST.TROPHY_ART,
),
}.get(
status,
(
CONST.STRINGS["blackjack_error"],
CONST.STRINGS["blackjack_error_description"],
discord.Color.red(),
None,
),
)
name, value, color, thumbnail_url = result
embed.add_field(name=name, value=value, inline=False)
embed.colour = color
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
return embed
def get_new_deck() -> List[Card]:
deck = [
rank + suit
for suit in ["", "", "", ""]
for rank in ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
]
random.shuffle(deck)
return deck
def deal_card(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]
)
aces = sum(card[0] == "A" for card in hand)
while value > 21 and aces:
value -= 10
aces -= 1
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
description = (
f"{CONST.STRINGS['blackjack_player_hand'].format(player_value, player_hand_str)}\n\n"
f"{CONST.STRINGS['blackjack_dealer_hand'].format(dealer_value, dealer_hand_str)}"
)
footer_text = (
f"{CONST.STRINGS['blackjack_bet'].format(Currency.format_human(bet))}"
f"{CONST.STRINGS['blackjack_deck_shuffled']}"
)
return Builder.create_embed(
theme="default",
user_name=ctx.author.name,
title=CONST.STRINGS["blackjack_title"],
description=description,
footer_text=footer_text,
footer_icon_url=CONST.MUFFIN_ART,
hide_name_in_description=True,
)
def create_end_game_embed(
self,
ctx: commands.Context[commands.Bot],
bet: int,
player_value: int,
dealer_value: int,
payout: int | float,
status: int,
) -> discord.Embed:
embed = Builder.create_embed(
theme="default",
user_name=ctx.author.name,
title=CONST.STRINGS["blackjack_title"],
description=CONST.STRINGS["blackjack_description"].format(
player_value,
dealer_value,
),
footer_text=CONST.STRINGS["blackjack_footer"],
footer_icon_url=CONST.MUFFIN_ART,
hide_name_in_description=True,
)
result = {
1: (
CONST.STRINGS["blackjack_busted"],
CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)),
discord.Color.red(),
CONST.CLOUD_ART,
),
2: (
CONST.STRINGS["blackjack_won_21"],
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(int(payout))),
discord.Color.green(),
CONST.TROPHY_ART,
),
4: (
CONST.STRINGS["blackjack_lost_generic"],
CONST.STRINGS["blackjack_lost"].format(Currency.format_human(bet)),
discord.Color.red(),
CONST.CLOUD_ART,
),
5: (
CONST.STRINGS["blackjack_won_natural"],
CONST.STRINGS["blackjack_won_payout"].format(Currency.format_human(int(payout))),
discord.Color.green(),
CONST.TROPHY_ART,
),
}.get(
status,
(
CONST.STRINGS["blackjack_error"],
CONST.STRINGS["blackjack_error_description"],
discord.Color.red(),
None,
),
)
name, value, color, thumbnail_url = result
embed.add_field(name=name, value=value, inline=False)
embed.colour = color
if thumbnail_url:
embed.set_thumbnail(url=thumbnail_url)
return embed
def get_new_deck(self) -> list[Card]:
deck = [
rank + suit
for suit in ["", "", "", ""]
for rank in ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
]
random.shuffle(deck)
return deck
def deal_card(self, deck: list[Card]) -> Card:
return deck.pop()
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
aces -= 1
return value
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 datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import lib.time from discord import Embed
from lib.constants import CONST from discord.ext import commands
from lib.embed_builder import EmbedBuilder
import lib.format
from lib.const import CONST
from services.currency_service import Currency from services.currency_service import Currency
from services.daily_service import Dailies from services.daily_service import Dailies
from ui.embeds import Builder
tz = ZoneInfo("US/Eastern")
async def cmd(ctx) -> None: def seconds_until(hours: int, minutes: int) -> int:
ctx_daily = Dailies(ctx.author.id) now = datetime.now(tz)
given_time = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
if not ctx_daily.can_be_claimed(): if given_time < now:
wait_time = datetime.now() + timedelta(seconds=lib.time.seconds_until(7, 0)) given_time += timedelta(days=1)
unix_time = int(round(wait_time.timestamp()))
error_embed = EmbedBuilder.create_error_embed(
ctx,
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)
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() return int((given_time - now).total_seconds())
embed = EmbedBuilder.create_success_embed(
ctx, class Daily(commands.Cog):
author_text=CONST.STRINGS["daily_success_claim_author"], def __init__(self, bot: commands.Bot) -> None:
description=CONST.STRINGS["daily_success_claim_description"].format( self.bot: commands.Bot = bot
Currency.format(ctx_daily.amount), self.daily.usage = lib.format.generate_usage(self.daily)
),
footer_text=CONST.STRINGS["daily_streak_footer"].format(ctx_daily.streak) @commands.hybrid_command(
if ctx_daily.streak > 1 name="daily",
else None, aliases=["timely"],
) )
@commands.guild_only()
async def daily(
self,
ctx: commands.Context[commands.Bot],
) -> None:
"""
Claim your daily reward.
await ctx.respond(embed=embed) 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 = 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.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: 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,
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Daily(bot))

View file

@ -1,39 +1,70 @@
import discord import discord
from discord.ext import commands 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 services.currency_service import Currency
from lib.constants import CONST from ui.embeds import Builder
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
async def cmd(ctx: commands.Context, user: discord.User, amount: int) -> None: class Give(commands.Cog):
if ctx.author.id == user.id: def __init__(self, bot: commands.Bot) -> None:
raise LumiException(CONST.STRINGS["give_error_self"]) self.bot: commands.Bot = bot
if user.bot: self.give.usage = lib.format.generate_usage(self.give)
raise LumiException(CONST.STRINGS["give_error_bot"])
if amount <= 0:
raise LumiException(CONST.STRINGS["give_error_invalid_amount"])
ctx_currency = Currency(ctx.author.id) @commands.hybrid_command(
target_currency = Currency(user.id) name="give",
if ctx_currency.balance < amount:
raise LumiException(CONST.STRINGS["give_error_insufficient_funds"])
target_currency.add_balance(amount)
ctx_currency.take_balance(amount)
ctx_currency.push()
target_currency.push()
embed = EmbedBuilder.create_success_embed(
ctx,
description=CONST.STRINGS["give_success"].format(
ctx.author.name,
Currency.format(amount),
user.name,
),
) )
@commands.guild_only()
async def give(
self,
ctx: commands.Context[commands.Bot],
user: discord.User,
amount: int,
) -> None:
"""
Give currency to another user.
await ctx.respond(embed=embed) 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:
raise LumiException(CONST.STRINGS["give_error_bot"])
if amount <= 0:
raise LumiException(CONST.STRINGS["give_error_invalid_amount"])
ctx_currency = Currency(ctx.author.id)
target_currency = Currency(user.id)
if ctx_currency.balance < amount:
raise LumiException(CONST.STRINGS["give_error_insufficient_funds"])
target_currency.add_balance(amount)
ctx_currency.take_balance(amount)
ctx_currency.push()
target_currency.push()
embed = Builder.create_embed(
theme="success",
user_name=ctx.author.name,
description=CONST.STRINGS["give_success"].format(
Currency.format(amount),
user.name,
),
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Give(bot))

View file

@ -2,213 +2,254 @@ import asyncio
import datetime import datetime
import random import random
from collections import Counter from collections import Counter
from zoneinfo import ZoneInfo
import discord import discord
import pytz
from discord.ext import commands 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.currency_service import Currency
from services.stats_service import SlotsStats from services.stats_service import SlotsStats
est = pytz.timezone("US/Eastern") est = ZoneInfo("US/Eastern")
async def cmd(self, ctx, bet): class Slots(commands.Cog):
ctx_currency = Currency(ctx.author.id) 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 @commands.hybrid_command(
if bet > player_balance: name="slots",
raise commands.BadArgument("you don't have enough cash.") aliases=["slot"],
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),
) )
await asyncio.sleep(1) @commands.guild_only()
async def slots(
self,
ctx: commands.Context[commands.Bot],
bet: int,
) -> None:
"""
Play the slots machine.
for i in range(2, 0, -1): Parameters
await ctx.edit( ----------
embed=slots_spinning(ctx, i, Currency.format_human(bet), results, emojis), 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),
) )
for i in range(2, 0, -1):
await asyncio.sleep(1)
await message.edit(
embed=self.slots_spinning(ctx, i, Currency.format_human(bet), results, emojis),
)
# output final result
finished_output: discord.Embed = self.slots_finished(
ctx,
result_type,
Currency.format_human(bet),
Currency.format_human(payout),
results,
emojis,
)
await asyncio.sleep(1) await asyncio.sleep(1)
await message.edit(embed=finished_output)
# output final result # user payout
finished_output = slots_finished( if payout > 0:
ctx, ctx_currency.add_balance(payout)
result_type,
Currency.format_human(bet),
Currency.format_human(payout),
results,
emojis,
)
await ctx.edit(embed=finished_output)
# user payout
if payout > 0:
ctx_currency.add_balance(payout)
else:
ctx_currency.take_balance(bet)
stats = SlotsStats(
user_id=ctx.author.id,
is_won=is_won,
bet=bet,
payout=payout,
spin_type=result_type,
icons=results,
)
ctx_currency.push()
stats.push()
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
# count occurrences of each item in the list
counts = Counter(results)
# no icons match
if len(counts) == 3:
result_type = "lost"
multiplier = 0
elif len(counts) == 2:
result_type = "pair"
multiplier = rewards[result_type]
elif len(counts) == 1:
if results[0] == 5:
result_type = "three_diamonds"
elif results[0] == 6:
result_type = "jackpot"
else: else:
result_type = "three_of_a_kind" ctx_currency.take_balance(bet)
multiplier = rewards[result_type]
payout = bet * multiplier stats: SlotsStats = SlotsStats(
return result_type, int(payout), multiplier user_id=ctx.author.id,
is_won=is_won,
bet=bet,
def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis): payout=payout,
first_slots_emote = emojis.get(f"slots_{results[0]}_id") spin_type=result_type,
second_slots_emote = emojis.get(f"slots_{results[1]}_id") icons=[str(icon) for icon in results],
slots_animated_emote = emojis.get("slots_animated_id")
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:
one = first_slots_emote
two = second_slots_emote
description = (
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"
f"{emojis['VBorder']}{one}{emojis['VBorder']}{two}{emojis['VBorder']}"
f"{three}{emojis['VBorder']}\n"
f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}"
f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}\n"
f"{emojis['Blank']}{emojis['Blank']}❓❓❓{emojis['Blank']}{emojis['Blank']}{emojis['Blank']}"
)
embed = discord.Embed(
description=description,
)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url)
embed.set_footer(
text=f"Bet ${bet} • jackpot = x15 • {current_time}",
icon_url="https://i.imgur.com/wFsgSnr.png",
)
return embed
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
if payout_type == "pair":
field_name = "Pair"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.dark_green()
elif payout_type == "three_of_a_kind":
field_name = "3 of a kind"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.dark_green()
elif payout_type == "three_diamonds":
field_name = "Triple Diamonds!"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.green()
elif payout_type == "jackpot":
field_name = "JACKPOT!!"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.green()
description = (
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"
f"{emojis['VBorder']}{first_slots_emote}{emojis['VBorder']}{second_slots_emote}"
f"{emojis['VBorder']}{third_slots_emote}{emojis['VBorder']}\n"
f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}"
f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}"
)
if is_lost:
description += (
f"\n{emojis['Blank']}{emojis['LCentered']}{emojis['OCentered']}{emojis['SCentered']}"
f"{emojis['ECentered']}{emojis['lost']}{emojis['Blank']}"
) )
else:
description += f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}"
embed = discord.Embed( ctx_currency.push()
color=color, stats.push()
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_footer(
text=f"Game finished • {current_time}",
icon_url="https://i.imgur.com/wFsgSnr.png",
)
return embed 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 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[int] = Counter(results)
# no icons match
if len(counts) == 3:
result_type = "lost"
multiplier = 0.0
elif len(counts) == 2:
result_type = "pair"
multiplier = rewards[result_type]
elif len(counts) == 1:
if results[0] == 5:
result_type = "three_diamonds"
elif results[0] == 6:
result_type = "jackpot"
else:
result_type = "three_of_a_kind"
multiplier = rewards[result_type]
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")
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
if spinning_icons_amount == 1:
one = first_slots_emote
two = second_slots_emote
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"
f"{emojis['VBorder']}{one}{emojis['VBorder']}{two}{emojis['VBorder']}"
f"{three}{emojis['VBorder']}\n"
f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}"
f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}\n"
f"{emojis['Blank']}{emojis['Blank']}❓❓❓{emojis['Blank']}{emojis['Blank']}{emojis['Blank']}"
)
embed: discord.Embed = discord.Embed(
description=description,
)
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",
)
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")
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"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.dark_green()
elif payout_type == "three_of_a_kind":
field_name = "3 of a kind"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.dark_green()
elif payout_type == "three_diamonds":
field_name = "Triple Diamonds!"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.green()
elif payout_type == "jackpot":
field_name = "JACKPOT!!"
field_value = f"You won **${payout}**."
is_lost = False
color = discord.Color.green()
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"
f"{emojis['VBorder']}{first_slots_emote}{emojis['VBorder']}{second_slots_emote}"
f"{emojis['VBorder']}{third_slots_emote}{emojis['VBorder']}\n"
f"{emojis['CBorderBLeft']}{emojis['HBorderB']}{emojis['HBorderB']}{emojis['HBorderB']}"
f"{emojis['HBorderB']}{emojis['HBorderB']}{emojis['CBorderBRight']}"
)
if is_lost:
description += (
f"\n{emojis['Blank']}{emojis['LCentered']}{emojis['OCentered']}{emojis['SCentered']}"
f"{emojis['ECentered']}{emojis['lost']}{emojis['Blank']}"
)
else:
description += (
f"\n{emojis['Blank']}🎉{emojis['WSmall']}{emojis['ISmall']}{emojis['NSmall']}🎉{emojis['Blank']}"
)
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 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 import Embed, Guild, Member
from discord.ext import bridge from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.currency_service import Currency from ui.embeds import Builder
from services.daily_service import Dailies from ui.views.leaderboard import LeaderboardCommandOptions, LeaderboardCommandView
from services.xp_service import XpService
async def cmd(ctx: bridge.Context) -> None: class Leaderboard(commands.Cog):
if not ctx.guild: def __init__(self, bot: commands.Bot) -> None:
return self.bot: commands.Bot = bot
self.leaderboard.usage = lib.format.generate_usage(self.leaderboard)
options = LeaderboardCommandOptions() @commands.hybrid_command(
view = LeaderboardCommandView(ctx, options) name="leaderboard",
aliases=["lb"],
# default leaderboard
embed = EmbedBuilder.create_success_embed(
ctx=ctx,
thumbnail_url=CONST.FLOWERS_ART,
show_name=False,
) )
async def leaderboard(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Get the leaderboard for the server.
icon = ctx.guild.icon.url if ctx.guild.icon else CONST.FLOWERS_ART Parameters
await view.populate_leaderboard("xp", embed, icon) ----------
ctx : commands.Context[commands.Bot]
The context of the command.
"""
guild: Guild | None = ctx.guild
if not guild:
return
await ctx.respond(embed=embed, view=view) options: LeaderboardCommandOptions = LeaderboardCommandOptions()
view: LeaderboardCommandView = LeaderboardCommandView(ctx, options)
author: Member = cast(Member, ctx.author)
class LeaderboardCommandOptions(discord.ui.Select): embed: Embed = Builder.create_embed(
""" theme="info",
This class specifies the options for the leaderboard command: user_name=author.name,
- XP thumbnail_url=author.display_avatar.url,
- Currency hide_name_in_description=True,
- 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: icon: str = guild.icon.url if guild.icon else CONST.FLOWERS_ART
if self.view: await view.populate_leaderboard("xp", embed, icon)
await self.view.on_select(self.values[0], interaction)
await ctx.send(embed=embed, view=view)
class LeaderboardCommandView(discord.ui.View): async def setup(bot: commands.Bot) -> None:
""" await bot.add_cog(Leaderboard(bot))
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,
)

View file

@ -1,31 +1,56 @@
from discord import Embed from discord import Embed
from discord.ext import bridge from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.xp_service import XpService from services.xp_service import XpService
from ui.embeds import Builder
async def rank(ctx: bridge.Context) -> None: class Level(commands.Cog):
if not ctx.guild: def __init__(self, bot: commands.Bot):
return self.bot = bot
self.level.usage = lib.format.generate_usage(self.level)
xp_data: XpService = XpService(ctx.author.id, ctx.guild.id) @commands.hybrid_command(
name="level",
rank: str = str(xp_data.calculate_rank()) aliases=["rank", "lvl", "xp"],
needed_xp_for_next_level: int = XpService.xp_needed_for_next_level(xp_data.level)
embed: Embed = EmbedBuilder.create_success_embed(
ctx=ctx,
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,
)
embed.add_field(
name=CONST.STRINGS["xp_progress"],
value=XpService.generate_progress_bar(xp_data.xp, needed_xp_for_next_level),
inline=False,
) )
async def level(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Get the level of the user.
await ctx.respond(embed=embed) 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,
)
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"),
thumbnail_url=ctx.author.display_avatar.url,
hide_name_in_description=True,
)
embed.add_field(
name=CONST.STRINGS["xp_progress"],
value=XpService.generate_progress_bar(xp_data.xp, needed_xp_for_next_level),
inline=False,
)
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 io import BytesIO
from typing import Optional
import discord
import httpx import httpx
from discord import File, Member from discord import File
from discord.ext import bridge from discord.ext import commands
client: httpx.AsyncClient = httpx.AsyncClient() import lib.format
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.")
async def create_avatar_file(url: str) -> File: async def create_avatar_file(url: str) -> File:
@ -50,9 +22,52 @@ async def create_avatar_file(url: str) -> File:
File File
The discord file. The discord file.
""" """
client: httpx.AsyncClient = httpx.AsyncClient()
response: httpx.Response = await client.get(url, timeout=10) response: httpx.Response = await client.get(url, timeout=10)
response.raise_for_status() response.raise_for_status()
image_data: bytes = response.content image_data: bytes = response.content
image_file: BytesIO = BytesIO(image_data) image_file: BytesIO = BytesIO(image_data)
image_file.seek(0) image_file.seek(0)
return File(image_file, filename="avatar.png") 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 import subprocess
from datetime import datetime from datetime import datetime
from typing import List, Optional from pathlib import Path
from zoneinfo import ZoneInfo
import dropbox import dropbox # type: ignore
from dropbox.files import FileMetadata from discord.ext import commands, tasks
from dropbox.files import FileMetadata # type: ignore
from loguru import logger from loguru import logger
from lib.constants import CONST from lib.const import CONST
# Initialize Dropbox client if instance is "main" # 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": if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
_app_key: Optional[str] = CONST.DBX_APP_KEY _app_key: str | None = CONST.DBX_APP_KEY
_dbx_token: Optional[str] = CONST.DBX_TOKEN _dbx_token: str | None = CONST.DBX_TOKEN
_app_secret: Optional[str] = CONST.DBX_APP_SECRET _app_secret: str | None = CONST.DBX_APP_SECRET
_dbx = dropbox.Dropbox( _dbx = dropbox.Dropbox(
app_key=_app_key, app_key=_app_key,
@ -22,36 +25,42 @@ if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
) )
async def create_db_backup() -> None: def run_db_dump() -> None:
if not _dbx:
raise ValueError("Dropbox client is not initialized")
backup_name: str = datetime.today().strftime("%Y-%m-%d_%H%M") + "_lumi.sql"
command: str = ( command: str = (
f"mariadb-dump --user={CONST.MARIADB_USER} --password={CONST.MARIADB_PASSWORD} " f"mariadb-dump --user={CONST.MARIADB_USER} --password={CONST.MARIADB_PASSWORD} "
f"--host=db --single-transaction --all-databases > ./db/migrations/100-dump.sql" f"--host=db --single-transaction --all-databases > ./db/migrations/100-dump.sql"
) )
subprocess.check_output(command, shell=True) 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: async def backup_cleanup() -> None:
if not _dbx: 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] = [ all_backup_files: list[str] = [entry.name for entry in result.entries if isinstance(entry, FileMetadata)] # type: ignore
entry.name
for entry in result.entries
if isinstance(entry, FileMetadata) # type: ignore
]
for file in sorted(all_backup_files)[:-48]: 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: async def backup() -> None:
@ -65,3 +74,22 @@ async def backup() -> None:
logger.error(f"Backup failed: {error}") logger.error(f"Backup failed: {error}")
else: else:
logger.debug('No backup, instance not "MAIN".') 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 discord
import psutil import psutil
from discord.ext import bridge from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from services.currency_service import Currency from ui.embeds import Builder
from services.stats_service import BlackJackStats
async def cmd(self, ctx: bridge.Context, unix_timestamp: int) -> None: class Info(commands.Cog):
memory_usage_in_mb: float = psutil.Process().memory_info().rss / (1024 * 1024) def __init__(self, bot: commands.Bot):
total_rows: str = Currency.format(BlackJackStats.get_total_rows_count()) self.bot = bot
self.info.usage = lib.format.generate_usage(self.info)
description: str = "".join( @commands.hybrid_command(
[ name="info",
CONST.STRINGS["info_uptime"].format(unix_timestamp),
CONST.STRINGS["info_latency"].format(round(1000 * self.client.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),
],
) )
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())
embed: discord.Embed = EmbedBuilder.create_success_embed( description: str = "".join(
ctx, [
description=description, CONST.STRINGS["info_latency"].format(round(1000 * self.bot.latency)),
footer_text=CONST.STRINGS["info_service_footer"], CONST.STRINGS["info_memory"].format(memory_usage_in_mb),
show_name=False, CONST.STRINGS["info_system"].format(platform.system(), os.name),
) CONST.STRINGS["info_api_version"].format(discord.__version__),
embed.set_author( # CONST.STRINGS["info_database_records"].format(total_rows),
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) 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"],
thumbnail_url=CONST.LUMI_LOGO_OPAQUE,
hide_name_in_description=True,
)
await ctx.send(embed=embed)
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Info(bot))

View file

@ -1,166 +1,191 @@
import asyncio
from typing import Dict, Optional
import discord import discord
from discord.ext import bridge from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from lib.interactions.introduction import ( from ui.embeds import Builder
from ui.views.introduction import (
IntroductionFinishButtons, IntroductionFinishButtons,
IntroductionStartButtons, IntroductionStartButtons,
) )
async def cmd(self, ctx: bridge.Context) -> None: class Introduction(commands.Cog):
guild: Optional[discord.Guild] = self.client.get_guild(CONST.KRC_GUILD_ID) def __init__(self, bot: commands.Bot):
member: Optional[discord.Member] = ( self.bot = bot
guild.get_member(ctx.author.id) if guild else None self.introduction.usage = lib.format.generate_usage(self.introduction)
)
if not guild or not member: @commands.hybrid_command(name="introduction", aliases=["intro"])
await ctx.respond( async def introduction(self, ctx: commands.Context[commands.Bot]) -> None:
embed=EmbedBuilder.create_error_embed( """
ctx, Introduction command.
author_text=CONST.STRINGS["intro_no_guild_author"],
description=CONST.STRINGS["intro_no_guild"], Parameters
footer_text=CONST.STRINGS["intro_service_name"], ----------
), ctx : commands.Context[commands.Bot]
The context of the command.
"""
guild: discord.Guild | None = self.bot.get_guild(
CONST.INTRODUCTIONS_GUILD_ID,
) )
return member: discord.Member | None = guild.get_member(ctx.author.id) if guild else None
question_mapping: Dict[str, str] = CONST.KRC_QUESTION_MAPPING if not guild or not member:
channel: Optional[discord.abc.GuildChannel] = guild.get_channel(
CONST.KRC_INTRO_CHANNEL_ID,
)
if not channel or isinstance(
channel,
(discord.ForumChannel, discord.CategoryChannel),
):
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_no_channel_author"],
description=CONST.STRINGS["intro_no_channel"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
return
view: IntroductionStartButtons | IntroductionFinishButtons = (
IntroductionStartButtons(ctx)
)
await ctx.respond(
embed=EmbedBuilder.create_embed(
ctx,
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:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_stopped_author"],
description=CONST.STRINGS["intro_stopped"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
return
if view.clickedStart:
def check(message: discord.Message) -> bool:
return message.author == ctx.author and isinstance(
message.channel,
discord.DMChannel,
)
answer_mapping: Dict[str, str] = {}
for key, question in question_mapping.items():
await ctx.send( await ctx.send(
embed=EmbedBuilder.create_embed( embed=Builder.create_embed(
ctx, theme="error",
author_text=key, user_name=ctx.author.name,
description=question, author_text=CONST.STRINGS["intro_no_guild_author"],
footer_text=CONST.STRINGS["intro_question_footer"], description=CONST.STRINGS["intro_no_guild"],
footer_text=CONST.STRINGS["intro_service_name"],
), ),
) )
return
try: question_mapping: dict[str, str] = CONST.INTRODUCTIONS_QUESTION_MAPPING
answer: discord.Message = await self.client.wait_for( channel: discord.abc.GuildChannel | None = guild.get_channel(
"message", CONST.INTRODUCTIONS_CHANNEL_ID,
check=check,
timeout=300,
)
answer_content: str = answer.content.replace("\n", " ")
if len(answer_content) > 200:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_too_long_author"],
description=CONST.STRINGS["intro_too_long"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
return
answer_mapping[key] = answer_content
except asyncio.TimeoutError:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_timeout_author"],
description=CONST.STRINGS["intro_timeout"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
return
description: str = "".join(
CONST.STRINGS["intro_preview_field"].format(key, value)
for key, value in answer_mapping.items()
) )
preview: discord.Embed = EmbedBuilder.create_embed( if not channel or isinstance(
ctx, channel,
author_text=ctx.author.name, discord.ForumChannel | discord.CategoryChannel,
author_icon_url=ctx.author.display_avatar.url, ):
description=description, await ctx.send(
footer_text=CONST.STRINGS["intro_content_footer"], embed=Builder.create_embed(
) theme="error",
view = IntroductionFinishButtons(ctx) 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"],
),
)
return
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 ctx.send(embed=preview, view=view)
await view.wait() await view.wait()
if view.clickedConfirm: if view.clicked_stop:
await channel.send(
embed=preview,
content=CONST.STRINGS["intro_content"].format(ctx.author.mention),
)
await ctx.send( await ctx.send(
embed=EmbedBuilder.create_embed( embed=Builder.create_embed(
ctx, theme="error",
description=CONST.STRINGS["intro_post_confirmation"].format( user_name=ctx.author.name,
channel.mention,
),
),
)
else:
await ctx.send(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_stopped_author"], author_text=CONST.STRINGS["intro_stopped_author"],
description=CONST.STRINGS["intro_stopped"], description=CONST.STRINGS["intro_stopped"],
footer_text=CONST.STRINGS["intro_service_name"], footer_text=CONST.STRINGS["intro_service_name"],
), ),
) )
return
if view.clicked_start:
def check(message: discord.Message) -> bool:
return message.author == ctx.author and isinstance(
message.channel,
discord.DMChannel,
)
answer_mapping: dict[str, str] = {}
for key, question in question_mapping.items():
await ctx.send(
embed=Builder.create_embed(
theme="info",
user_name=ctx.author.name,
author_text=key,
description=question,
footer_text=CONST.STRINGS["intro_question_footer"],
),
)
try:
answer: discord.Message = await self.bot.wait_for(
"message",
check=check,
timeout=300,
)
answer_content: str = answer.content.replace("\n", " ")
if len(answer_content) > 200:
await ctx.send(
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"],
),
)
return
answer_mapping[key] = answer_content
except TimeoutError:
await ctx.send(
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"],
),
)
return
description: str = "".join(
CONST.STRINGS["intro_preview_field"].format(key, value) for key, value in answer_mapping.items()
)
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,
footer_text=CONST.STRINGS["intro_content_footer"],
)
view = IntroductionFinishButtons(ctx)
await ctx.send(embed=preview, view=view)
await view.wait()
if view.clicked_confirm:
await channel.send(
embed=preview,
content=CONST.STRINGS["intro_content"].format(ctx.author.mention),
)
await ctx.send(
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,
),
),
)
else:
await ctx.send(
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 commands
from discord.ext import bridge
from discord.ui import Button, View
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.const import CONST
from ui.embeds import Builder
from ui.views.invite import InviteButton
async def cmd(ctx: bridge.BridgeContext) -> None: class Invite(commands.Cog):
await ctx.respond( def __init__(self, bot: commands.Bot):
embed=EmbedBuilder.create_success_embed( self.bot = bot
ctx, self.invite.usage = lib.format.generate_usage(self.invite)
description=CONST.STRINGS["invite_description"],
),
view=InviteButton(),
)
@commands.hybrid_command(name="invite", aliases=["inv"])
async def invite(self, ctx: commands.Context[commands.Bot]) -> None:
"""
Invite command.
class InviteButton(View): Parameters
def __init__(self) -> None: ----------
super().__init__(timeout=None) ctx : commands.Context[commands.Bot]
invite_button: Button = Button( The context of the command.
label=CONST.STRINGS["invite_button_text"], """
style=ButtonStyle.url, await ctx.send(
url=CONST.INVITE_LINK, embed=Builder.create_embed(
theme="success",
user_name=ctx.author.name,
author_text=CONST.STRINGS["invite_author"],
description=CONST.STRINGS["invite_description"],
),
view=InviteButton(),
) )
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 import lib.format
from lib.const import CONST
from lib.constants import CONST from ui.embeds import Builder
from lib.embed_builder import EmbedBuilder
async def ping(self, ctx: bridge.BridgeContext) -> None: class Ping(commands.Cog):
embed = EmbedBuilder.create_success_embed( def __init__(self, bot: commands.Bot):
ctx, self.bot = bot
author_text=CONST.STRINGS["ping_author"], self.ping.usage = lib.format.generate_usage(self.ping)
description=CONST.STRINGS["ping_pong"],
footer_text=CONST.STRINGS["ping_footer"].format( @commands.hybrid_command(name="ping")
round(1000 * self.client.latency), async def ping(self, ctx: commands.Context[commands.Bot]) -> None:
), """
) Ping command.
await ctx.respond(embed=embed)
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.bot.latency),
),
)
await ctx.send(embed=embed)
async def uptime(self, ctx: bridge.BridgeContext, start_time: datetime) -> None: async def setup(bot: commands.Bot) -> None:
unix_timestamp: int = int(round(self.start_time.timestamp())) await bot.add_cog(Ping(bot))
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)

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.const import CONST
from ui.embeds import Builder
from lib.constants import CONST from wrappers.xkcd import Client, HttpError
from lib.embed_builder import EmbedBuilder
from services.xkcd_service import Client, HttpError
_xkcd = Client() _xkcd = Client()
async def print_comic( async def print_comic(
ctx: bridge.Context, interaction: discord.Interaction,
latest: bool = False, latest: bool = False,
number: Optional[int] = None, number: int | None = None,
) -> None: ) -> None:
try: try:
if latest: if latest:
@ -22,9 +22,9 @@ async def print_comic(
else: else:
comic = _xkcd.get_random_comic(raw_comic_image=True) comic = _xkcd.get_random_comic(raw_comic_image=True)
await ctx.respond( await interaction.response.send_message(
embed=EmbedBuilder.create_success_embed( embed=Builder.create_embed(
ctx, theme="info",
author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title), author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title),
description=CONST.STRINGS["xkcd_description"].format( description=CONST.STRINGS["xkcd_description"].format(
comic.explanation_url, comic.explanation_url,
@ -32,16 +32,64 @@ async def print_comic(
), ),
footer_text=CONST.STRINGS["xkcd_footer"], footer_text=CONST.STRINGS["xkcd_footer"],
image_url=comic.image_url, image_url=comic.image_url,
show_name=False,
), ),
) )
except HttpError: except HttpError:
await ctx.respond( await interaction.response.send_message(
embed=EmbedBuilder.create_error_embed( embed=Builder.create_embed(
ctx, theme="error",
author_text=CONST.STRINGS["xkcd_not_found_author"], author_text=CONST.STRINGS["xkcd_not_found_author"],
description=CONST.STRINGS["xkcd_not_found"], description=CONST.STRINGS["xkcd_not_found"],
footer_text=CONST.STRINGS["xkcd_footer"], 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,114 +1,149 @@
import asyncio import asyncio
from typing import Optional import contextlib
from typing import cast
import discord import discord
from discord.ext.commands import MemberConverter from discord.ext import commands
from lib import formatter import lib.format
from lib.constants import CONST from lib.actionable import async_actionable
from lib.embed_builder import EmbedBuilder from lib.case_handler import create_case
from modules.moderation.utils.actionable import async_actionable from lib.const import CONST
from modules.moderation.utils.case_handler import create_case from ui.embeds import Builder
async def ban_user(cog, ctx, target: discord.User, reason: Optional[str] = None): class Ban(commands.Cog):
# see if user is in guild def __init__(self, bot: commands.Bot):
member = await MemberConverter().convert(ctx, str(target.id)) self.bot = bot
self.ban.usage = lib.format.generate_usage(self.ban)
self.unban.usage = lib.format.generate_usage(self.unban)
output_reason = reason or CONST.STRINGS["mod_no_reason"] @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.
# member -> user is in the guild, check role hierarchy Parameters
if member: ----------
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) target: discord.Member | discord.User
await async_actionable(member, ctx.author, bot_member) 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),
)
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)
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,
),
hide_name_in_description=True,
),
)
dm_sent = True
await ctx.guild.ban(target, reason=formatted_reason)
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.name),
)
if isinstance(target, discord.Member):
embed.set_footer(text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"])
await asyncio.gather(
ctx.send(embed=embed),
create_case(ctx, cast(discord.User, target), "BAN", reason),
return_exceptions=True,
)
@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
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try: try:
await member.send( await ctx.guild.unban(
embed=EmbedBuilder.create_warning_embed( target,
ctx, reason=CONST.STRINGS["mod_reason"].format(
author_text=CONST.STRINGS["mod_banned_author"], ctx.author.name,
description=CONST.STRINGS["mod_ban_dm"].format( lib.format.shorten(output_reason, 200),
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
), ),
) )
dm_sent = True
except (discord.HTTPException, discord.Forbidden): respond_task = ctx.send(
dm_sent = False 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.name),
),
)
create_case_task = create_case(ctx, target, "UNBAN", reason)
await asyncio.gather(respond_task, create_case_task)
await member.ban( except (discord.NotFound, discord.HTTPException):
reason=CONST.STRINGS["mod_reason"].format( await ctx.send(
ctx.author.name, embed=Builder.create_embed(
formatter.shorten(output_reason, 200), theme="error",
), user_name=ctx.author.name,
delete_message_seconds=86400, author_text=CONST.STRINGS["mod_not_banned_author"],
) description=CONST.STRINGS["mod_not_banned"].format(target.id),
),
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),
footer_text=CONST.STRINGS["mod_dm_sent"]
if dm_sent
else CONST.STRINGS["mod_dm_not_sent"],
),
)
create_case_task = create_case(ctx, target, "BAN", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
# 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),
),
)
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)
async def unban_user(ctx, target: discord.User, reason: Optional[str] = None): async def setup(bot: commands.Bot) -> None:
output_reason = reason or CONST.STRINGS["mod_no_reason"] await bot.add_cog(Ban(bot))
try:
await ctx.guild.unban(
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_unbanned_author"],
description=CONST.STRINGS["mod_unbanned"].format(target.id),
),
)
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,
author_text=CONST.STRINGS["mod_not_banned_author"],
description=CONST.STRINGS["mod_not_banned"].format(target.id),
),
)

View file

@ -1,115 +1,236 @@
import asyncio import asyncio
import discord import discord
from discord.ext import pages from discord.ext import commands
from discord.ext.commands import UserConverter from reactionmenu import ViewButton, ViewMenu
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.case_handler import edit_case_modlog
from lib.formatter import format_case_number from lib.const import CONST
from modules.moderation.utils.case_embed import ( 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_embed,
create_case_list_embed, create_case_list_embed,
) )
from modules.moderation.utils.case_handler import edit_case_modlog from ui.embeds import Builder
from services.moderation.case_service import CaseService
case_service = CaseService() 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:
case = case_service.fetch_case_by_guild_and_number(guild_id, case_number) return Builder.create_embed(
theme="info",
if not case: user_name=ctx.author.name,
embed = EmbedBuilder.create_error_embed( author_text=author_text,
ctx, description=description,
author_text=CONST.STRINGS["error_no_case_found_author"],
description=CONST.STRINGS["error_no_case_found_description"],
)
return await ctx.respond(embed=embed)
target = await UserConverter().convert(ctx, str(case["target_id"]))
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case["case_number"],
action_type=case["action_type"],
reason=case["reason"],
timestamp=case["created_at"],
duration=case["duration"] or None,
)
await ctx.respond(embed=embed)
async def view_all_cases_in_guild(ctx, guild_id: int):
cases = case_service.fetch_cases_by_guild(guild_id)
if not cases:
embed = EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["case_guild_no_cases_author"],
description=CONST.STRINGS["case_guild_no_cases"],
)
return await ctx.respond(embed=embed)
pages_list = []
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
ctx,
chunk,
CONST.STRINGS["case_guild_cases_author"],
)
pages_list.append(embed)
paginator = pages.Paginator(pages=pages_list)
await paginator.respond(ctx)
async def view_all_cases_by_mod(ctx, guild_id: int, moderator: discord.Member):
cases = case_service.fetch_cases_by_moderator(guild_id, moderator.id)
if not cases:
embed = EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["case_mod_no_cases_author"],
description=CONST.STRINGS["case_mod_no_cases"],
)
return await ctx.respond(embed=embed)
pages_list = []
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
ctx,
chunk,
CONST.STRINGS["case_mod_cases_author"].format(moderator.name),
)
pages_list.append(embed)
paginator = pages.Paginator(pages=pages_list)
await paginator.respond(ctx)
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,
author_text=CONST.STRINGS["case_reason_update_author"],
description=CONST.STRINGS["case_reason_update_description"].format(
format_case_number(case_number),
),
)
async def update_tasks(): def create_case_view_menu(ctx: commands.Context[commands.Bot]) -> ViewMenu:
await asyncio.gather( menu = ViewMenu(ctx, menu_type=ViewMenu.TypeEmbed, all_can_click=True, delete_on_timeout=True)
ctx.respond(embed=embed),
edit_case_modlog(ctx, guild_id, case_number, new_reason), 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 = create_no_cases_embed(
ctx,
CONST.STRINGS["error_no_case_found_author"],
CONST.STRINGS["error_no_case_found_description"],
)
await ctx.send(embed=embed)
return
target = await commands.UserConverter().convert(ctx, str(case["target_id"]))
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case["case_number"],
action_type=case["action_type"],
reason=case["reason"],
timestamp=case["created_at"],
duration=case["duration"] or None,
)
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.
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 = create_no_cases_embed(
ctx,
CONST.STRINGS["case_guild_no_cases_author"],
CONST.STRINGS["case_guild_no_cases"],
)
await ctx.send(embed=embed)
return
menu = create_case_view_menu(ctx)
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
ctx,
chunk,
CONST.STRINGS["case_guild_cases_author"],
)
menu.add_page(embed)
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.
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 = create_no_cases_embed(
ctx,
CONST.STRINGS["case_mod_no_cases_author"],
CONST.STRINGS["case_mod_no_cases"],
)
await ctx.send(embed=embed)
return
menu = create_case_view_menu(ctx)
for i in range(0, len(cases), 10):
chunk = cases[i : i + 10]
embed = create_case_list_embed(
ctx,
chunk,
CONST.STRINGS["case_mod_cases_author"].format(moderator.name),
)
menu.add_page(embed)
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
case_service.edit_case_reason(
guild_id,
case_number,
new_reason,
) )
await update_tasks() 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),
),
)
async def update_tasks():
await asyncio.gather(
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,58 +1,90 @@
import asyncio import asyncio
from typing import Optional from typing import cast
import discord import discord
from discord.ext.commands import UserConverter, MemberConverter from discord.ext import commands
from lib import formatter import lib.format
from lib.constants import CONST from lib.actionable import async_actionable
from lib.embed_builder import EmbedBuilder from lib.case_handler import create_case
from modules.moderation.utils.actionable import async_actionable from lib.const import CONST
from modules.moderation.utils.case_handler import create_case from ui.embeds import Builder
async def kick_user(cog, ctx, target: discord.Member, reason: Optional[str] = None): class Kick(commands.Cog):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) def __init__(self, bot: commands.Bot):
await async_actionable(target, ctx.author, bot_member) self.bot = bot
self.kick.usage = lib.format.generate_usage(self.kick)
output_reason = reason or CONST.STRINGS["mod_no_reason"] @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.
try: Parameters
await target.send( ----------
embed=EmbedBuilder.create_warning_embed( target: discord.Member
ctx, The user to kick.
author_text=CONST.STRINGS["mod_kicked_author"], reason: str | None
description=CONST.STRINGS["mod_kick_dm"].format( The reason for the kick. Defaults to None.
target.name, """
ctx.guild.name, assert ctx.guild
output_reason, 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=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,
),
hide_name_in_description=True,
), ),
show_name=False, )
dm_sent = True
except (discord.HTTPException, discord.Forbidden):
dm_sent = False
await target.kick(
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
lib.format.shorten(output_reason, 200),
), ),
) )
dm_sent = True
except (discord.HTTPException, discord.Forbidden): respond_task = ctx.send(
dm_sent = False 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"],
),
)
await target.kick( create_case_task = create_case(ctx, cast(discord.User, target), "KICK", reason)
reason=CONST.STRINGS["mod_reason"].format( await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
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"],
),
)
target_user = await UserConverter().convert(ctx, str(target.id)) async def setup(bot: commands.Bot) -> None:
create_case_task = create_case(ctx, target_user, "KICK", reason) await bot.add_cog(Kick(bot))
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)

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,66 +1,98 @@
import asyncio import asyncio
from typing import Optional from typing import cast
import discord import discord
from discord.ext.commands import MemberConverter, UserConverter from discord.ext import commands
from lib import formatter import lib.format
from lib.constants import CONST from lib.actionable import async_actionable
from lib.embed_builder import EmbedBuilder from lib.case_handler import create_case
from modules.moderation.utils.actionable import async_actionable from lib.const import CONST
from modules.moderation.utils.case_handler import create_case from ui.embeds import Builder
async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None): class Softban(commands.Cog):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) def __init__(self, bot: commands.Bot):
await async_actionable(target, ctx.author, bot_member) self.bot = bot
self.softban.usage = lib.format.generate_usage(self.softban)
output_reason = reason or CONST.STRINGS["mod_no_reason"] @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.
try: Parameters
await target.send( ----------
embed=EmbedBuilder.create_warning_embed( target: discord.Member
ctx, The user to softban.
author_text=CONST.STRINGS["mod_softbanned_author"], reason: str | None
description=CONST.STRINGS["mod_softban_dm"].format( The reason for the softban. Defaults to None.
target.name, """
ctx.guild.name, assert ctx.guild
output_reason, 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=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,
),
hide_name_in_description=True,
), ),
show_name=False, )
dm_sent = True
except (discord.HTTPException, discord.Forbidden):
dm_sent = False
await ctx.guild.ban(
target,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
lib.format.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)
await ctx.guild.unban(
target,
reason=CONST.STRINGS["mod_softban_unban_reason"].format(
ctx.author.name,
), ),
) )
dm_sent = True
except (discord.HTTPException, discord.Forbidden):
dm_sent = False
await ctx.guild.ban( respond_task = ctx.send(
target, embed=Builder.create_embed(
reason=CONST.STRINGS["mod_reason"].format( theme="success",
ctx.author.name, user_name=target.name,
formatter.shorten(output_reason, 200), author_text=CONST.STRINGS["mod_softbanned_author"],
), description=CONST.STRINGS["mod_softbanned_user"].format(target.name),
delete_message_seconds=86400, footer_text=CONST.STRINGS["mod_dm_sent"] if dm_sent else CONST.STRINGS["mod_dm_not_sent"],
) ),
)
await ctx.guild.unban( create_case_task = create_case(ctx, cast(discord.User, target), "SOFTBAN", reason)
target, await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
reason=CONST.STRINGS["mod_softban_unban_reason"].format(
ctx.author.name,
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
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"],
),
)
target_user = await UserConverter().convert(ctx, str(target.id)) async def setup(bot: commands.Bot) -> None:
create_case_task = create_case(ctx, target_user, "SOFTBAN", reason) await bot.add_cog(Softban(bot))
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)

View file

@ -1,103 +1,163 @@
import asyncio import asyncio
import datetime import datetime
from typing import Optional from typing import cast
import discord import discord
from discord.ext.commands import UserConverter, MemberConverter from discord.ext import commands
from lib import formatter import lib.format
from lib.constants import CONST from lib.actionable import async_actionable
from lib.embed_builder import EmbedBuilder from lib.case_handler import create_case
from lib.formatter import format_duration_to_seconds, format_seconds_to_duration_string from lib.const import CONST
from modules.moderation.utils.actionable import async_actionable from lib.exceptions import LumiException
from modules.moderation.utils.case_handler import create_case from ui.embeds import Builder
async def timeout_user( class Timeout(commands.Cog):
cog, def __init__(self, bot: commands.Bot):
ctx, self.bot = bot
target: discord.Member, self.timeout.usage = lib.format.generate_usage(self.timeout)
duration: str, self.untimeout.usage = lib.format.generate_usage(self.untimeout)
reason: Optional[str] = None,
):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"] @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: str | None = None,
) -> None:
"""
Timeout a user in the guild.
# Parse duration to minutes and validate Parameters
duration_int = format_duration_to_seconds(duration) ----------
duration_str = format_seconds_to_duration_string(duration_int) 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
await target.timeout_for( # Parse duration to minutes and validate
duration=datetime.timedelta(seconds=duration_int), duration_int = lib.format.format_duration_to_seconds(duration)
reason=CONST.STRINGS["mod_reason"].format( duration_str = lib.format.format_seconds_to_duration_string(duration_int)
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
dm_task = target.send( # if longer than 27 days, return LumiException
embed=EmbedBuilder.create_warning_embed( if duration_int > 2332800:
ctx, raise LumiException(CONST.STRINGS["mod_timeout_too_long"])
author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timeout_dm"].format(
target.name,
ctx.guild.name,
duration_str,
output_reason,
),
show_name=False,
),
)
respond_task = ctx.respond( bot_member = await commands.MemberConverter().convert(ctx, str(ctx.bot.user.id))
embed=EmbedBuilder.create_success_embed( await async_actionable(target, cast(discord.Member, ctx.author), bot_member)
ctx,
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)) output_reason = reason or CONST.STRINGS["mod_no_reason"]
create_case_task = create_case(ctx, target_user, "TIMEOUT", reason, duration_int)
await asyncio.gather( await target.timeout(
dm_task, datetime.timedelta(seconds=duration_int),
respond_task,
create_case_task,
return_exceptions=True,
)
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(
reason=CONST.STRINGS["mod_reason"].format( reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name, ctx.author.name,
formatter.shorten(output_reason, 200), lib.format.shorten(output_reason, 200),
), ),
) )
respond_task = ctx.respond( dm_task = target.send(
embed=EmbedBuilder.create_success_embed( embed=Builder.create_embed(
ctx, theme="warning",
author_text=CONST.STRINGS["mod_untimed_out_author"], user_name=target.name,
description=CONST.STRINGS["mod_untimed_out"].format(target.name), author_text=CONST.STRINGS["mod_timed_out_author"],
description=CONST.STRINGS["mod_timeout_dm"].format(
target.name,
ctx.guild.name,
duration_str,
output_reason,
),
hide_name_in_description=True,
), ),
) )
target_user = await UserConverter().convert(ctx, str(target.id)) respond_task = ctx.send(
create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason) embed=Builder.create_embed(
await asyncio.gather(respond_task, create_case_task, return_exceptions=True) theme="success",
user_name=target.name,
except discord.HTTPException: author_text=CONST.STRINGS["mod_timed_out_author"],
return await ctx.respond( description=CONST.STRINGS["mod_timed_out_user"].format(target.name),
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_not_timed_out_author"],
description=CONST.STRINGS["mod_not_timed_out"].format(target.name),
), ),
) )
create_case_task = create_case(ctx, cast(discord.User, target), "TIMEOUT", reason, duration_int)
await asyncio.gather(
dm_task,
respond_task,
create_case_task,
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
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.timeout(
None,
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
lib.format.shorten(output_reason, 200),
),
)
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 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:
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,48 +1,82 @@
import asyncio import asyncio
from typing import Optional from typing import cast
import discord import discord
from discord.ext.commands import UserConverter, MemberConverter from discord.ext import commands
from lib.constants import CONST import lib.format
from lib.embed_builder import EmbedBuilder from lib.actionable import async_actionable
from modules.moderation.utils.actionable import async_actionable from lib.case_handler import create_case
from modules.moderation.utils.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]): class Warn(commands.Cog):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id)) def __init__(self, bot: commands.Bot):
await async_actionable(target, ctx.author, bot_member) self.bot = bot
self.warn.usage = lib.format.generate_usage(self.warn)
output_reason = reason or CONST.STRINGS["mod_no_reason"] @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.
dm_task = target.send( Parameters
embed=EmbedBuilder.create_warning_embed( ----------
ctx, target: discord.Member
author_text=CONST.STRINGS["mod_warned_author"], The user to warn.
description=CONST.STRINGS["mod_warn_dm"].format( reason: str | None
target.name, The reason for the warn. Defaults to None.
ctx.guild.name, """
output_reason, 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=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,
),
hide_name_in_description=True,
), ),
show_name=False, )
),
)
respond_task = ctx.respond( respond_task = ctx.send(
embed=EmbedBuilder.create_success_embed( embed=Builder.create_embed(
ctx, theme="success",
author_text=CONST.STRINGS["mod_warned_author"], user_name=ctx.author.name,
description=CONST.STRINGS["mod_warned_user"].format(target.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, cast(discord.User, target), "WARN", reason)
create_case_task = create_case(ctx, target_user, "WARN", reason)
await asyncio.gather( await asyncio.gather(
dm_task, dm_task,
respond_task, respond_task,
create_case_task, create_case_task,
return_exceptions=True, 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. # 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]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
version = "2.4.0" version = "2.4.0"
@ -136,6 +174,17 @@ files = [
[package.dependencies] [package.dependencies]
frozenlist = ">=1.1.0" 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]] [[package]]
name = "anyio" name = "anyio"
version = "4.4.0" version = "4.4.0"
@ -177,13 +226,13 @@ tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2024.7.4" version = "2024.8.30"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
] ]
[[package]] [[package]]
@ -307,6 +356,26 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {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]] [[package]]
name = "distlib" name = "distlib"
version = "0.3.8" version = "0.3.8"
@ -471,13 +540,13 @@ trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.27.0" version = "0.27.2"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"},
] ]
[package.dependencies] [package.dependencies]
@ -492,6 +561,7 @@ brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "identify" name = "identify"
@ -762,33 +832,137 @@ files = [
test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
[[package]] [[package]]
name = "py-cord" name = "pydantic"
version = "2.6.0" version = "2.8.2"
description = "A Python wrapper for the Discord API" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "py_cord-2.6.0-py3-none-any.whl", hash = "sha256:906cc077904a4d478af0264ae4374b0412ed9f9ab950d0162c4f31239a906d26"}, {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"},
{file = "py_cord-2.6.0.tar.gz", hash = "sha256:bbc0349542965d05e4b18cc4424136206430a8cc911fda12a0a57df6fdf9cd9c"}, {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"},
] ]
[package.dependencies] [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] [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)"] email = ["email-validator (>=2.0.0)"]
speed = ["aiohttp[speedups]", "msgspec (>=0.18.6,<0.19.0)"]
voice = ["PyNaCl (>=1.3.0,<1.6)"] [[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]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.377" version = "1.1.378"
description = "Command line wrapper for pyright" description = "Command line wrapper for pyright"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162"}, {file = "pyright-1.1.378-py3-none-any.whl", hash = "sha256:8853776138b01bc284da07ac481235be7cc89d3176b073d2dba73636cb95be79"},
{file = "pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf"}, {file = "pyright-1.1.378.tar.gz", hash = "sha256:78a043be2876d12d0af101d667e92c7734f3ebb9db71dccc2c220e7e7eb89ca2"},
] ]
[package.dependencies] [package.dependencies]
@ -798,20 +972,6 @@ nodeenv = ">=1.6.0"
all = ["twine (>=3.4.1)"] all = ["twine (>=3.4.1)"]
dev = ["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]] [[package]]
name = "pytimeparse" name = "pytimeparse"
version = "1.1.8" version = "1.1.8"
@ -823,17 +983,6 @@ files = [
{file = "pytimeparse-1.1.8.tar.gz", hash = "sha256:e86136477be924d7e670646a98561957e8ca7308d44841e21f5ddea757556a0a"}, {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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.2" version = "6.0.2"
@ -896,6 +1045,20 @@ files = [
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, {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]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" version = "2.32.3"
@ -919,29 +1082,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.5.7" version = "0.6.3"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a"}, {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"},
{file = "ruff-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:00cc8872331055ee017c4f1071a8a31ca0809ccc0657da1d154a1d2abac5c0be"}, {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"},
{file = "ruff-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf3d86a1fdac1aec8a3417a63587d93f906c678bb9ed0b796da7b59c1114a1e"}, {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a01c34400097b06cf8a6e61b35d6d456d5bd1ae6961542de18ec81eaf33b4cb8"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcc8054f1a717e2213500edaddcf1dbb0abad40d98e1bd9d0ad364f75c763eea"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f70284e73f36558ef51602254451e50dd6cc479f8b6f8413a95fcb5db4a55fc"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a78ad870ae3c460394fc95437d43deb5c04b5c29297815a2a1de028903f19692"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ccd078c66a8e419475174bfe60a69adb36ce04f8d4e91b006f1329d5cd44bcf"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e31c9bad4ebf8fdb77b59cae75814440731060a09a0e0077d559a556453acbb"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"},
{file = "ruff-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d796327eed8e168164346b769dd9a27a70e0298d667b4ecee6877ce8095ec8e"}, {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a09ea2c3f7778cc635e7f6edf57d566a8ee8f485f3c4454db7771efb692c499"}, {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a36d8dcf55b3a3bc353270d544fb170d75d2dff41eba5df57b4e0b67a95bb64e"}, {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9369c218f789eefbd1b8d82a8cf25017b523ac47d96b2f531eba73770971c9e5"}, {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"},
{file = "ruff-0.5.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b88ca3db7eb377eb24fb7c82840546fb7acef75af4a74bd36e9ceb37a890257e"}, {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"},
{file = "ruff-0.5.7-py3-none-win32.whl", hash = "sha256:33d61fc0e902198a3e55719f4be6b375b28f860b09c281e4bdbf783c0566576a"}, {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"},
{file = "ruff-0.5.7-py3-none-win_amd64.whl", hash = "sha256:083bbcbe6fadb93cd86709037acc510f86eed5a314203079df174c40bbbca6b3"}, {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"},
{file = "ruff-0.5.7-py3-none-win_arm64.whl", hash = "sha256:2dca26154ff9571995107221d0aeaad0e75a77b5a682d6236cf89a58c70b76f4"}, {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"},
{file = "ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5"}, {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"},
] ]
[[package]] [[package]]
@ -982,6 +1145,17 @@ files = [
ply = ">=3.4" ply = ">=3.4"
six = ">=1.12.0" 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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.2.2" version = "2.2.2"
@ -1035,101 +1209,103 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.9.4" version = "1.9.7"
description = "Yet another URL library" description = "Yet another URL library"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"}, {file = "yarl-1.9.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:60c04415b31a1611ef5989a6084dd6f6b95652c6a18378b58985667b65b2ecb6"},
{file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"}, {file = "yarl-1.9.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1787dcfdbe730207acb454548a6e19f80ae75e6d2d1f531c5a777bc1ab6f7952"},
{file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"}, {file = "yarl-1.9.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f5ddad20363f9f1bbedc95789c897da62f939e6bc855793c3060ef8b9f9407bf"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"}, {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdb156a06208fc9645ae7cc0fca45c40dd40d7a8c4db626e542525489ca81a9"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"}, {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522fa3d300d898402ae4e0fa7c2c21311248ca43827dc362a667de87fdb4f1be"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"}, {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7f9cabfb8b980791b97a3ae3eab2e38b2ba5eab1af9b7495bdc44e1ce7c89e3"},
{file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"}, {file = "yarl-1.9.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fc728857df4087da6544fc68f62d7017fa68d74201d5b878e18ed4822c31fb3"},
{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.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dba2ebac677184d56374fa3e452b461f5d6a03aa132745e648ae8859361eb6b"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"}, {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a95167ae34667c5cc7d9206c024f793e8ffbadfb307d5c059de470345de58a21"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"}, {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9d319ac113ca47352319cbea92d1925a37cb7bd61a8c2f3e3cd2e96eb33cccae"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"}, {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:2d71a5d818d82586ac46265ae01466e0bda0638760f18b21f1174e0dd58a9d2f"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"}, {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ff03f1c1ac474c66d474929ae7e4dd195592c1c7cc8c36418528ed81b1ca0a79"},
{file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"}, {file = "yarl-1.9.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78250f635f221dde97d02c57aade3313310469bc291888dfe32acd1012594441"},
{file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"}, {file = "yarl-1.9.7-cp310-cp310-win32.whl", hash = "sha256:f3aaf9fa960d55bd7876d55d7ea3cc046f3660df1ff73fc1b8c520a741ed1f21"},
{file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"}, {file = "yarl-1.9.7-cp310-cp310-win_amd64.whl", hash = "sha256:e8362c941e07fbcde851597672a5e41b21dc292b7d5a1dc439b7a93c9a1af5d9"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"}, {file = "yarl-1.9.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:596069ddeaf72b5eb36cd714dcd2b5751d0090d05a8d65113b582ed9e1c801fb"},
{file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"}, {file = "yarl-1.9.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cb870907e8b86b2f32541403da9455afc1e535ce483e579bea0e6e79a0cc751c"},
{file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"}, {file = "yarl-1.9.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ca5e86be84492fa403c4dcd4dcaf8e1b1c4ffc747b5176f7c3d09878c45719b0"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"}, {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a99cecfb51c84d00132db909e83ae388793ca86e48df7ae57f1be0beab0dcce5"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"}, {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25508739e9b44d251172145f54c084b71747b09e4d237dc2abb045f46c36a66e"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"}, {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60f3b5aec3146b6992640592856414870f5b20eb688c1f1d5f7ac010a7f86561"},
{file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"}, {file = "yarl-1.9.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1557456afce5db3d655b5f8a31cdcaae1f47e57958760525c44b76e812b4987"},
{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.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71bb1435a84688ed831220c5305d96161beb65cac4a966374475348aa3de4575"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"}, {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f87d8645a7a806ec8f66aac5e3b1dcb5014849ff53ffe2a1f0b86ca813f534c7"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"}, {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:58e3f01673873b8573da3abe138debc63e4e68541b2104a55df4c10c129513a4"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"}, {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8af0bbd4d84f8abdd9b11be9488e32c76b1501889b73c9e2292a15fb925b378b"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"}, {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7fc441408ed0d9c6d2d627a02e281c21f5de43eb5209c16636a17fc704f7d0f8"},
{file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"}, {file = "yarl-1.9.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a9552367dc440870556da47bb289a806f08ad06fbc4054072d193d9e5dd619ba"},
{file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"}, {file = "yarl-1.9.7-cp311-cp311-win32.whl", hash = "sha256:628619008680a11d07243391271b46f07f13b75deb9fe92ef342305058c70722"},
{file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"}, {file = "yarl-1.9.7-cp311-cp311-win_amd64.whl", hash = "sha256:bc23d870864971c8455cfba17498ccefa53a5719ea9f5fce5e7e9c1606b5755f"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"}, {file = "yarl-1.9.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d8cf3d0b67996edc11957aece3fbce4c224d0451c7c3d6154ec3a35d0e55f6b"},
{file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"}, {file = "yarl-1.9.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a7748cd66fef49c877e59503e0cc76179caf1158d1080228e67e1db14554f08"},
{file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"}, {file = "yarl-1.9.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a6fa3aeca8efabb0fbbb3b15e0956b0cb77f7d9db67c107503c30af07cd9e00"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"}, {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf37dd0008e5ac5c3880198976063c491b6a15b288d150d12833248cf2003acb"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"}, {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87aa5308482f248f8c3bd9311cd6c7dfd98ea1a8e57e35fb11e4adcac3066003"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"}, {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:867b13c1b361f9ba5d2f84dc5408082f5d744c83f66de45edc2b96793a9c5e48"},
{file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"}, {file = "yarl-1.9.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ce93947554c2c85fe97fc4866646ec90840bc1162e4db349b37d692a811755"},
{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.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcd3d94b848cba132f39a5b40d80b0847d001a91a6f35a2204505cdd46afe1b2"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"}, {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d06d6a8f98dd87646d98f0c468be14b201e47ec6092ad569adf835810ad0dffb"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"}, {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:91567ff4fce73d2e7ac67ed5983ad26ba2343bc28cb22e1e1184a9677df98d7c"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"}, {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d5594512541e63188fea640b7f066c218d2176203d6e6f82abf702ae3dca3b2"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"}, {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c2743e43183e4afbb07d5605693299b8756baff0b086c25236c761feb0e3c56"},
{file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"}, {file = "yarl-1.9.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:daa69a3a2204355af39f4cfe7f3870d87c53d77a597b5100b97e3faa9460428b"},
{file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"}, {file = "yarl-1.9.7-cp312-cp312-win32.whl", hash = "sha256:36b16884336c15adf79a4bf1d592e0c1ffdb036a760e36a1361565b66785ec6c"},
{file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"}, {file = "yarl-1.9.7-cp312-cp312-win_amd64.whl", hash = "sha256:2ead2f87a1174963cc406d18ac93d731fbb190633d3995fa052d10cefae69ed8"},
{file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"}, {file = "yarl-1.9.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:808eddabcb6f7b2cdb6929b3e021ac824a2c07dc7bc83f7618e18438b1b65781"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"}, {file = "yarl-1.9.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:395ab0d8ce6d104a988da429bcbfd445e03fb4c911148dfd523f69d13f772e47"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"}, {file = "yarl-1.9.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49827dfccbd59c4499605c13805e947349295466e490860a855b7c7e82ec9c75"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"}, {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b8bbdd425d0978311520ea99fb6c0e9e04e64aee84fac05f3157ace9f81b05"},
{file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"}, {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71d33fd1c219b5b28ee98cd76da0c9398a4ed4792fd75c94135237db05ba5ca8"},
{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.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62440431741d0b7d410e5cbad800885e3289048140a43390ecab4f0b96dde3bb"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"}, {file = "yarl-1.9.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db97210433366dfba55590e48285b89ad0146c52bf248dd0da492dd9f0f72cf"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"}, {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.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"}, {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df47612129e66f7ce7c9994d4cd4e6852f6e3bf97699375d86991481796eeec8"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"}, {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5e338b6febbae6c9fe86924bac3ea9c1944e33255c249543cd82a4af6df6047b"},
{file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"}, {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e649d37d04665dddb90994bbf0034331b6c14144cc6f3fbce400dc5f28dc05b7"},
{file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"}, {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0a1b8fd849567be56342e988e72c9d28bd3c77b9296c38b9b42d2fe4813c9d3f"},
{file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"}, {file = "yarl-1.9.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9d715b2175dff9a49c6dafdc2ab3f04850ba2f3d4a77f69a5a1786b057a9d45"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"}, {file = "yarl-1.9.7-cp313-cp313-win32.whl", hash = "sha256:bc9233638b07c2e4a3a14bef70f53983389bffa9e8cb90a2da3f67ac9c5e1842"},
{file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"}, {file = "yarl-1.9.7-cp313-cp313-win_amd64.whl", hash = "sha256:62e110772330d7116f91e79cd83fef92545cb2f36414c95881477aa01971f75f"},
{file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"}, {file = "yarl-1.9.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a564155cc2194ecd9c0d8f8dc57059b822a507de5f08120063675eb9540576aa"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"}, {file = "yarl-1.9.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:03e917cc44a01e1be60a83ee1a17550b929490aaa5df2a109adc02137bddf06b"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"}, {file = "yarl-1.9.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eefda67ba0ba44ab781e34843c266a76f718772b348f7c5d798d8ea55b95517f"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"}, {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:316c82b499b6df41444db5dea26ee23ece9356e38cea43a8b2af9e6d8a3558e4"},
{file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"}, {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10452727843bc847596b75e30a7fe92d91829f60747301d1bd60363366776b0b"},
{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.7-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:050f3e4d886be55728fef268587d061c5ce6f79a82baba71840801b63441c301"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"}, {file = "yarl-1.9.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0aabe557446aa615693a82b4d3803c102fd0e7a6a503bf93d744d182a510184"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"}, {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.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"}, {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:34736fcc9d6d7080ebbeb0998ecb91e4f14ad8f18648cf0b3099e2420a225d86"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"}, {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:48f7a158f3ca67509d21cb02a96964e4798b6f133691cc0c86cf36e26e26ec8f"},
{file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"}, {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:6639444d161c693cdabb073baaed1945c717d3982ecedf23a219bc55a242e728"},
{file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"}, {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1cd450e10cb53d63962757c3f6f7870be49a3e448c46621d6bd46f8088d532de"},
{file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"}, {file = "yarl-1.9.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74d3ef5e81f81507cea04bf5ae22f18ef538607a7c754aac2b6e3029956a2842"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"}, {file = "yarl-1.9.7-cp38-cp38-win32.whl", hash = "sha256:4052dbd0c900bece330e3071c636f99dff06e4628461a29b38c6e222a427cf98"},
{file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"}, {file = "yarl-1.9.7-cp38-cp38-win_amd64.whl", hash = "sha256:dd08da4f2d171e19bd02083c921f1bef89f8f5f87000d0ffc49aa257bc5a9802"},
{file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"}, {file = "yarl-1.9.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ab906a956d2109c6ea11e24c66592b06336e2743509290117f0f7f47d2c1dd3"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"}, {file = "yarl-1.9.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d8ad761493d5aaa7ab2a09736e62b8a220cb0b10ff8ccf6968c861cd8718b915"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"}, {file = "yarl-1.9.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d35f9cdab0ec5e20cf6d2bd46456cf599052cf49a1698ef06b9592238d1cf1b1"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"}, {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a48d2b9f0ae29a456fb766ae461691378ecc6cf159dd9f938507d925607591c3"},
{file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"}, {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf85599c9336b89b92c313519bcaa223d92fa5d98feb4935a47cce2e8722b4b8"},
{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.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e8916b1ff7680b1f2b1608c82dc15c569b9f2cb2da100c747c291f1acf18a14"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"}, {file = "yarl-1.9.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29c80890e0a64fb0e5f71350d48da330995073881f8b8e623154aef631febfb0"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"}, {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.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"}, {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:65e3098969baf221bb45e3b2f60735fc2b154fc95902131ebc604bae4c629ea6"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"}, {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cddebd096effe4be90fd378e4224cd575ac99e1c521598a6900e94959006e02e"},
{file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"}, {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8525f955a2dcc281573b6aadeb8ab9c37e2d3428b64ca6a2feec2a794a69c1da"},
{file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"}, {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:5d585c7d834c13f24c7e3e0efaf1a4b7678866940802e11bd6c4d1f99c935e6b"},
{file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"}, {file = "yarl-1.9.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78805148e780a9ca66f3123e04741e344b66cf06b4fb13223e3a209f39a6da55"},
{file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"}, {file = "yarl-1.9.7-cp39-cp39-win32.whl", hash = "sha256:3f53df493ec80b76969d6e1ae6e4411a55ab1360e02b80c84bd4b33d61a567ba"},
{file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"}, {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] [package.dependencies]
@ -1139,4 +1315,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "541857707095fb0b5c439aedbfacd91ca3582f110f12d786dc29e7c70f989b3e" content-hash = "70d489a46ab888e4ed82b7447d5a02cde51e9062b735715d98cc3e4f089aadb6"

View file

@ -1,27 +1,156 @@
[tool.poetry] [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." description = "A Discord application, can serve as a template for your own bot."
license = "GNU General Public License v3.0" license = "GNU General Public License v3.0"
name = "lumi" name = "luminara"
package-mode = false package-mode = false
readme = "README.md" readme = "README.md"
version = "0.1.0" version = "3"
[tool.poetry.dependencies] [tool.poetry.dependencies]
aiocache = "^0.12.2"
aioconsole = "^0.7.1"
aiofiles = "^24.1.0"
discord-py = "^2.4.0"
dropbox = "^12.0.2" dropbox = "^12.0.2"
httpx = "^0.27.0" httpx = "^0.27.2"
loguru = "^0.7.2" loguru = "^0.7.2"
mysql-connector-python = "^9.0.0" mysql-connector-python = "^9.0.0"
pre-commit = "^3.7.1" pre-commit = "^3.8.0"
psutil = "^6.0.0" psutil = "^6.0.0"
py-cord = "^2.5.0" pydantic = "^2.8.2"
pyright = "^1.1.371" pyright = "^1.1.377"
python = "^3.12" python = "^3.12"
python-dotenv = "^1.0.1"
pytimeparse = "^1.1.8" pytimeparse = "^1.1.8"
pytz = "^2024.1" pyyaml = "^6.0.2"
ruff = "^0.5.2" reactionmenu = "^3.1.7"
ruff = "^0.6.2"
typing-extensions = "^4.12.2"
[build-system] [build-system]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"] 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 datetime
from zoneinfo import ZoneInfo
import pytz
from db import database from db import database
class Birthday: class BirthdayService:
def __init__(self, user_id, guild_id): def __init__(self, user_id: int, guild_id: int) -> None:
self.user_id = user_id self.user_id: int = user_id
self.guild_id = guild_id self.guild_id: int = guild_id
def set(self, birthday): def set(self, birthday: datetime.date) -> None:
query = """ query: str = """
INSERT INTO birthdays (user_id, guild_id, birthday) INSERT INTO birthdays (user_id, guild_id, birthday)
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE birthday = VALUES(birthday); ON DUPLICATE KEY UPDATE birthday = VALUES(birthday);
@ -19,8 +18,8 @@ class Birthday:
database.execute_query(query, (self.user_id, self.guild_id, birthday)) database.execute_query(query, (self.user_id, self.guild_id, birthday))
def delete(self): def delete(self) -> None:
query = """ query: str = """
DELETE FROM birthdays DELETE FROM birthdays
WHERE user_id = %s AND guild_id = %s; WHERE user_id = %s AND guild_id = %s;
""" """
@ -28,27 +27,26 @@ class Birthday:
database.execute_query(query, (self.user_id, self.guild_id)) database.execute_query(query, (self.user_id, self.guild_id))
@staticmethod @staticmethod
def get_birthdays_today(): def get_birthdays_today() -> list[tuple[int, int]]:
query = """ query: str = """
SELECT user_id, guild_id SELECT user_id, guild_id
FROM birthdays FROM birthdays
WHERE DATE_FORMAT(birthday, '%m-%d') = %s WHERE DATE_FORMAT(birthday, '%m-%d') = %s
""" """
tz = pytz.timezone("US/Eastern") today: str = datetime.datetime.now(ZoneInfo("US/Eastern")).strftime("%m-%d")
today = datetime.datetime.now(tz).strftime("%m-%d")
return database.select_query(query, (today,)) return database.select_query(query, (today,))
@staticmethod @staticmethod
def get_upcoming_birthdays(guild_id): def get_upcoming_birthdays(guild_id: int) -> list[tuple[int, str]]:
query = """ query: str = """
SELECT user_id, DATE_FORMAT(birthday, '%m-%d') AS upcoming_birthday SELECT user_id, DATE_FORMAT(birthday, '%m-%d') AS upcoming_birthday
FROM birthdays FROM birthdays
WHERE guild_id = %s WHERE guild_id = %s
ORDER BY (DAYOFYEAR(birthday) - DAYOFYEAR(now()) + 366) % 366; 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 from db import database
@ -7,7 +5,7 @@ class BlacklistUserService:
def __init__(self, user_id: int) -> None: def __init__(self, user_id: int) -> None:
self.user_id: int = user_id 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. Adds a user to the blacklist with the given reason.
@ -37,5 +35,5 @@ class BlacklistUserService:
FROM blacklist_user FROM blacklist_user
WHERE user_id = %s 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) return any(active for (active,) in result)

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