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

Clean up dir tree in preparation for v3

This commit is contained in:
wlinator 2024-08-28 04:58:40 -04:00
parent 5645a78174
commit 1c9e89cb0e
92 changed files with 1 additions and 9393 deletions

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

@ -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

@ -1,100 +1 @@
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)
# TODO: switch to DPY

View file

@ -1,115 +0,0 @@
import os
import pathlib
import re
import mysql.connector
from loguru import logger
from mysql.connector import pooling
from lib.constants import CONST
def create_connection_pool(name: str, size: int) -> pooling.MySQLConnectionPool:
return pooling.MySQLConnectionPool(
pool_name=name,
pool_size=size,
host="db",
port=3306,
database=CONST.MARIADB_DATABASE,
user=CONST.MARIADB_USER,
password=CONST.MARIADB_PASSWORD,
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
)
try:
_cnxpool = create_connection_pool("core-pool", 25)
except mysql.connector.Error as e:
logger.critical(f"Couldn't create the MySQL connection pool: {e}")
raise e
def execute_query(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(query, values)
conn.commit()
return cursor
def select_query(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(query, values)
return cursor.fetchall()
def select_query_one(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(query, values)
output = cursor.fetchone()
return output[0] if output else None
def select_query_dict(query, values=None):
with _cnxpool.get_connection() as conn:
with conn.cursor(dictionary=True) as cursor:
cursor.execute(query, values)
return cursor.fetchall()
def run_migrations():
migrations_dir = "db/migrations"
migration_files = sorted(
[f for f in os.listdir(migrations_dir) if f.endswith(".sql")],
)
with _cnxpool.get_connection() as conn:
with conn.cursor() as cursor:
# Create migrations table if it doesn't exist
cursor.execute("""
CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
for migration_file in migration_files:
# 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(
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

@ -1,109 +0,0 @@
SET FOREIGN_KEY_CHECKS=0;
CREATE TABLE IF NOT EXISTS xp (
user_id BIGINT NOT NULL,
guild_id BIGINT NOT NULL,
user_xp INT NOT NULL,
user_level INT NOT NULL,
cooldown DECIMAL(15,2),
PRIMARY KEY (user_id, guild_id)
);
CREATE TABLE IF NOT EXISTS currency (
user_id BIGINT NOT NULL,
balance BIGINT NOT NULL,
PRIMARY KEY (user_id)
);
CREATE TABLE IF NOT EXISTS blackjack (
id INT AUTO_INCREMENT,
user_id BIGINT,
is_won BOOLEAN,
bet BIGINT,
payout BIGINT,
hand_player TEXT,
hand_dealer TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS slots (
id INT AUTO_INCREMENT,
user_id BIGINT,
is_won BOOLEAN,
bet BIGINT,
payout BIGINT,
spin_type TEXT,
icons TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS dailies (
id INT AUTO_INCREMENT,
user_id BIGINT,
amount BIGINT,
claimed_at TINYTEXT,
streak INT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS item (
id INT AUTO_INCREMENT,
name TEXT,
display_name TEXT,
description TEXT,
image_url TEXT,
emote_id BIGINT,
quote TEXT,
type TEXT,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS inventory (
user_id BIGINT,
item_id INT,
quantity INT,
PRIMARY KEY (user_id, item_id),
FOREIGN KEY (item_id) REFERENCES item (id)
);
CREATE TABLE IF NOT EXISTS birthdays (
user_id BIGINT NOT NULL,
guild_id BIGINT NOT NULL,
birthday DATETIME DEFAULT NULL,
PRIMARY KEY (user_id, guild_id)
);
CREATE TABLE IF NOT EXISTS guild_config (
guild_id BIGINT NOT NULL,
prefix TINYTEXT,
birthday_channel_id BIGINT,
command_channel_id BIGINT, /* NULL: users can do XP & Currency commands everywhere. */
intro_channel_id BIGINT,
welcome_channel_id BIGINT,
welcome_message TEXT,
boost_channel_id BIGINT,
boost_message TEXT,
boost_image_url TEXT,
level_channel_id BIGINT, /* level-up messages, if NULL the level-up message will be shown in current msg channel*/
level_message TEXT, /* if NOT NULL and LEVEL_TYPE = 2, this can be a custom level up message. */
level_message_type TINYINT(1) NOT NULL DEFAULT 1, /* 0: no level up messages, 1: levels.en-US.json, 2: generic message */
PRIMARY KEY (guild_id)
);
CREATE TABLE IF NOT EXISTS level_rewards (
guild_id BIGINT NOT NULL,
level INT NOT NULL,
role_id BIGINT,
persistent BOOLEAN,
PRIMARY KEY (guild_id, level)
);
CREATE TABLE IF NOT EXISTS blacklist_user (
user_id BIGINT NOT NULL,
reason TEXT,
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
active BOOLEAN DEFAULT TRUE,
PRIMARY KEY (user_id)
);

View file

@ -1,21 +0,0 @@
-- Create a table to store custom reactions
CREATE TABLE IF NOT EXISTS custom_reactions (
id SERIAL PRIMARY KEY, -- Unique identifier for each custom reaction
trigger_text TEXT NOT NULL, -- The text that triggers the custom reaction
response TEXT, -- The response text for the custom reaction (nullable for emoji reactions)
emoji_id BIGINT UNSIGNED, -- The emoji for the custom reaction (nullable for text responses)
is_emoji BOOLEAN DEFAULT FALSE, -- Indicates if the reaction is a discord emoji reaction
is_full_match BOOLEAN DEFAULT FALSE, -- Indicates if the trigger matches the full content of the message
is_global BOOLEAN DEFAULT TRUE, -- Indicates if the reaction is global or specific to a guild
guild_id BIGINT UNSIGNED, -- The ID of the guild where the custom reaction is used (nullable for global reactions)
creator_id BIGINT UNSIGNED NOT NULL, -- The ID of the user who created the custom reaction
usage_count INT DEFAULT 0, -- The number of times a custom reaction has been used
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the custom reaction was created
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp when the custom reaction was last updated
CONSTRAINT unique_trigger_guild UNIQUE (trigger_text, guild_id) -- Ensure that the combination of trigger_text, guild_id, and is_full_match is unique
);
-- 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_creator_id ON custom_reactions(creator_id);
CREATE OR REPLACE INDEX idx_custom_reactions_trigger_text ON custom_reactions(trigger_text);

View file

@ -1,44 +0,0 @@
CREATE TABLE IF NOT EXISTS mod_log (
guild_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
guild_id BIGINT UNSIGNED NOT NULL,
case_number INT UNSIGNED NOT NULL,
target_id BIGINT UNSIGNED NOT NULL,
moderator_id BIGINT UNSIGNED NOT NULL,
action_type ENUM(
'WARN',
'TIMEOUT',
'UNTIMEOUT',
'KICK',
'BAN',
'UNBAN',
'SOFTBAN',
'TEMPBAN',
'NOTE',
'MUTE',
'UNMUTE',
'DEAFEN',
'UNDEAFEN'
) NOT NULL,
reason TEXT,
duration INT UNSIGNED, -- for timeouts
expires_at TIMESTAMP, -- for tempbans & mutes
modlog_message_id BIGINT UNSIGNED,
is_closed BOOLEAN NOT NULL DEFAULT FALSE, -- to indicate if the case is closed
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_case (guild_id, case_number)
);
CREATE OR REPLACE INDEX idx_cases_guild_id ON cases(guild_id);
CREATE OR REPLACE INDEX idx_cases_target_id ON cases(target_id);
CREATE OR REPLACE INDEX idx_cases_moderator_id ON cases(moderator_id);
CREATE OR REPLACE INDEX idx_cases_action_type ON cases(action_type);

View file

@ -1,28 +0,0 @@
services:
core:
image: ghcr.io/wlinator/luminara:2 # Remove "ghcr.io/" if you want to use the Docker Hub image.
container_name: lumi-core
restart: always
env_file:
- path: ./.env
required: true
depends_on:
db:
condition: service_healthy
db:
image: mariadb
container_name: lumi-db
restart: always
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_USER: ${MARIADB_USER}
MARIADB_PASSWORD: ${MARIADB_PASSWORD}
MARIADB_DATABASE: ${MARIADB_DATABASE}
volumes:
- ./data:/var/lib/mysql/
healthcheck:
test: [ "CMD", "mariadb", "-h", "localhost", "-u", "${MARIADB_USER}", "-p${MARIADB_PASSWORD}", "-e", "SELECT 1" ]
interval: 5s
timeout: 10s
retries: 5

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

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,86 +0,0 @@
import contextlib
from discord import Message
from discord.ext.commands import Cog
from loguru import logger
from services.blacklist_service import BlacklistUserService
from services.reactions_service import CustomReactionsService
class ReactionHandler:
"""
Handles reactions to messages based on predefined triggers and responses.
"""
def __init__(self, client, message: Message) -> None:
self.client = client
self.message: Message = message
self.content: str = self.message.content.lower()
self.reaction_service = CustomReactionsService()
async def run_checks(self) -> None:
"""
Runs checks for reactions and responses.
Guild triggers are prioritized over global triggers if they are identical.
"""
guild_id = self.message.guild.id if self.message.guild else None
if guild_id:
data = await self.reaction_service.find_trigger(guild_id, self.content)
if data:
processed = False
try:
if data["type"] == "text":
processed = await self.try_respond(data)
elif data["type"] == "emoji":
processed = await self.try_react(data)
except Exception as e:
logger.warning(f"Failed to process reaction: {e}")
if processed:
await self.reaction_service.increment_reaction_usage(
int(data["id"]),
)
async def try_respond(self, data) -> bool:
"""
Tries to respond to the message.
"""
if response := data.get("response"):
with contextlib.suppress(Exception):
await self.message.reply(response)
return True
return False
async def try_react(self, data) -> bool:
"""
Tries to react to the message.
"""
if emoji_id := data.get("emoji_id"):
with contextlib.suppress(Exception):
if emoji := self.client.get_emoji(emoji_id):
await self.message.add_reaction(emoji)
return True
return False
class ReactionListener(Cog):
def __init__(self, client) -> None:
self.client = client
@Cog.listener("on_message")
async def reaction_listener(self, message: Message) -> None:
"""
Listens for new messages and processes them if the author is not a bot and not blacklisted.
:param message: The message to process.
"""
if not message.author.bot and not BlacklistUserService.is_user_blacklisted(
message.author.id,
):
await ReactionHandler(self.client, message).run_checks()
def setup(client) -> None:
client.add_cog(ReactionListener(client))

View file

@ -1,263 +0,0 @@
import asyncio
import contextlib
import random
import time
from typing import Optional
import discord
from discord.ext import commands
from discord.ext.commands import TextChannelConverter
from Client import LumiBot
from lib import formatter
from lib.constants import CONST
from services.blacklist_service import BlacklistUserService
from services.config_service import GuildConfig
from services.xp_service import XpRewardService, XpService
class XPHandler:
def __init__(self, client: LumiBot, message: discord.Message) -> None:
"""
Initializes the XPHandler with the given client and message.
Args:
client (LumiBot): The bot client.
message (discord.Message): The message object.
"""
self.client = client
self.message: discord.Message = message
self.author: discord.Member | discord.User = message.author
self.guild: discord.Guild | None = message.guild
self.xp_conf: XpService = XpService(
self.author.id,
self.guild.id if self.guild else 0,
)
self.guild_conf: Optional[GuildConfig] = None
def process(self) -> bool:
"""
Processes the XP gain and level up for the user.
Returns:
bool: True if the user leveled up, False otherwise.
"""
_xp: XpService = self.xp_conf
_now: float = time.time()
leveled_up: bool = False
if _xp.cooldown_time and _now < _xp.cooldown_time:
return False
# Award the amount of XP specified in .env
_xp.xp += _xp.xp_gain
# Check if total XP now exceeds the XP required to level up
if _xp.xp >= XpService.xp_needed_for_next_level(_xp.level):
_xp.level += 1
_xp.xp = 0
leveled_up = True
_xp.cooldown_time = _now + _xp.new_cooldown
_xp.push()
return leveled_up
async def notify(self) -> None:
"""
Notifies the user and the guild about the level up.
"""
if self.guild is None:
return
_xp: XpService = self.xp_conf
_gd: GuildConfig = GuildConfig(self.guild.id)
level_message: Optional[str] = None # Initialize level_message
if isinstance(self.author, discord.Member):
level_message = await self.get_level_message(_gd, _xp, self.author)
if level_message:
level_channel: Optional[discord.TextChannel] = await self.get_level_channel(
self.message,
_gd,
)
if level_channel:
await level_channel.send(content=level_message)
else:
await self.message.reply(content=level_message)
async def reward(self) -> None:
"""
Rewards the user with a role for leveling up.
"""
if self.guild is None:
return
_xp: XpService = self.xp_conf
_rew: XpRewardService = XpRewardService(self.guild.id)
if role_id := _rew.get_role(_xp.level):
reason: str = "Automated Level Reward"
if role := self.guild.get_role(role_id):
with contextlib.suppress(
discord.Forbidden,
discord.NotFound,
discord.HTTPException,
):
if isinstance(self.author, discord.Member):
await self.author.add_roles(role, reason=reason)
previous, replace = _rew.should_replace_previous_reward(_xp.level)
if replace and isinstance(self.author, discord.Member):
if role := self.guild.get_role(previous or role_id):
with contextlib.suppress(
discord.Forbidden,
discord.NotFound,
discord.HTTPException,
):
await self.author.remove_roles(role, reason=reason)
async def get_level_channel(
self,
message: discord.Message,
guild_config: GuildConfig,
) -> Optional[discord.TextChannel]:
"""
Retrieves the level up notification channel for the guild.
Args:
message (discord.Message): The message object.
guild_config (GuildConfig): The guild configuration.
Returns:
Optional[discord.TextChannel]: The level up notification channel, or None if not found.
"""
if guild_config.level_channel_id and message.guild:
context = await self.client.get_context(message)
with contextlib.suppress(commands.BadArgument, commands.CommandError):
return await TextChannelConverter().convert(
context,
str(guild_config.level_channel_id),
)
return None
@staticmethod
async def get_level_message(
guild_config: GuildConfig,
level_config: XpService,
author: discord.Member,
) -> Optional[str]:
"""
Retrieves the level up message for the user.
Args:
guild_config (GuildConfig): The guild configuration.
level_config (XpService): The XP service configuration.
author (discord.Member): The user who leveled up.
Returns:
Optional[str]: The level up message, or None if not found.
"""
match guild_config.level_message_type:
case 0:
level_message = None
case 1:
level_message = XPHandler.messages_whimsical(level_config.level, author)
case 2:
if not guild_config.level_message:
level_message = XPHandler.level_message_generic(
level_config.level,
author,
)
else:
level_message = formatter.template(
guild_config.level_message,
author.name,
level_config.level,
)
case _:
raise ValueError("Invalid level message type")
return level_message
@staticmethod
def level_message_generic(level: int, author: discord.Member) -> str:
"""
Generates a generic level up message.
Args:
level (int): The new level of the user.
author (discord.Member): The user who leveled up.
Returns:
str: The generic level up message.
"""
return CONST.STRINGS["level_up"].format(author.name, level)
@staticmethod
def messages_whimsical(level: int, author: discord.Member) -> str:
"""
Generates a whimsical level up message.
Args:
level (int): The new level of the user.
author (discord.Member): The user who leveled up.
Returns:
str: The whimsical level up message.
"""
level_range: Optional[str] = None
for key in CONST.LEVEL_MESSAGES.keys():
start, end = map(int, key.split("-"))
if start <= level <= end:
level_range = key
break
if level_range is None:
# Generic fallback
return XPHandler.level_message_generic(level, author)
message_list = CONST.LEVEL_MESSAGES[level_range]
random_message = random.choice(message_list)
start_string = CONST.STRINGS["level_up_prefix"].format(author.name)
return start_string + random_message.format(level)
class XpListener(commands.Cog):
def __init__(self, client: LumiBot) -> None:
"""
Initializes the XpListener with the given client.
Args:
client (LumiBot): The bot client.
"""
self.client: LumiBot = client
@commands.Cog.listener("on_message")
async def xp_listener(self, message: discord.Message) -> None:
"""
Listens for messages and processes XP gain and level up.
Args:
message (discord.Message): The message object.
"""
if BlacklistUserService.is_user_blacklisted(message.author.id):
return
if message.author.bot or message.guild is None:
return
_xp: XPHandler = XPHandler(self.client, message)
if _xp.process():
await asyncio.gather(
_xp.notify(),
_xp.reward(),
)
def setup(client: LumiBot) -> None:
client.add_cog(XpListener(client))

View file

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

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

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,138 +0,0 @@
import textwrap
import discord
from discord.ext import commands
from pytimeparse import parse
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
from services.config_service import GuildConfig
def template(text: str, username: str, level: int | None = None) -> str:
"""
Replaces placeholders in the given text with actual values.
Args:
text (str): The template text containing placeholders.
username (str): The username to replace the "{user}" placeholder.
level (int | None, optional): The level to replace the "{level}" placeholder. Defaults to None.
Returns:
str: The formatted text with placeholders replaced by actual values.
"""
replacements: dict[str, str] = {
"{user}": username,
"{level}": str(level) if level else "",
}
for placeholder, value in replacements.items():
text = text.replace(placeholder, value)
return text
def shorten(text: str, width: int = 200) -> str:
"""
Shortens the input text to the specified width by adding a placeholder at the end if the text exceeds the width.
Args:
text (str): The text to be shortened.
width (int): The maximum width of the shortened text (default is 200).
Returns:
str: The shortened text.
Examples:
shortened_text = shorten("Lorem ipsum dolor sit amet", 10)
"""
return textwrap.shorten(text, width=width, placeholder="...")
def format_case_number(case_number: int) -> str:
"""
Formats a case number as a string with leading zeros if necessary.
Args:
case_number (int): The case number to format.
Returns:
str: The formatted case number as a string.
If the case number is less than 1000, it will be padded with leading zeros to three digits.
If the case number is 1000 or greater, it will be returned as a regular string.
Examples:
>>> format_case_number(1)
'001'
>>> format_case_number(42)
'042'
>>> format_case_number(999)
'999'
>>> format_case_number(1000)
'1000'
"""
return f"{case_number:03d}" if case_number < 1000 else str(case_number)
def get_prefix(ctx: commands.Context) -> str:
"""
Attempts to retrieve the prefix for the given guild context.
Args:
ctx (discord.ext.commands.Context): The context of the command invocation.
Returns:
str: The prefix for the guild. Defaults to "." if the guild or prefix is not found.
"""
try:
return GuildConfig.get_prefix(ctx.guild.id if ctx.guild else 0)
except (AttributeError, TypeError):
return "."
def get_invoked_name(ctx: commands.Context) -> str | None:
"""
Attempts to get the alias of the command used. If the user used a SlashCommand, return the command name.
Args:
ctx (discord.ext.commands.Context): The context of the command invocation.
Returns:
str: The alias or name of the invoked command.
"""
try:
return ctx.invoked_with
except (discord.ApplicationCommandInvokeError, AttributeError):
return ctx.command.name if ctx.command else None
def format_duration_to_seconds(duration: str) -> int:
"""
Formats a duration in seconds to a human-readable string.
"""
parsed_duration = parse(duration)
if isinstance(parsed_duration, int):
return parsed_duration
else:
raise LumiException(CONST.STRINGS["error_invalid_duration"].format(duration))
def format_seconds_to_duration_string(seconds: int) -> str:
"""
Formats a duration in seconds to a human-readable string.
Returns seconds if shorter than a minute.
"""
if seconds < 60:
return f"{seconds}s"
days = seconds // 86400
hours = (seconds % 86400) // 3600
minutes = (seconds % 3600) // 60
if days > 0:
return f"{days}d{hours}h" if hours > 0 else f"{days}d"
elif hours > 0:
return f"{hours}h{minutes}m" if minutes > 0 else f"{hours}h"
else:
return f"{minutes}m"

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

View file

@ -1,78 +0,0 @@
import discord
from discord.ext import bridge
from discord.ui import View
class IntroductionStartButtons(View):
def __init__(self, ctx):
super().__init__(timeout=60)
self.ctx = ctx
self.clickedStart = False
self.clickedStop = False
async def on_timeout(self):
for child in self.children:
if isinstance(child, discord.ui.Button):
child.disabled = True
if self.message:
await self.message.edit(view=None)
@discord.ui.button(label="Start", style=discord.ButtonStyle.primary)
async def short_button_callback(self, button, interaction):
await interaction.response.edit_message(view=None)
self.clickedStart = 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.clickedStop = True
self.stop()
class IntroductionFinishButtons(View):
def __init__(self, ctx: bridge.Context) -> None:
"""
Initializes the IntroductionFinishConfirm view.
"""
super().__init__(timeout=60)
self.ctx = ctx
self.clickedConfirm: bool = False
async def on_timeout(self) -> None:
"""
Called when the view times out. Disables all buttons and edits the message to remove the view.
"""
for child in self.children:
if isinstance(child, discord.ui.Button):
child.disabled = True
if self.message:
await self.message.edit(view=None)
@discord.ui.button(label="Post it!", style=discord.ButtonStyle.green)
async def short_button_callback(
self,
button: discord.ui.Button,
interaction: discord.Interaction,
) -> None:
await interaction.response.edit_message(view=None)
self.clickedConfirm = True
self.stop()
@discord.ui.button(label="Stop", style=discord.ButtonStyle.red)
async def extended_button_callback(
self,
button: discord.ui.Button,
interaction: discord.Interaction,
) -> None:
await interaction.response.edit_message(view=None)
self.stop()
async def interaction_check(self, interaction: discord.Interaction) -> bool:
if interaction.user == self.ctx.author:
return True
await interaction.response.send_message(
"You can't use these buttons.",
ephemeral=True,
)
return False

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()

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

View file

@ -1,23 +0,0 @@
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.currency_service import Currency
async def cmd(ctx, user: discord.User, amount: int):
# Currency handler
curr = Currency(user.id)
curr.add_balance(amount)
curr.push()
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["admin_award_title"],
description=CONST.STRINGS["admin_award_description"].format(
Currency.format(amount),
user.name,
),
)
await ctx.respond(embed=embed)

View file

@ -1,26 +0,0 @@
from typing import Optional
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.blacklist_service import BlacklistUserService
async def blacklist_user(
ctx,
user: discord.User,
reason: Optional[str] = None,
) -> None:
blacklist_service = BlacklistUserService(user.id)
blacklist_service.add_to_blacklist(reason)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["admin_blacklist_author"],
description=CONST.STRINGS["admin_blacklist_description"].format(user.name),
footer_text=CONST.STRINGS["admin_blacklist_footer"],
hide_timestamp=True,
)
await ctx.send(embed=embed)

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 +0,0 @@
import calendar
import datetime
import discord
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.birthday_service import Birthday
async def add(ctx, month, month_index, day):
leap_year = 2020
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)
birthday = Birthday(ctx.author.id, ctx.guild.id)
birthday.set(date_obj)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["birthday_add_success_author"],
description=CONST.STRINGS["birthday_add_success_description"].format(
month,
day,
),
show_name=True,
)
await ctx.respond(embed=embed)
async def delete(ctx):
Birthday(ctx.author.id, ctx.guild.id).delete()
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["birthday_delete_success_author"],
description=CONST.STRINGS["birthday_delete_success_description"],
show_name=True,
)
await ctx.respond(embed=embed)
async def upcoming(ctx):
upcoming_birthdays = Birthday.get_upcoming_birthdays(ctx.guild.id)
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(
ctx,
author_text=CONST.STRINGS["birthday_upcoming_author"],
description="",
show_name=False,
)
embed.set_thumbnail(url=CONST.LUMI_LOGO_TRANSPARENT)
birthday_lines = []
for user_id, birthday in upcoming_birthdays[:10]:
try:
member = await ctx.guild.fetch_member(user_id)
birthday_date = datetime.datetime.strptime(birthday, "%m-%d")
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 ctx.respond(embed=embed)

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)

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 +0,0 @@
from discord.ext import commands
from services.currency_service import Currency
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
async def cmd(ctx: commands.Context[commands.Bot]) -> None:
ctx_currency = Currency(ctx.author.id)
balance = Currency.format(ctx_currency.balance)
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["balance_author"].format(ctx.author.name),
author_icon_url=ctx.author.display_avatar.url,
description=CONST.STRINGS["balance_cash"].format(balance),
footer_text=CONST.STRINGS["balance_footer"],
show_name=False,
hide_timestamp=True,
)
await ctx.respond(embed=embed)

View file

@ -1,324 +0,0 @@
import random
from typing import List, Tuple
from loguru import logger
import discord
from discord.ui import View
import pytz
from discord.ext import commands
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
from services.currency_service import Currency
from services.stats_service import BlackJackStats
from lib.embed_builder import EmbedBuilder
EST = pytz.timezone("US/Eastern")
ACTIVE_BLACKJACK_GAMES: dict[int, bool] = {}
Card = str
Hand = List[Card]
async def cmd(ctx: commands.Context, bet: int) -> None:
if ctx.author.id in ACTIVE_BLACKJACK_GAMES:
raise LumiException(CONST.STRINGS["error_already_playing_blackjack"])
currency = Currency(ctx.author.id)
if bet > currency.balance:
raise LumiException(CONST.STRINGS["error_not_enough_cash"])
if bet <= 0:
raise LumiException(CONST.STRINGS["error_invalid_bet"])
ACTIVE_BLACKJACK_GAMES[ctx.author.id] = True
try:
await play_blackjack(ctx, currency, bet)
except Exception as e:
logger.exception(f"Error in blackjack game: {e}")
raise LumiException(CONST.STRINGS["error_blackjack_game_error"]) from e
finally:
del ACTIVE_BLACKJACK_GAMES[ctx.author.id]
async def play_blackjack(ctx: commands.Context, currency: Currency, bet: int) -> None:
deck = get_new_deck()
player_hand, dealer_hand = initial_deal(deck)
multiplier = CONST.BLACKJACK_MULTIPLIER
player_value = calculate_hand_value(player_hand)
status = 5 if player_value == 21 else 0
view = BlackJackButtons(ctx)
playing_embed = False
while status == 0:
dealer_value = calculate_hand_value(dealer_hand)
embed = create_game_embed(
ctx,
bet,
player_hand,
dealer_hand,
player_value,
dealer_value,
)
if not playing_embed:
await ctx.respond(embed=embed, view=view, content=ctx.author.mention)
playing_embed = True
else:
await ctx.edit(embed=embed, view=view)
await view.wait()
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:
currency.take_balance(bet)
currency.push()
raise LumiException(CONST.STRINGS["error_out_of_time_economy"])
view = BlackJackButtons(ctx)
await handle_game_end(
ctx,
currency,
bet,
player_hand,
dealer_hand,
status,
multiplier,
playing_embed,
)
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

View file

@ -1,43 +0,0 @@
from datetime import datetime, timedelta
import lib.time
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.currency_service import Currency
from services.daily_service import Dailies
async def cmd(ctx) -> None:
ctx_daily = Dailies(ctx.author.id)
if not ctx_daily.can_be_claimed():
wait_time = datetime.now() + timedelta(seconds=lib.time.seconds_until(7, 0))
unix_time = int(round(wait_time.timestamp()))
error_embed = EmbedBuilder.create_error_embed(
ctx,
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()
embed = EmbedBuilder.create_success_embed(
ctx,
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.respond(embed=embed)

View file

@ -1,39 +0,0 @@
import discord
from discord.ext import commands
from services.currency_service import Currency
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
async def cmd(ctx: commands.Context, user: discord.User, amount: int) -> None:
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 = EmbedBuilder.create_success_embed(
ctx,
description=CONST.STRINGS["give_success"].format(
ctx.author.name,
Currency.format(amount),
user.name,
),
)
await ctx.respond(embed=embed)

View file

@ -1,214 +0,0 @@
import asyncio
import datetime
import random
from collections import Counter
import discord
import pytz
from discord.ext import commands
from lib.constants import CONST
from services.currency_service import Currency
from services.stats_service import SlotsStats
est = pytz.timezone("US/Eastern")
async def cmd(self, ctx, bet):
ctx_currency = Currency(ctx.author.id)
player_balance = ctx_currency.balance
if bet > player_balance:
raise commands.BadArgument("you don't have enough cash.")
elif bet <= 0:
raise commands.BadArgument("the bet you entered is invalid.")
results = [random.randint(0, 6) for _ in range(3)]
calculated_results = calculate_slots_results(bet, results)
(result_type, payout, multiplier) = calculated_results
is_won = result_type != "lost"
# only get the emojis once
emojis = get_emotes(self.client)
# start with default "spinning" embed
await ctx.respond(
embed=slots_spinning(ctx, 3, Currency.format_human(bet), results, emojis),
)
await asyncio.sleep(1)
for i in range(2, 0, -1):
await ctx.edit(
embed=slots_spinning(ctx, i, Currency.format_human(bet), results, emojis),
)
await asyncio.sleep(1)
# output final result
finished_output = slots_finished(
ctx,
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:
result_type = "three_of_a_kind"
multiplier = rewards[result_type]
payout = bet * multiplier
return result_type, int(payout), multiplier
def slots_spinning(ctx, spinning_icons_amount, bet, results, emojis):
first_slots_emote = emojis.get(f"slots_{results[0]}_id")
second_slots_emote = emojis.get(f"slots_{results[1]}_id")
slots_animated_emote = emojis.get("slots_animated_id")
current_time = 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(
color=color,
description=description,
)
embed.add_field(name=field_name, value=field_value, inline=False)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar.url)
embed.set_footer(
text=f"Game finished • {current_time}",
icon_url="https://i.imgur.com/wFsgSnr.png",
)
return embed

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

View file

@ -1,31 +0,0 @@
from discord import Embed
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.xp_service import XpService
async def rank(ctx: bridge.Context) -> None:
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 = 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,
)
await ctx.respond(embed=embed)

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,58 +0,0 @@
from io import BytesIO
from typing import Optional
import httpx
from discord import File, Member
from discord.ext import bridge
client: httpx.AsyncClient = httpx.AsyncClient()
async def get_avatar(ctx: bridge.Context, member: Member) -> None:
"""
Get the avatar of a member.
Parameters:
-----------
ctx : ApplicationContext
The discord context object.
member : Member
The member to get the avatar of.
"""
guild_avatar: Optional[str] = (
member.guild_avatar.url if member.guild_avatar else None
)
profile_avatar: Optional[str] = member.avatar.url if member.avatar else None
files: list[File] = [
await create_avatar_file(avatar)
for avatar in [guild_avatar, profile_avatar]
if avatar
]
if files:
await ctx.respond(files=files)
else:
await ctx.respond(content="member has no avatar.")
async def create_avatar_file(url: str) -> File:
"""
Create a discord file from an avatar url.
Parameters:
-----------
url : str
The url of the avatar.
Returns:
--------
File
The discord file.
"""
response: httpx.Response = await client.get(url, timeout=10)
response.raise_for_status()
image_data: bytes = response.content
image_file: BytesIO = BytesIO(image_data)
image_file.seek(0)
return File(image_file, filename="avatar.png")

View file

@ -1,67 +0,0 @@
import subprocess
from datetime import datetime
from typing import List, Optional
import dropbox
from dropbox.files import FileMetadata
from loguru import logger
from lib.constants import CONST
# Initialize Dropbox client if instance is "main"
_dbx: Optional[dropbox.Dropbox] = None
if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
_app_key: Optional[str] = CONST.DBX_APP_KEY
_dbx_token: Optional[str] = CONST.DBX_TOKEN
_app_secret: Optional[str] = CONST.DBX_APP_SECRET
_dbx = dropbox.Dropbox(
app_key=_app_key,
app_secret=_app_secret,
oauth2_refresh_token=_dbx_token,
)
async def create_db_backup() -> None:
if not _dbx:
raise ValueError("Dropbox client is not initialized")
backup_name: str = datetime.today().strftime("%Y-%m-%d_%H%M") + "_lumi.sql"
command: str = (
f"mariadb-dump --user={CONST.MARIADB_USER} --password={CONST.MARIADB_PASSWORD} "
f"--host=db --single-transaction --all-databases > ./db/migrations/100-dump.sql"
)
subprocess.check_output(command, shell=True)
with open("./db/migrations/100-dump.sql", "rb") as f:
_dbx.files_upload(f.read(), f"/{backup_name}")
async def backup_cleanup() -> None:
if not _dbx:
raise ValueError("Dropbox client is not initialized")
result = _dbx.files_list_folder("")
all_backup_files: List[str] = [
entry.name
for entry in result.entries
if isinstance(entry, FileMetadata) # type: ignore
]
for file in sorted(all_backup_files)[:-48]:
_dbx.files_delete_v2("/" + file)
async def backup() -> None:
if CONST.INSTANCE and CONST.INSTANCE.lower() == "main":
logger.debug("Backing up the database.")
try:
await create_db_backup()
await backup_cleanup()
logger.debug("Backup successful.")
except Exception as error:
logger.error(f"Backup failed: {error}")
else:
logger.debug('No backup, instance not "MAIN".')

View file

@ -1,42 +0,0 @@
import os
import platform
import discord
import psutil
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.currency_service import Currency
from services.stats_service import BlackJackStats
async def cmd(self, ctx: bridge.Context, unix_timestamp: int) -> None:
memory_usage_in_mb: float = psutil.Process().memory_info().rss / (1024 * 1024)
total_rows: str = Currency.format(BlackJackStats.get_total_rows_count())
description: str = "".join(
[
CONST.STRINGS["info_uptime"].format(unix_timestamp),
CONST.STRINGS["info_latency"].format(round(1000 * self.client.latency)),
CONST.STRINGS["info_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),
],
)
embed: discord.Embed = EmbedBuilder.create_success_embed(
ctx,
description=description,
footer_text=CONST.STRINGS["info_service_footer"],
show_name=False,
)
embed.set_author(
name=f"{CONST.TITLE} v{CONST.VERSION}",
url=CONST.REPO_URL,
icon_url=CONST.CHECK_ICON,
)
embed.set_thumbnail(url=CONST.LUMI_LOGO_OPAQUE)
await ctx.respond(embed=embed)

View file

@ -1,166 +0,0 @@
import asyncio
from typing import Dict, Optional
import discord
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.interactions.introduction import (
IntroductionFinishButtons,
IntroductionStartButtons,
)
async def cmd(self, ctx: bridge.Context) -> None:
guild: Optional[discord.Guild] = self.client.get_guild(CONST.KRC_GUILD_ID)
member: Optional[discord.Member] = (
guild.get_member(ctx.author.id) if guild else None
)
if not guild or not member:
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["intro_no_guild_author"],
description=CONST.STRINGS["intro_no_guild"],
footer_text=CONST.STRINGS["intro_service_name"],
),
)
return
question_mapping: Dict[str, str] = CONST.KRC_QUESTION_MAPPING
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(
embed=EmbedBuilder.create_embed(
ctx,
author_text=key,
description=question,
footer_text=CONST.STRINGS["intro_question_footer"],
),
)
try:
answer: discord.Message = await self.client.wait_for(
"message",
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(
ctx,
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.clickedConfirm:
await channel.send(
embed=preview,
content=CONST.STRINGS["intro_content"].format(ctx.author.mention),
)
await ctx.send(
embed=EmbedBuilder.create_embed(
ctx,
description=CONST.STRINGS["intro_post_confirmation"].format(
channel.mention,
),
),
)
else:
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"],
),
)

View file

@ -1,27 +0,0 @@
from discord import ButtonStyle
from discord.ext import bridge
from discord.ui import Button, View
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
async def cmd(ctx: bridge.BridgeContext) -> None:
await ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
description=CONST.STRINGS["invite_description"],
),
view=InviteButton(),
)
class InviteButton(View):
def __init__(self) -> None:
super().__init__(timeout=None)
invite_button: Button = Button(
label=CONST.STRINGS["invite_button_text"],
style=ButtonStyle.url,
url=CONST.INVITE_LINK,
)
self.add_item(invite_button)

View file

@ -1,32 +0,0 @@
from datetime import datetime
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
async def ping(self, ctx: bridge.BridgeContext) -> None:
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["ping_author"],
description=CONST.STRINGS["ping_pong"],
footer_text=CONST.STRINGS["ping_footer"].format(
round(1000 * self.client.latency),
),
)
await ctx.respond(embed=embed)
async def uptime(self, ctx: bridge.BridgeContext, start_time: datetime) -> None:
unix_timestamp: int = int(round(self.start_time.timestamp()))
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["ping_author"],
description=CONST.STRINGS["ping_uptime"].format(unix_timestamp),
footer_text=CONST.STRINGS["ping_footer"].format(
round(1000 * self.client.latency),
),
)
await ctx.respond(embed=embed)

View file

@ -1,47 +0,0 @@
from typing import Optional
from discord.ext import bridge
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from services.xkcd_service import Client, HttpError
_xkcd = Client()
async def print_comic(
ctx: bridge.Context,
latest: bool = False,
number: Optional[int] = None,
) -> None:
try:
if latest:
comic = _xkcd.get_latest_comic(raw_comic_image=True)
elif number is not None:
comic = _xkcd.get_comic(number, raw_comic_image=True)
else:
comic = _xkcd.get_random_comic(raw_comic_image=True)
await ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["xkcd_title"].format(comic.id, comic.title),
description=CONST.STRINGS["xkcd_description"].format(
comic.explanation_url,
comic.comic_url,
),
footer_text=CONST.STRINGS["xkcd_footer"],
image_url=comic.image_url,
show_name=False,
),
)
except HttpError:
await ctx.respond(
embed=EmbedBuilder.create_error_embed(
ctx,
author_text=CONST.STRINGS["xkcd_not_found_author"],
description=CONST.STRINGS["xkcd_not_found"],
footer_text=CONST.STRINGS["xkcd_footer"],
),
)

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 +0,0 @@
import asyncio
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 modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
async def ban_user(cog, ctx, target: discord.User, reason: Optional[str] = None):
# see if user is in guild
member = await MemberConverter().convert(ctx, str(target.id))
output_reason = reason or CONST.STRINGS["mod_no_reason"]
# member -> user is in the guild, check role hierarchy
if member:
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(member, ctx.author, bot_member)
try:
await member.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_banned_author"],
description=CONST.STRINGS["mod_ban_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
),
)
dm_sent = True
except (discord.HTTPException, discord.Forbidden):
dm_sent = False
await member.ban(
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
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):
output_reason = reason or CONST.STRINGS["mod_no_reason"]
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 +0,0 @@
import asyncio
import discord
from discord.ext import pages
from discord.ext.commands import UserConverter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import format_case_number
from modules.moderation.utils.case_embed import (
create_case_embed,
create_case_list_embed,
)
from modules.moderation.utils.case_handler import edit_case_modlog
from services.moderation.case_service import CaseService
case_service = CaseService()
async def view_case_by_number(ctx, guild_id: int, case_number: int):
case = case_service.fetch_case_by_guild_and_number(guild_id, case_number)
if not case:
embed = EmbedBuilder.create_error_embed(
ctx,
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():
await asyncio.gather(
ctx.respond(embed=embed),
edit_case_modlog(ctx, guild_id, case_number, new_reason),
)
await update_tasks()

View file

@ -1,58 +0,0 @@
import asyncio
from typing import Optional
import discord
from discord.ext.commands import UserConverter, MemberConverter
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
async def kick_user(cog, ctx, target: discord.Member, reason: Optional[str] = None):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_kicked_author"],
description=CONST.STRINGS["mod_kick_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
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,
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))
create_case_task = create_case(ctx, target_user, "KICK", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)

View file

@ -1,66 +0,0 @@
import asyncio
from typing import Optional
import discord
from discord.ext.commands import MemberConverter, UserConverter
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
async def softban_user(ctx, target: discord.Member, reason: Optional[str] = None):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
try:
await target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_softbanned_author"],
description=CONST.STRINGS["mod_softban_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
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,
formatter.shorten(output_reason, 200),
),
delete_message_seconds=86400,
)
await ctx.guild.unban(
target,
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))
create_case_task = create_case(ctx, target_user, "SOFTBAN", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)

View file

@ -1,103 +0,0 @@
import asyncio
import datetime
from typing import Optional
import discord
from discord.ext.commands import UserConverter, MemberConverter
from lib import formatter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import format_duration_to_seconds, format_seconds_to_duration_string
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
async def timeout_user(
cog,
ctx,
target: discord.Member,
duration: str,
reason: Optional[str] = None,
):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
# Parse duration to minutes and validate
duration_int = format_duration_to_seconds(duration)
duration_str = format_seconds_to_duration_string(duration_int)
await target.timeout_for(
duration=datetime.timedelta(seconds=duration_int),
reason=CONST.STRINGS["mod_reason"].format(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
dm_task = target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
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(
embed=EmbedBuilder.create_success_embed(
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))
create_case_task = create_case(ctx, target_user, "TIMEOUT", reason, duration_int)
await asyncio.gather(
dm_task,
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(
ctx.author.name,
formatter.shorten(output_reason, 200),
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_untimed_out_author"],
description=CONST.STRINGS["mod_untimed_out"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "UNTIMEOUT", reason)
await asyncio.gather(respond_task, create_case_task, return_exceptions=True)
except discord.HTTPException:
return await ctx.respond(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_not_timed_out_author"],
description=CONST.STRINGS["mod_not_timed_out"].format(target.name),
),
)

View file

@ -1,30 +0,0 @@
import discord
from lib.constants import CONST
from lib.exceptions.LumiExceptions import LumiException
async def async_actionable(
target: discord.Member,
invoker: discord.Member,
bot_user: discord.Member,
) -> None:
"""
Checks if the invoker and client have a higher role than the target user.
Args:
target: The member object of the target user.
invoker: The member object of the user who invoked the command.
bot_user: The discord.Bot.user object representing the bot itself.
Returns:
True if the client's highest role AND the invoker's highest role are higher than the target.
"""
if target == invoker:
raise LumiException(CONST.STRINGS["error_actionable_self"])
if target.top_role >= invoker.top_role and invoker != invoker.guild.owner:
raise LumiException(CONST.STRINGS["error_actionable_hierarchy_user"])
if target.top_role >= bot_user.top_role:
raise LumiException(CONST.STRINGS["error_actionable_hierarchy_bot"])

View file

@ -1,102 +0,0 @@
import datetime
from typing import Optional
import discord
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.formatter import format_case_number
from lib.formatter import format_seconds_to_duration_string
def create_case_embed(
ctx,
target: discord.User,
case_number: int,
action_type: str,
reason: Optional[str],
timestamp: Optional[datetime.datetime] = None,
duration: Optional[int] = None,
) -> discord.Embed:
embed = EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["case_new_case_author"],
thumbnail_url=target.display_avatar.url,
show_name=False,
timestamp=timestamp,
)
embed.add_field(
name=CONST.STRINGS["case_case_field"],
value=CONST.STRINGS["case_case_field_value"].format(
format_case_number(case_number),
),
inline=True,
)
if not duration:
embed.add_field(
name=CONST.STRINGS["case_type_field"],
value=CONST.STRINGS["case_type_field_value"].format(
action_type.lower().capitalize(),
),
inline=True,
)
else:
embed.add_field(
name=CONST.STRINGS["case_type_field"],
value=CONST.STRINGS["case_type_field_value_with_duration"].format(
action_type.lower().capitalize(),
format_seconds_to_duration_string(duration),
),
inline=True,
)
embed.add_field(
name=CONST.STRINGS["case_moderator_field"],
value=CONST.STRINGS["case_moderator_field_value"].format(
ctx.author.name,
),
inline=True,
)
embed.add_field(
name=CONST.STRINGS["case_target_field"],
value=CONST.STRINGS["case_target_field_value"].format(target.name),
inline=False,
)
embed.add_field(
name=CONST.STRINGS["case_reason_field"],
value=CONST.STRINGS["case_reason_field_value"].format(
reason or CONST.STRINGS["mod_no_reason"],
),
inline=False,
)
return embed
def create_case_list_embed(ctx, cases: list, author_text: str) -> discord.Embed:
embed = EmbedBuilder.create_success_embed(
ctx,
author_text=author_text,
show_name=False,
)
for case in cases:
status_emoji = "" if case.get("is_closed") else ""
case_number = case.get("case_number", "N/A")
if isinstance(case_number, int):
case_number = format_case_number(case_number)
action_type = case.get("action_type", "Unknown")
timestamp = case.get("created_at", "Unknown")
if isinstance(timestamp, datetime.datetime):
formatted_timestamp = f"<t:{int(timestamp.timestamp())}:R>"
else:
formatted_timestamp = str(timestamp)
if embed.description is None:
embed.description = ""
embed.description += f"{status_emoji} `{case_number}` **[{action_type}]** {formatted_timestamp}\n"
return embed

View file

@ -1,148 +0,0 @@
from typing import Optional
import discord
from discord.ext.commands import TextChannelConverter, UserConverter
from loguru import logger
from modules.moderation.utils.case_embed import create_case_embed
from services.moderation.case_service import CaseService
from services.moderation.modlog_service import ModLogService
case_service = CaseService()
modlog_service = ModLogService()
async def create_case(
ctx,
target: discord.User,
action_type: str,
reason: Optional[str] = None,
duration: Optional[int] = None,
expires_at: Optional[str] = None,
):
"""
Creates a new moderation case and logs it to the modlog channel if configured.
Args:
ctx: The context of the command invocation.
target (discord.User): The user who is the subject of the moderation action.
action_type (str): The type of moderation action (e.g., "ban", "kick", "warn").
reason (Optional[str]): The reason for the moderation action. Defaults to None.
duration (Optional[int]): The duration of the action in seconds, if applicable. Defaults to None.
expires_at (Optional[str]): The expiration date of the action, if applicable. Defaults to None.
Returns:
None
Raises:
Exception: If there's an error sending the case to the modlog channel.
This function performs the following steps:
1. Creates a new case in the database using the CaseService.
2. Logs the case creation using the logger.
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.
"""
guild_id = ctx.guild.id
moderator_id = ctx.author.id
target_id = target.id
# Create the case
case_number: int = case_service.create_case(
guild_id=guild_id,
target_id=target_id,
moderator_id=moderator_id,
action_type=action_type,
reason=reason,
duration=duration,
expires_at=expires_at,
modlog_message_id=None,
)
logger.info(f"Created case {case_number} for {target.name} in guild {guild_id}")
if mod_log_channel_id := modlog_service.fetch_modlog_channel_id(guild_id):
try:
mod_log_channel = await TextChannelConverter().convert(
ctx,
str(mod_log_channel_id),
)
embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case_number,
action_type=action_type,
reason=reason,
timestamp=None,
duration=duration,
)
message = await mod_log_channel.send(embed=embed)
# Update the case with the modlog_message_id
case_service.edit_case(
guild_id=guild_id,
case_number=case_number,
changes={"modlog_message_id": message.id},
)
except Exception as e:
logger.error(f"Failed to send case to modlog channel: {e}")
async def edit_case_modlog(
ctx,
guild_id: int,
case_number: int,
new_reason: str,
) -> bool:
"""
Edits the reason for an existing case and updates the modlog message if it exists.
Args:
ctx: The context of the command invocation.
guild_id: The ID of the guild where the case exists.
case_number: The number of the case to edit.
new_reason: The new reason for the case.
Raises:
ValueError: If the case is not found.
Exception: If there's an error updating the modlog message.
"""
case = case_service.fetch_case_by_guild_and_number(guild_id, case_number)
if not case:
raise ValueError(f"Case {case_number} not found in guild {guild_id}")
modlog_message_id = case.get("modlog_message_id")
if not modlog_message_id:
return False
mod_log_channel_id = modlog_service.fetch_modlog_channel_id(guild_id)
if not mod_log_channel_id:
return False
try:
mod_log_channel = await TextChannelConverter().convert(
ctx,
str(mod_log_channel_id),
)
message = await mod_log_channel.fetch_message(modlog_message_id)
target = await UserConverter().convert(ctx, str(case["target_id"]))
updated_embed: discord.Embed = create_case_embed(
ctx=ctx,
target=target,
case_number=case_number,
action_type=case["action_type"],
reason=new_reason,
timestamp=case["created_at"],
duration=case["duration"] or None,
)
await message.edit(embed=updated_embed)
logger.info(f"Updated case {case_number} in guild {guild_id}")
except Exception as e:
logger.error(f"Failed to update modlog message for case {case_number}: {e}")
return False
return True

View file

@ -1,48 +0,0 @@
import asyncio
from typing import Optional
import discord
from discord.ext.commands import UserConverter, MemberConverter
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from modules.moderation.utils.actionable import async_actionable
from modules.moderation.utils.case_handler import create_case
async def warn_user(ctx, target: discord.Member, reason: Optional[str]):
bot_member = await MemberConverter().convert(ctx, str(ctx.bot.user.id))
await async_actionable(target, ctx.author, bot_member)
output_reason = reason or CONST.STRINGS["mod_no_reason"]
dm_task = target.send(
embed=EmbedBuilder.create_warning_embed(
ctx,
author_text=CONST.STRINGS["mod_warned_author"],
description=CONST.STRINGS["mod_warn_dm"].format(
target.name,
ctx.guild.name,
output_reason,
),
show_name=False,
),
)
respond_task = ctx.respond(
embed=EmbedBuilder.create_success_embed(
ctx,
author_text=CONST.STRINGS["mod_warned_author"],
description=CONST.STRINGS["mod_warned_user"].format(target.name),
),
)
target_user = await UserConverter().convert(ctx, str(target.id))
create_case_task = create_case(ctx, target_user, "WARN", reason)
await asyncio.gather(
dm_task,
respond_task,
create_case_task,
return_exceptions=True,
)

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)

1142
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
[tool.poetry]
authors = ["wlinator <dokimakimaki@gmail.com>"]
description = "A Discord application, can serve as a template for your own bot."
license = "GNU General Public License v3.0"
name = "lumi"
package-mode = false
readme = "README.md"
version = "0.1.0"
[tool.poetry.dependencies]
dropbox = "^12.0.2"
httpx = "^0.27.0"
loguru = "^0.7.2"
mysql-connector-python = "^9.0.0"
pre-commit = "^3.7.1"
psutil = "^6.0.0"
py-cord = "^2.5.0"
pyright = "^1.1.371"
python = "^3.12"
python-dotenv = "^1.0.1"
pytimeparse = "^1.1.8"
pytz = "^2024.1"
ruff = "^0.5.2"
[build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core"]

View file

@ -1,54 +0,0 @@
import datetime
import pytz
from db import database
class Birthday:
def __init__(self, user_id, guild_id):
self.user_id = user_id
self.guild_id = guild_id
def set(self, birthday):
query = """
INSERT INTO birthdays (user_id, guild_id, birthday)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE birthday = VALUES(birthday);
"""
database.execute_query(query, (self.user_id, self.guild_id, birthday))
def delete(self):
query = """
DELETE FROM birthdays
WHERE user_id = %s AND guild_id = %s;
"""
database.execute_query(query, (self.user_id, self.guild_id))
@staticmethod
def get_birthdays_today():
query = """
SELECT user_id, guild_id
FROM birthdays
WHERE DATE_FORMAT(birthday, '%m-%d') = %s
"""
tz = pytz.timezone("US/Eastern")
today = datetime.datetime.now(tz).strftime("%m-%d")
return database.select_query(query, (today,))
@staticmethod
def get_upcoming_birthdays(guild_id):
query = """
SELECT user_id, DATE_FORMAT(birthday, '%m-%d') AS upcoming_birthday
FROM birthdays
WHERE guild_id = %s
ORDER BY (DAYOFYEAR(birthday) - DAYOFYEAR(now()) + 366) % 366;
"""
data = database.select_query(query, (guild_id,))
return [(row[0], row[1]) for row in data]

View file

@ -1,41 +0,0 @@
from typing import List, Optional, Tuple
from db import database
class BlacklistUserService:
def __init__(self, user_id: int) -> None:
self.user_id: int = user_id
def add_to_blacklist(self, reason: Optional[str] = None) -> None:
"""
Adds a user to the blacklist with the given reason.
Args:
reason (str): The reason for blacklisting the user.
"""
query: str = """
INSERT INTO blacklist_user (user_id, reason)
VALUES (%s, %s)
ON DUPLICATE KEY UPDATE reason = VALUES(reason)
"""
database.execute_query(query, (self.user_id, reason))
@staticmethod
def is_user_blacklisted(user_id: int) -> bool:
"""
Checks if a user is currently blacklisted.
Args:
user_id (int): The ID of the user to check.
Returns:
bool: True if the user is blacklisted, False otherwise.
"""
query: str = """
SELECT active
FROM blacklist_user
WHERE user_id = %s
"""
result: List[Tuple[bool]] = database.select_query(query, (user_id,))
return any(active for (active,) in result)

View file

@ -1,144 +0,0 @@
from db import database
class GuildConfig:
def __init__(self, guild_id):
self.guild_id = guild_id
self.birthday_channel_id = None
self.command_channel_id = None
self.intro_channel_id = None
self.welcome_channel_id = None
self.welcome_message = None
self.boost_channel_id = None
self.boost_message = None
self.boost_image_url = None
self.level_channel_id = None
self.level_message = None
self.level_message_type = 1
self.fetch_or_create_config()
def fetch_or_create_config(self):
"""
Gets a Guild Config from the database or inserts a new row if it doesn't exist yet.
"""
query = """
SELECT birthday_channel_id, command_channel_id, intro_channel_id,
welcome_channel_id, welcome_message, boost_channel_id,
boost_message, boost_image_url, level_channel_id,
level_message, level_message_type
FROM guild_config WHERE guild_id = %s
"""
try:
self._extracted_from_fetch_or_create_config_14(query)
except (IndexError, TypeError):
# No record found for the specified guild_id
query = "INSERT INTO guild_config (guild_id) VALUES (%s)"
database.execute_query(query, (self.guild_id,))
# TODO Rename this here and in `fetch_or_create_config`
def _extracted_from_fetch_or_create_config_14(self, query):
(
birthday_channel_id,
command_channel_id,
intro_channel_id,
welcome_channel_id,
welcome_message,
boost_channel_id,
boost_message,
boost_image_url,
level_channel_id,
level_message,
level_message_type,
) = database.select_query(query, (self.guild_id,))[0]
self.birthday_channel_id = birthday_channel_id
self.command_channel_id = command_channel_id
self.intro_channel_id = intro_channel_id
self.welcome_channel_id = welcome_channel_id
self.welcome_message = welcome_message
self.boost_channel_id = boost_channel_id
self.boost_message = boost_message
self.boost_image_url = boost_image_url
self.level_channel_id = level_channel_id
self.level_message = level_message
self.level_message_type = level_message_type
def push(self):
query = """
UPDATE guild_config
SET
birthday_channel_id = %s,
command_channel_id = %s,
intro_channel_id = %s,
welcome_channel_id = %s,
welcome_message = %s,
boost_channel_id = %s,
boost_message = %s,
boost_image_url = %s,
level_channel_id = %s,
level_message = %s,
level_message_type = %s
WHERE guild_id = %s;
"""
database.execute_query(
query,
(
self.birthday_channel_id,
self.command_channel_id,
self.intro_channel_id,
self.welcome_channel_id,
self.welcome_message,
self.boost_channel_id,
self.boost_message,
self.boost_image_url,
self.level_channel_id,
self.level_message,
self.level_message_type,
self.guild_id,
),
)
@staticmethod
def get_prefix(message):
"""
Gets the prefix from a given guild.
This function is done as static method to make the prefix fetch process faster.
"""
query = """
SELECT prefix
FROM guild_config
WHERE guild_id = %s
"""
prefix = database.select_query_one(
query,
(message.guild.id if message.guild else None,),
)
return prefix or "."
@staticmethod
def get_prefix_from_guild_id(guild_id):
query = """
SELECT prefix
FROM guild_config
WHERE guild_id = %s
"""
return database.select_query_one(query, (guild_id,)) or "."
@staticmethod
def set_prefix(guild_id, prefix):
"""
Sets the prefix for a given guild.
"""
query = """
UPDATE guild_config
SET prefix = %s
WHERE guild_id = %s;
"""
database.execute_query(query, (prefix, guild_id))

View file

@ -1,92 +0,0 @@
import locale
from db import database
class Currency:
def __init__(self, user_id):
self.user_id = user_id
self.balance = Currency.fetch_or_create_balance(self.user_id)
def add_balance(self, amount):
self.balance += abs(amount)
def take_balance(self, amount):
self.balance -= abs(amount)
if self.balance < 0:
self.balance = 0
def push(self):
query = """
UPDATE currency
SET balance = %s
WHERE user_id = %s
"""
database.execute_query(query, (round(self.balance), self.user_id))
@staticmethod
def fetch_or_create_balance(user_id):
query = """
SELECT balance
FROM currency
WHERE user_id = %s
"""
try:
balance = database.select_query_one(query, (user_id,))
except (IndexError, TypeError):
balance = None
# if the user doesn't have a balance yet -> create one
# additionally if for some reason a balance becomes Null
# re-generate the user's balance as fallback.
if balance is None:
query = """
INSERT INTO currency (user_id, balance)
VALUES (%s, 50)
"""
database.execute_query(query, (user_id,))
return 50
return balance
@staticmethod
def load_leaderboard():
query = "SELECT user_id, balance FROM currency ORDER BY balance DESC"
data = database.select_query(query)
return [(row[0], row[1], rank) for rank, row in enumerate(data, start=1)]
@staticmethod
def format(num):
locale.setlocale(locale.LC_ALL, "en_US.UTF-8")
return locale.format_string("%d", num, grouping=True)
@staticmethod
def format_human(num):
num = float("{:.3g}".format(num))
magnitude = 0
while abs(num) >= 1000:
magnitude += 1
num /= 1000.0
return "{}{}".format(
"{:f}".format(num).rstrip("0").rstrip("."),
["", "K", "M", "B", "T", "Q", "Qi", "Sx", "Sp", "Oc", "No", "Dc"][
magnitude
],
)
# A Thousand = K
# Million = M
# Billion = B
# Trillion = T
# Quadrillion: Q
# Quintillion: Qi
# Sextillion: Sx
# Septillion: Sp
# Octillion: Oc
# Nonillion: No
# Decillion: Dc

View file

@ -1,117 +0,0 @@
from datetime import datetime, timedelta
from typing import List, Optional, Tuple
import pytz
from db import database
from lib.constants import CONST
from services.currency_service import Currency
class Dailies:
def __init__(self, user_id: int) -> None:
self.user_id: int = user_id
self.amount: int = 0
self.tz = pytz.timezone("US/Eastern")
self.time_now: datetime = datetime.now(tz=self.tz)
self.reset_time: datetime = self.time_now.replace(
hour=7,
minute=0,
second=0,
microsecond=0,
)
data: Tuple[Optional[str], int] = Dailies.get_data(user_id)
if data[0] is not None:
self.claimed_at: datetime = datetime.fromisoformat(data[0])
else:
# set date as yesterday to mock a valid claimed_at.
self.claimed_at: datetime = datetime.now(tz=self.tz) - timedelta(days=2)
self.streak: int = int(data[1])
def refresh(self) -> None:
if self.amount == 0:
self.amount = CONST.DAILY_REWARD
query: str = """
INSERT INTO dailies (user_id, amount, claimed_at, streak)
VALUES (%s, %s, %s, %s)
"""
values: Tuple[int, int, str, int] = (
self.user_id,
self.amount,
self.claimed_at.isoformat(),
self.streak,
)
database.execute_query(query, values)
cash = Currency(self.user_id)
cash.add_balance(self.amount)
cash.push()
def can_be_claimed(self) -> bool:
if self.claimed_at is None:
return True
if self.time_now < self.reset_time:
self.reset_time -= timedelta(days=1)
return self.claimed_at < self.reset_time <= self.time_now
def streak_check(self) -> bool:
"""
Three checks are performed, only one has to return True.
1. the daily was claimed yesterday
2. the daily was claimed the day before yesterday (users no longer lose their dailies as fast)
3. the daily was claimed today but before the reset time (see __init__)
:return:
"""
check_1: bool = (
self.claimed_at.date() == (self.time_now - timedelta(days=1)).date()
)
check_2: bool = (
self.claimed_at.date() == (self.time_now - timedelta(days=2)).date()
)
check_3: bool = (
self.claimed_at.date() == self.time_now.date()
and self.claimed_at < self.reset_time
)
return check_1 or check_2 or check_3
@staticmethod
def get_data(user_id: int) -> Tuple[Optional[str], int]:
query: str = """
SELECT claimed_at, streak
FROM dailies
WHERE id = (
SELECT MAX(id)
FROM dailies
WHERE user_id = %s
)
"""
try:
(claimed_at, streak) = database.select_query(query, (user_id,))[0]
except (IndexError, TypeError):
(claimed_at, streak) = None, 0
return claimed_at, streak
@staticmethod
def load_leaderboard() -> List[Tuple[int, int, str, int]]:
query: str = """
SELECT user_id, MAX(streak), claimed_at
FROM dailies
GROUP BY user_id
ORDER BY MAX(streak) DESC;
"""
data: List[Tuple[int, int, str]] = database.select_query(query)
leaderboard: List[Tuple[int, int, str, int]] = [
(row[0], row[1], row[2], rank) for rank, row in enumerate(data, start=1)
]
return leaderboard

View file

@ -1,128 +0,0 @@
import discord
from discord.ext import commands
from lib.constants import CONST
from lib.embed_builder import EmbedBuilder
from lib.exceptions.LumiExceptions import LumiException
class LumiHelp(commands.HelpCommand):
def __init__(self, **options):
super().__init__(**options)
self.verify_checks = True
self.command_attrs = {
"aliases": ["h"],
"help": "Show a list of commands, or information about a specific command when an argument is passed.",
"name": "help",
"hidden": True,
}
def get_command_qualified_name(self, command):
return f"`{self.context.clean_prefix}{command.qualified_name}`"
async def send_bot_help(self, mapping):
embed = EmbedBuilder.create_success_embed(
ctx=self.context,
author_text="Help Command",
show_name=False,
)
for cog, lumi_commands in mapping.items():
filtered = await self.filter_commands(lumi_commands, sort=True)
if command_signatures := [
self.get_command_qualified_name(c) for c in filtered
]:
# Remove duplicates using set() and convert back to a list
unique_command_signatures = list(set(command_signatures))
cog_name = getattr(cog, "qualified_name", "Help")
embed.add_field(
name=cog_name,
value=", ".join(sorted(unique_command_signatures)),
inline=False,
)
channel = self.get_destination()
await channel.send(embed=embed)
async def send_command_help(self, command):
embed = EmbedBuilder.create_success_embed(
ctx=self.context,
author_text=f"{self.context.clean_prefix}{command.qualified_name}",
description=command.help,
show_name=False,
)
usage_value = (
f"`{self.context.clean_prefix}{command.qualified_name} {command.signature}`"
)
for alias in command.aliases:
usage_value += f"\n`{self.context.clean_prefix}{alias} {command.signature}`"
embed.add_field(name="Usage", value=usage_value, inline=False)
channel = self.get_destination()
await channel.send(embed=embed)
async def send_error_message(self, error):
raise LumiException(error)
async def send_group_help(self, group):
raise LumiException(
CONST.STRINGS["error_command_not_found"].format(group.qualified_name),
)
async def send_cog_help(self, cog):
raise LumiException(
CONST.STRINGS["error_command_not_found"].format(cog.qualified_name),
)
async def command_callback(self, ctx, *, command=None):
await self.prepare_help_command(ctx, command)
bot = ctx.bot
if command is None:
mapping = self.get_bot_mapping()
return await self.send_bot_help(mapping)
# Check if it's a cog
cog = bot.get_cog(command)
if cog is not None:
return await self.send_cog_help(cog)
maybe_coro = discord.utils.maybe_coroutine # type: ignore
# If it's not a cog then it's a command.
# Since we want to have detailed errors when someone
# passes an invalid subcommand, we need to walk through
# the command group chain ourselves.
keys = command.split(" ")
cmd = bot.all_commands.get(keys[0].removeprefix(self.context.prefix))
if cmd is None:
string = await maybe_coro(
self.command_not_found,
self.remove_mentions(keys[0]),
)
return await self.send_error_message(string)
for key in keys[1:]:
try:
found = cmd.all_commands.get(key)
except AttributeError:
string = await maybe_coro(
self.subcommand_not_found,
cmd,
self.remove_mentions(key),
)
return await self.send_error_message(string)
else:
if found is None:
string = await maybe_coro(
self.subcommand_not_found,
cmd,
self.remove_mentions(key),
)
return await self.send_error_message(string)
cmd = found
return await self.send_command_help(cmd)

View file

@ -1,77 +0,0 @@
from loguru import logger
from db import database
from services import item_service
class Inventory:
def __init__(self, user_id):
self.user_id = user_id
def add_item(self, item: item_service.Item, quantity=1):
"""
Adds an item with a specific count (default 1) to the database, if there are
no records of this user having that item yet, it will just add a record with quantity=quantity.
:param item:
:param quantity:
:return:
"""
query = """
INSERT INTO inventory (user_id, item_id, quantity)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE quantity = quantity + %s;
"""
database.execute_query(
query,
(self.user_id, item.id, abs(quantity), abs(quantity)),
)
def take_item(self, item: item_service.Item, quantity=1):
query = """
INSERT INTO inventory (user_id, item_id, quantity)
VALUES (%s, %s, 0)
ON DUPLICATE KEY UPDATE quantity = CASE
WHEN quantity - %s < 0 THEN 0
ELSE quantity - %s
END;
"""
database.execute_query(
query,
(self.user_id, item.id, self.user_id, item.id, abs(quantity)),
)
def get_inventory(self):
query = "SELECT item_id, quantity FROM inventory WHERE user_id = %s AND quantity > 0"
results = database.select_query(query, (self.user_id,))
items_dict = {}
for row in results:
item_id, quantity = row
item = item_service.Item(item_id)
items_dict[item] = quantity
return items_dict
def get_item_quantity(self, item: item_service.Item):
query = "SELECT COALESCE(quantity, 0) FROM inventory WHERE user_id = %s AND item_id = %s"
return database.select_query_one(query, (self.user_id, item.id))
def get_sell_data(self):
query = """
SELECT item.display_name
FROM inventory
JOIN ShopItem ON inventory.item_id = ShopItem.item_id
JOIN item ON inventory.item_id = item.id
WHERE inventory.user_id = %s AND inventory.quantity > 0 AND ShopItem.worth > 0
"""
try:
results = database.select_query(query, (self.user_id,))
return [item[0] for item in results]
except Exception as e:
logger.error(e)
return []

View file

@ -1,69 +0,0 @@
import sqlite3
from loguru import logger
from db import database
class Item:
def __init__(self, item_id):
self.id = item_id
data = self.get_item_data()
self.name = data[0]
self.display_name = data[1]
self.description = data[2]
self.image_url = data[3]
self.emote_id = data[4]
self.quote = data[5]
self.type = data[6]
def get_item_data(self):
query = """
SELECT name, display_name, description, image_url, emote_id, quote, type
FROM item
WHERE id = %s
"""
return database.select_query(query, (self.id,))[0]
def get_quantity(self, author_id):
query = """
SELECT COALESCE((SELECT quantity FROM inventory WHERE user_id = %s AND item_id = %s), 0) AS quantity
"""
return database.select_query_one(query, (author_id, self.id))
def get_item_worth(self):
query = """
SELECT worth
FROM ShopItem
WHERE item_id = %s
"""
return database.select_query_one(query, (self.id,))
@staticmethod
def get_all_item_names():
query = "SELECT display_name FROM item"
try:
items = database.select_query(query)
return [item[0] for item in items]
except sqlite3.Error:
logger.error(sqlite3.Error)
return []
@staticmethod
def get_item_by_display_name(display_name):
query = "SELECT id FROM item WHERE display_name = %s"
item_id = database.select_query_one(query, (display_name,))
return Item(item_id)
@staticmethod
def get_item_by_name(name):
query = "SELECT id FROM item WHERE name = %s"
item_id = database.select_query_one(query, (name,))
return Item(item_id)

View file

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

View file

@ -1,32 +0,0 @@
from typing import Optional
from db.database import execute_query, select_query_one
class ModLogService:
def __init__(self):
pass
def set_modlog_channel(self, guild_id: int, channel_id: int) -> None:
query: str = """
INSERT INTO mod_log (guild_id, channel_id, is_enabled)
VALUES (%s, %s, TRUE)
ON DUPLICATE KEY UPDATE channel_id = VALUES(channel_id), is_enabled = TRUE, updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (guild_id, channel_id))
def disable_modlog_channel(self, guild_id: int) -> None:
query: str = """
UPDATE mod_log
SET is_enabled = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE guild_id = %s
"""
execute_query(query, (guild_id,))
def fetch_modlog_channel_id(self, guild_id: int) -> Optional[int]:
query: str = """
SELECT channel_id FROM mod_log
WHERE guild_id = %s AND is_enabled = TRUE
"""
result = select_query_one(query, (guild_id,))
return result or None

View file

@ -1,191 +0,0 @@
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from db import database
class CustomReactionsService:
def __init__(self):
pass
async def find_trigger(
self,
guild_id: int,
message_content: str,
) -> Optional[Dict[str, Any]]:
message_content = message_content.lower()
query = """
SELECT * FROM custom_reactions
WHERE (guild_id = %s OR is_global = TRUE) AND (
(is_full_match = TRUE AND trigger_text = %s) OR
(is_full_match = FALSE AND %s LIKE CONCAT('%%', trigger_text, '%%'))
)
ORDER BY guild_id = %s DESC, is_global ASC
LIMIT 1
"""
if result := database.select_query(
query,
(guild_id, message_content, message_content, guild_id),
):
reaction = result[0] # Get the first result from the list
return {
"id": reaction[0],
"trigger_text": reaction[1],
"response": reaction[2],
"emoji_id": reaction[3],
"is_emoji": reaction[4],
"is_full_match": reaction[5],
"is_global": reaction[6],
"guild_id": reaction[7],
"creator_id": reaction[8],
"usage_count": reaction[9],
"created_at": reaction[10],
"updated_at": reaction[11],
"type": "emoji" if reaction[4] else "text",
}
return None
async def find_id(self, reaction_id: int) -> Optional[Dict[str, Any]]:
query = """
SELECT * FROM custom_reactions
WHERE id = %s
LIMIT 1
"""
if result := database.select_query(query, (reaction_id,)):
reaction = result[0] # Get the first result from the list
return {
"id": reaction[0],
"trigger_text": reaction[1],
"response": reaction[2],
"emoji_id": reaction[3],
"is_emoji": reaction[4],
"is_full_match": reaction[5],
"is_global": reaction[6],
"guild_id": reaction[7],
"creator_id": reaction[8],
"usage_count": reaction[9],
"created_at": reaction[10],
"updated_at": reaction[11],
"type": "emoji" if reaction[4] else "text",
}
return None
async def find_all_by_guild(self, guild_id: int) -> List[Dict[str, Any]]:
query = """
SELECT * FROM custom_reactions
WHERE guild_id = %s
"""
results = database.select_query(query, (guild_id,))
return [
{
"id": reaction[0],
"trigger_text": reaction[1],
"response": reaction[2],
"emoji_id": reaction[3],
"is_emoji": reaction[4],
"is_full_match": reaction[5],
"is_global": reaction[6],
"guild_id": reaction[7],
"creator_id": reaction[8],
"usage_count": reaction[9],
"created_at": reaction[10],
"updated_at": reaction[11],
"type": "emoji" if reaction[4] else "text",
}
for reaction in results
]
async def create_custom_reaction(
self,
guild_id: int,
creator_id: int,
trigger_text: str,
response: Optional[str] = None,
emoji_id: Optional[int] = None,
is_emoji: bool = False,
is_full_match: bool = False,
is_global: bool = True,
) -> bool:
if await self.count_custom_reactions(guild_id) >= 100:
return False
query = """
INSERT INTO custom_reactions (trigger_text, response, emoji_id, is_emoji, is_full_match, is_global, guild_id, creator_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE trigger_text=trigger_text
"""
database.execute_query(
query,
(
trigger_text,
response,
emoji_id,
is_emoji,
is_full_match,
is_global,
guild_id,
creator_id,
),
)
return True
async def edit_custom_reaction(
self,
reaction_id: int,
new_response: Optional[str] = None,
new_emoji_id: Optional[int] = None,
is_emoji: Optional[bool] = None,
is_full_match: Optional[bool] = None,
is_global: Optional[bool] = None,
) -> bool:
query = """
UPDATE custom_reactions
SET response = COALESCE(%s, response),
emoji_id = COALESCE(%s, emoji_id),
is_emoji = COALESCE(%s, is_emoji),
is_full_match = COALESCE(%s, is_full_match),
is_global = COALESCE(%s, is_global),
updated_at = %s
WHERE id = %s
"""
database.execute_query(
query,
(
new_response,
new_emoji_id,
is_emoji,
is_full_match,
is_global,
datetime.now(timezone.utc),
reaction_id,
),
)
return True
async def delete_custom_reaction(self, reaction_id: int) -> bool:
query = """
DELETE FROM custom_reactions
WHERE id = %s
"""
database.execute_query(query, (reaction_id,))
return True
async def count_custom_reactions(self, guild_id: int) -> int:
query = """
SELECT COUNT(*) FROM custom_reactions
WHERE guild_id = %s
"""
count = database.select_query_one(query, (guild_id,))
return count if count else 0
async def increment_reaction_usage(self, reaction_id: int) -> bool:
query = """
UPDATE custom_reactions
SET usage_count = usage_count + 1
WHERE id = %s
"""
database.execute_query(
query,
(reaction_id,),
)
return True

View file

@ -1,139 +0,0 @@
import json
from db import database
class BlackJackStats:
def __init__(self, user_id, is_won, bet, payout, hand_player, hand_dealer):
self.user_id = user_id
self.is_won = is_won
self.bet = bet
self.payout = payout
self.hand_player = json.dumps(hand_player)
self.hand_dealer = json.dumps(hand_dealer)
def push(self):
query = """
INSERT INTO blackjack (user_id, is_won, bet, payout, hand_player, hand_dealer)
VALUES (%s, %s, %s, %s, %s, %s)
"""
values = (
self.user_id,
self.is_won,
self.bet,
self.payout,
self.hand_player,
self.hand_dealer,
)
database.execute_query(query, values)
@staticmethod
def get_user_stats(user_id):
query = """
SELECT
COUNT(*) AS amount_of_games,
SUM(bet) AS total_bet,
SUM(payout) AS total_payout,
SUM(CASE WHEN is_won = 1 THEN 1 ELSE 0 END) AS winning,
SUM(CASE WHEN is_won = 0 THEN 1 ELSE 0 END) AS losing
FROM blackjack
WHERE user_id = %s;
"""
(
amount_of_games,
total_bet,
total_payout,
winning_amount,
losing_amount,
) = database.select_query(query, (user_id,))[0]
return {
"amount_of_games": amount_of_games,
"total_bet": total_bet,
"total_payout": total_payout,
"winning_amount": winning_amount,
"losing_amount": losing_amount,
}
@staticmethod
def get_total_rows_count():
query = """
SELECT SUM(TABLE_ROWS)
FROM INFORMATION_SCHEMA.TABLES
"""
return database.select_query_one(query)
class SlotsStats:
"""
Handles statistics for the /slots command
"""
def __init__(self, user_id, is_won, bet, payout, spin_type, icons):
self.user_id = user_id
self.is_won = is_won
self.bet = bet
self.payout = payout
self.spin_type = spin_type
self.icons = json.dumps(icons)
def push(self):
"""
Insert the services from any given slots game into the database
"""
query = """
INSERT INTO slots (user_id, is_won, bet, payout, spin_type, icons)
VALUES (%s, %s, %s, %s, %s, %s)
"""
values = (
self.user_id,
self.is_won,
self.bet,
self.payout,
self.spin_type,
self.icons,
)
database.execute_query(query, values)
@staticmethod
def get_user_stats(user_id):
"""
Retrieve the Slots stats for a given user from the database.
"""
query = """
SELECT
COUNT(*) AS amount_of_games,
SUM(bet) AS total_bet,
SUM(payout) AS total_payout,
SUM(CASE WHEN spin_type = 'pair' AND is_won = 1 THEN 1 ELSE 0 END) AS games_won_pair,
SUM(CASE WHEN spin_type = 'three_of_a_kind' AND is_won = 1 THEN 1 ELSE 0 END) AS games_won_three_of_a_kind,
SUM(CASE WHEN spin_type = 'three_diamonds' AND is_won = 1 THEN 1 ELSE 0 END) AS games_won_three_diamonds,
SUM(CASE WHEN spin_type = 'jackpot' AND is_won = 1 THEN 1 ELSE 0 END) AS games_won_jackpot
FROM slots
WHERE user_id = %s
"""
(
amount_of_games,
total_bet,
total_payout,
games_won_pair,
games_won_three_of_a_kind,
games_won_three_diamonds,
games_won_jackpot,
) = database.select_query(query, (user_id,))[0]
return {
"amount_of_games": amount_of_games,
"total_bet": total_bet,
"total_payout": total_payout,
"games_won_pair": games_won_pair,
"games_won_three_of_a_kind": games_won_three_of_a_kind,
"games_won_three_diamonds": games_won_three_diamonds,
"games_won_jackpot": games_won_jackpot,
}

View file

@ -1,321 +0,0 @@
import datetime
import imghdr
import json
import random
from typing import Any
import httpx
class HttpError(Exception):
def __init__(self, status_code: int, reason: str) -> None:
"""
Initialize the HttpError.
Parameters
----------
status_code : int
The status code of the error.
reason : str
The reason of the error.
"""
self.status_code = status_code
self.reason = reason
super().__init__(f"HTTP Error {status_code}: {reason}")
class Comic:
"""
A class representing a xkcd comic.
"""
def __init__(
self,
xkcd_dict: dict[str, Any],
raw_image: bytes | None = None,
comic_url: str | None = None,
explanation_url: str | None = None,
) -> None:
self.id: int | None = xkcd_dict.get("num")
self.date: datetime.date | None = self._determine_date(xkcd_dict)
self.title: str | None = xkcd_dict.get("safe_title")
self.description: str | None = xkcd_dict.get("alt")
self.transcript: str | None = xkcd_dict.get("transcript")
self.image: bytes | None = raw_image
self.image_extension: str | None = self._determine_image_extension()
self.image_url: str | None = xkcd_dict.get("img")
self.comic_url: str | None = comic_url
self.explanation_url: str | None = explanation_url
@staticmethod
def _determine_date(xkcd_dict: dict[str, Any]) -> datetime.date | None:
"""
Determine the date of the comic.
Args:
xkcd_dict:
Returns:
"""
try:
return datetime.date(
int(xkcd_dict["year"]),
int(xkcd_dict["month"]),
int(xkcd_dict["day"]),
)
except (KeyError, ValueError):
return None
def _determine_image_extension(self) -> str | None:
"""
Determine the image extension of the comic.
Returns
-------
str | None
The extension of the image.
"""
return f".{imghdr.what(None, h=self.image)}" if self.image else None
def update_raw_image(self, raw_image: bytes) -> None:
"""
Update the raw image of the comic.
Parameters
----------
raw_image : bytes
The raw image data.
"""
self.image = raw_image
self.image_extension = self._determine_image_extension()
def __repr__(self) -> str:
"""
Return the representation of the comic.
Returns
-------
str
The representation of the comic.
"""
return f"Comic({self.title})"
class Client:
def __init__(
self,
api_url: str = "https://xkcd.com",
explanation_wiki_url: str = "https://www.explainxkcd.com/wiki/index.php/",
) -> None:
self._api_url = api_url
self._explanation_wiki_url = explanation_wiki_url
def latest_comic_url(self) -> str:
"""
Get the URL for the latest comic.
Returns
-------
str
The URL for the latest comic.
"""
return f"{self._api_url}/info.0.json"
def comic_id_url(self, comic_id: int) -> str:
"""
Get the URL for a specific comic ID.
Parameters
----------
comic_id : int
The ID of the comic.
Returns
-------
str
The URL for the specific comic ID.
"""
return f"{self._api_url}/{comic_id}/info.0.json"
def _parse_response(self, response_text: str) -> Comic:
"""
Parse the response text into a Comic object.
Parameters
----------
response_text : str
The response text to parse.
Returns
-------
Comic
The parsed comic object.
"""
response_dict: dict[str, Any] = json.loads(response_text)
comic_url: str = f"{self._api_url}/{response_dict['num']}/"
explanation_url: str = f"{self._explanation_wiki_url}{response_dict['num']}"
return Comic(
response_dict,
comic_url=comic_url,
explanation_url=explanation_url,
)
def _fetch_comic(self, comic_id: int, raw_comic_image: bool) -> Comic:
"""
Fetch a comic from the xkcd API.
Parameters
----------
comic_id : int
The ID of the comic to fetch.
raw_comic_image : bool
Whether to fetch the raw image data.
Returns
-------
Comic
The fetched comic.
"""
comic = self._parse_response(self._request_comic(comic_id))
if raw_comic_image:
raw_image = self._request_raw_image(comic.image_url)
comic.update_raw_image(raw_image)
return comic
def get_latest_comic(self, raw_comic_image: bool = False) -> Comic:
"""
Get the latest xkcd comic.
Parameters
----------
raw_comic_image : bool, optional
Whether to fetch the raw image data, by default False
Returns
-------
Comic
The latest xkcd comic.
"""
return self._fetch_comic(0, raw_comic_image)
def get_comic(self, comic_id: int, raw_comic_image: bool = False) -> Comic:
"""
Get a specific xkcd comic.
Parameters
----------
comic_id : int
The ID of the comic to fetch.
raw_comic_image : bool, optional
Whether to fetch the raw image data, by default False
Returns
-------
Comic
The fetched xkcd comic.
"""
return self._fetch_comic(comic_id, raw_comic_image)
def get_random_comic(self, raw_comic_image: bool = False) -> Comic:
"""
Get a random xkcd comic.
Parameters
----------
raw_comic_image : bool, optional
Whether to fetch the raw image data, by default False
Returns
-------
Comic
The random xkcd comic.
"""
latest_comic_id: int = self._parse_response(self._request_comic(0)).id or 0
random_id: int = random.randint(1, latest_comic_id)
return self._fetch_comic(random_id, raw_comic_image)
def _request_comic(self, comic_id: int) -> str:
"""
Request the comic data from the xkcd API.
Parameters
----------
comic_id : int
The ID of the comic to fetch.
Returns
-------
str
The response text.
Raises
------
HttpError
If the request fails.
"""
comic_url = (
self.latest_comic_url() if comic_id <= 0 else self.comic_id_url(comic_id)
)
try:
response = httpx.get(comic_url)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HttpError(
exc.response.status_code,
exc.response.reason_phrase,
) from exc
return response.text
@staticmethod
def _request_raw_image(raw_image_url: str | None) -> bytes:
"""
Request the raw image data from the xkcd API.
Parameters
----------
raw_image_url : str | None
The URL of the raw image data.
Returns
-------
bytes
The raw image data.
Raises
------
HttpError
If the request fails.
"""
if not raw_image_url:
raise HttpError(404, "Image URL not found")
try:
response = httpx.get(raw_image_url)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise HttpError(
exc.response.status_code,
exc.response.reason_phrase,
) from exc
return response.content
def __repr__(self) -> str:
"""
Return the representation of the client.
Returns
-------
str
The representation of the client.
"""
return "Client()"

View file

@ -1,295 +0,0 @@
import time
from typing import Callable, Dict, List, Optional, Tuple
from discord.ext import commands
from db import database
from lib.constants import CONST
class XpService:
"""
Manages XP for a user, including storing, retrieving, and updating XP in the database.
"""
def __init__(self, user_id: int, guild_id: int) -> None:
"""
Initializes the XpService with user and guild IDs, and fetches or creates XP data.
Args:
user_id (int): The ID of the user.
guild_id (int): The ID of the guild.
"""
self.user_id: int = user_id
self.guild_id: int = guild_id
self.xp: int = 0
self.level: int = 0
self.cooldown_time: Optional[float] = None
self.xp_gain: int = CONST.XP_GAIN_PER_MESSAGE
self.new_cooldown: int = CONST.XP_GAIN_COOLDOWN
self.fetch_or_create_xp()
def push(self) -> None:
"""
Updates the XP and cooldown for a user in the database.
"""
query: str = """
UPDATE xp
SET user_xp = %s, user_level = %s, cooldown = %s
WHERE user_id = %s AND guild_id = %s
"""
database.execute_query(
query,
(self.xp, self.level, self.cooldown_time, self.user_id, self.guild_id),
)
def fetch_or_create_xp(self) -> None:
"""
Retrieves a user's XP from the database or inserts a new row if it doesn't exist yet.
"""
query: str = "SELECT user_xp, user_level, cooldown FROM xp WHERE user_id = %s AND guild_id = %s"
try:
user_xp, user_level, cooldown = database.select_query(
query,
(self.user_id, self.guild_id),
)[0]
except (IndexError, TypeError):
user_xp, user_level, cooldown = 0, 0, None
if any(var is None for var in [user_xp, user_level, cooldown]):
query = """
INSERT INTO xp (user_id, guild_id, user_xp, user_level, cooldown)
VALUES (%s, %s, 0, 0, %s)
"""
database.execute_query(query, (self.user_id, self.guild_id, time.time()))
user_xp, user_level, cooldown = 0, 0, time.time()
self.xp = user_xp
self.level = user_level
self.cooldown_time = cooldown
def calculate_rank(self) -> Optional[int]:
"""
Determines the rank of a user in the guild based on their XP and level.
Returns:
Optional[int]: The rank of the user in the guild, or None if not found.
"""
query: str = """
SELECT user_id, user_xp, user_level
FROM xp
WHERE guild_id = %s
ORDER BY user_level DESC, user_xp DESC
"""
data: List[Tuple[int, int, int]] = database.select_query(
query,
(self.guild_id,),
)
leaderboard: List[Tuple[int, int, int, int]] = [
(row[0], row[1], row[2], rank) for rank, row in enumerate(data, start=1)
]
return next(
(entry[3] for entry in leaderboard if entry[0] == self.user_id),
None,
)
@staticmethod
def load_leaderboard(guild_id: int) -> List[Tuple[int, int, int, int]]:
"""
Retrieves the guild's XP leaderboard.
Args:
guild_id (int): The ID of the guild.
Returns:
List[Tuple[int, int, int, int]]: A list of tuples containing user_id, user_xp, user_level, and needed_xp_for_next_level.
"""
query: str = """
SELECT user_id, user_xp, user_level
FROM xp
WHERE guild_id = %s
ORDER BY user_level DESC, user_xp DESC
"""
data: List[Tuple[int, int, int]] = database.select_query(query, (guild_id,))
leaderboard: List[Tuple[int, int, int, int]] = []
for row in data:
row_user_id: int = row[0]
user_xp: int = row[1]
user_level: int = row[2]
needed_xp_for_next_level: int = XpService.xp_needed_for_next_level(
user_level,
)
leaderboard.append(
(row_user_id, user_xp, user_level, needed_xp_for_next_level),
)
return leaderboard
@staticmethod
def generate_progress_bar(
current_value: int,
target_value: int,
bar_length: int = 10,
) -> str:
"""
Generates an XP progress bar based on the current level and XP.
Args:
current_value (int): The current XP value.
target_value (int): The target XP value.
bar_length (int, optional): The length of the progress bar. Defaults to 10.
Returns:
str: The formatted progress bar.
"""
progress: float = current_value / target_value
filled_length: int = int(bar_length * progress)
empty_length: int = bar_length - filled_length
bar: str = "" * filled_length + "" * empty_length
return f"`{bar}` {current_value}/{target_value}"
@staticmethod
def xp_needed_for_next_level(current_level: int) -> int:
"""
Calculates the amount of XP needed to reach the next level, based on the current level.
Args:
current_level (int): The current level of the user.
Returns:
int: The amount of XP needed for the next level.
"""
formula_mapping: Dict[Tuple[int, int], Callable[[int], int]] = {
(10, 19): lambda level: 12 * level + 28,
(20, 29): lambda level: 15 * level + 29,
(30, 39): lambda level: 18 * level + 30,
(40, 49): lambda level: 21 * level + 31,
(50, 59): lambda level: 24 * level + 32,
(60, 69): lambda level: 27 * level + 33,
(70, 79): lambda level: 30 * level + 34,
(80, 89): lambda level: 33 * level + 35,
(90, 99): lambda level: 36 * level + 36,
}
return next(
(
formula(current_level)
for level_range, formula in formula_mapping.items()
if level_range[0] <= current_level <= level_range[1]
),
(
10 * current_level + 27
if current_level < 10
else 42 * current_level + 37
),
)
class XpRewardService:
"""
Manages XP rewards for a guild, including storing, retrieving, and updating rewards in the database.
"""
def __init__(self, guild_id: int) -> None:
"""
Initializes the XpRewardService with the guild ID and fetches rewards.
Args:
guild_id (int): The ID of the guild.
"""
self.guild_id: int = guild_id
self.rewards: Dict[int, Tuple[int, bool]] = self._fetch_rewards()
def _fetch_rewards(self) -> Dict[int, Tuple[int, bool]]:
"""
Retrieves the XP rewards for the guild from the database.
Returns:
Dict[int, Tuple[int, bool]]: A dictionary of rewards with levels as keys and (role_id, persistent) as values.
"""
query: str = """
SELECT level, role_id, persistent
FROM level_rewards
WHERE guild_id = %s
ORDER BY level DESC
"""
data: List[Tuple[int, int, bool]] = database.select_query(
query,
(self.guild_id,),
)
return {level: (role_id, persistent) for level, role_id, persistent in data}
def add_reward(self, level: int, role_id: int, persistent: bool) -> None:
"""
Adds a new XP reward for the guild.
Args:
level (int): The level at which the reward is given.
role_id (int): The ID of the role to be awarded.
persistent (bool): Whether the reward is persistent.
Raises:
commands.BadArgument: If the server has more than 25 XP rewards.
"""
if len(self.rewards) >= 25:
raise commands.BadArgument("A server can't have more than 25 XP rewards.")
query: str = """
INSERT INTO level_rewards (guild_id, level, role_id, persistent)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE role_id = %s, persistent = %s;
"""
database.execute_query(
query,
(self.guild_id, level, role_id, persistent, role_id, persistent),
)
self.rewards[level] = (role_id, persistent)
def remove_reward(self, level: int) -> None:
"""
Removes an XP reward for the guild.
Args:
level (int): The level at which the reward is to be removed.
"""
query: str = """
DELETE FROM level_rewards
WHERE guild_id = %s AND level = %s;
"""
database.execute_query(query, (self.guild_id, level))
self.rewards.pop(level, None)
def get_role(self, level: int) -> Optional[int]:
"""
Retrieves the role ID for a given level.
Args:
level (int): The level for which to retrieve the role ID.
Returns:
Optional[int]: The role ID if found, otherwise None.
"""
return self.rewards.get(level, (None,))[0]
def should_replace_previous_reward(self, level: int) -> Tuple[Optional[int], bool]:
"""
Checks if the previous reward should be replaced based on the given level.
Args:
level (int): The level to check for replacement.
Returns:
Tuple[Optional[int], bool]: A tuple containing the previous reward and a boolean indicating if it should be replaced.
"""
previous_reward, replace = None, False
if levels_below := [lvl for lvl in sorted(self.rewards) if lvl < level]:
highest_level_below = max(levels_below)
previous_reward, persistent = self.rewards[highest_level_below]
replace = not persistent
return previous_reward, replace

View file

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

View file

@ -1,79 +0,0 @@
{
"0-10": [
"Behold, you've reached **Level {}**. Let's all try not to yawn too loudly.",
"Congratulations on reaching **Level {}**. It's like leveling up, but without the fanfare.",
"Congrats on reaching **Level {}**, you're slowly but surely ascending the ladder of \"success\"...",
"Rejoice! You reached **Level {}**. It's time to throw a party with a side of meh.",
"You've reached **Level {}**, where the bar is set low and the excitement is mild.",
"Welcome to **Level {}**, the land of marginal achievements and faint praise.",
"It's time to celebrate! You've unlocked the 'Slightly Better Than Before' achievement at **Level {}**.",
"Congratulations on your promotion to **Level {}**. It's like climbing a tiny hill.",
"At **Level {}**, you're steadily inching closer to the realm of almost impressive.",
"You reached **Level {}**! Get ready for a ripple of apathetic applause.",
"Alert! You reached **Level {}**. Don't worry, it's not that exciting.",
"Rumor has it that reaching **Level {}** grants you the ability to mildly impress others.",
"You got to **Level {}**! Prepare for a slightly raised eyebrow of acknowledgement.",
"Congratulations on reaching **Level {}**. It's a modest achievement, to say the least.",
"Congratulations on **Level {}**! You must be SO proud of yourself. \uD83D\uDE44",
"You've reached **Level {}**! Your achievement is about as significant as a grain of sand.",
"Congratulations on your ascent to **Level {}**. It's a small step for mankind.",
"At **Level {}**, you're like a firework that fizzles out before it even begins.",
"Oh, you've reached **Level {}**? Maybe it's time to see the sunlight.",
"Wow, **Level {}**! How's the weather in your mom's basement?",
"You've reached **Level {}**. Now go reach for a job application.",
"Look at you, **Level {}**. You're really climbing that ladder to nowhere.",
"You've hit **Level {}**. Your keyboard must be thrilled.",
"Congrats on **Level {}**. Your social life, however, remains at Level 0.",
"You've reached **Level {}**. But remember, in the game of life, you're still a beginner.",
"You're now **Level {}**. I'd say 'get a life', but clearly, you've chosen Discord instead.",
"You've achieved **Level {}**. Achievement unlocked: Professional Procrastinator.",
"You're at **Level {}**. Do you also level up in avoiding responsibilities?",
"You've reached **Level {}**. If only leveling up in real life was this easy, huh?",
"You're now **Level {}**. If only your productivity levels matched your Lumi level."
],
"11-20": [
"Congratulations motherfucker you leveled the fuck up to **Level {}**.",
"levle **{}** cmoning in! Let's celbraet!",
"yay you reach the level **{}** waw you are so cool many time",
"reached **Level {}** but you'll never get on MY level HAAHAHAHAHA",
"*elevator music* Welcome to **level {}**."
],
"21-40": [
"**Level {}** 👍",
"Look who's slacking off work to level up on Discord. **Level {}** and counting!",
"**Level {}**? Have you considered that there might be an entire world outside of Discord?",
"Wow, you've climbed to **level {}**. Is Discord your full-time job now?",
"Oh look, it's **level {}**! Are you sure you're not secretly a bot in disguise?",
"You've reached **level {}**. I hope you're using your Discord powers for good and not just spamming memes.",
"**Level {}** and still going strong. Who needs a social life when you have Discord, right?",
"Congratulations on leveling up to **level {}**. I hope Discord gives you a lifetime supply of virtual cookies.",
"Look who's made it to **level {}**. I'm starting to think you're more Discord than human.",
"Wow, **level {}**! Do you ever wonder if Discord should be paying you a salary at this point?",
"Congratulations on reaching **level {}**. Your dedication to Discord is both awe-inspiring and mildly concerning.",
"**Level {}**? I bet you have more Discord badges than real-life achievements."
],
"41-60": [
"Well, well, well, **level {}**. Your Discord addiction is reaching legendary status.",
"**Level {}**. If you don't stop leveling up, I might have to stage an intervention. Discord addiction is real!",
"You've reached **Level {}**. Stop. Just stop. You've had enough of this app. Go away.",
"Oh, look who's flexing their **Level {}** status. Don't strain a muscle.",
"Congratulations on reaching **Level {}**. Are you trying to make the rest of us feel inadequate?",
"Hats off to **Level {}**. Your dedication is truly admirable... or slightly concerning.",
"Are you okay...? **Level {}** is seriously unhealthy bro. Sleep.",
"STOP. LEVELING. LEAVE. ME. ALONE. Here's your damn level: **{}**",
"HAS REACHED **LEVEL {}**, FUCK YEAH.",
"**Level {}**. The second-hand embarrassment is real."
],
"61-100000": [
"Lol it took you this long to reach **Level {}**.",
"**{}**.",
"**Level {}**???? Who are you? Gear?",
"Yay you reached **Level {}**!! :3 UwU \uD83E\uDD8B \uD83D\uDDA4 (nobody cares)",
"Conragulasions your level **{}** now.",
"Hey man congrats on reaching **Level {}**. I mean it. GG.",
"You reached **Level {}**!! What's it like being a loser?",
"**Level {}**. BIG IF TRUE.",
"CONGRATIONS LEVE **{}**",
"Hahahahahahahahahhahahahaahahah. **Level {}**."
]
}

View file

@ -1,296 +0,0 @@
{
"admin_award_description": "awarded **${0}** to {1}.",
"admin_award_title": "Awarded Currency",
"admin_blacklist_author": "User Blacklisted",
"admin_blacklist_description": "user `{0}` has been blacklisted from Luminara.",
"admin_blacklist_footer": "There is no process to reinstate a blacklisted user. Appeals are not considered.",
"admin_sql_inject_description": "```sql\n{0}\n```",
"admin_sql_inject_error_description": "```sql\n{0}\n```\n```\n{1}\n```",
"admin_sql_inject_error_title": "SQL Query Error",
"admin_sql_inject_title": "SQL Query Executed",
"admin_sql_select_description": "```sql\nSELECT {0}\n```\n```\n{1}\n```",
"admin_sql_select_error_description": "```sql\nSELECT {0}\n```\n```\n{1}\n```",
"admin_sql_select_error_title": "SQL Select Query Error",
"admin_sql_select_title": "SQL Select Query",
"admin_sync_description": "command tree synced successfully.",
"admin_sync_error_description": "An error occurred while syncing: {0}",
"admin_sync_error_title": "Sync Error",
"admin_sync_title": "Sync Successful",
"bet_limit": "❌ | **{0}** you cannot place any bets above **${1}**.",
"birthday_add_invalid_date": "The date you entered is invalid.",
"birthday_add_success_author": "Birthday Set",
"birthday_add_success_description": "your birthday has been set to **{0} {1}**.",
"birthday_check_error": "Birthday announcement skipped processing user/guild {0}/{1} | {2}",
"birthday_check_finished": "Daily birthday check finished. {0} birthdays processed. {1} birthdays failed.",
"birthday_check_skipped": "Birthday announcements in guild with ID {0} skipped: no birthday channel.",
"birthday_check_started": "Daily birthday check started.",
"birthday_check_success": "Birthday announcement Success! user/guild/chan ID: {0}/{1}/{2}",
"birthday_delete_success_author": "Birthday Deleted",
"birthday_delete_success_description": "your birthday has been deleted from this server.",
"birthday_leap_year": "February 29",
"birthday_upcoming_author": "Upcoming Birthdays!",
"birthday_upcoming_description_line": "🎂 {0} - {1}",
"birthday_upcoming_no_birthdays": "there are no upcoming birthdays in this server.",
"birthday_upcoming_no_birthdays_author": "No Upcoming Birthdays",
"blackjack_bet": "Bet ${0}",
"blackjack_busted": "Busted..",
"blackjack_dealer_busted": "The dealer busted. You won!",
"blackjack_dealer_hand": "**Dealer**\nScore: {0}\n*Hand: {1}*",
"blackjack_dealer_hidden": "??",
"blackjack_deck_shuffled": "deck shuffled",
"blackjack_description": "You | Score: {0}\nDealer | Score: {1}",
"blackjack_error": "I.. don't know if you won?",
"blackjack_error_description": "This is an error, please report it.",
"blackjack_footer": "Game finished",
"blackjack_lost": "You lost **${0}**.",
"blackjack_lost_generic": "You lost..",
"blackjack_player_hand": "**You**\nScore: {0}\n*Hand: {1}*",
"blackjack_title": "BlackJack",
"blackjack_won_21": "You won with a score of 21!",
"blackjack_won_natural": "You won with a natural hand!",
"blackjack_won_payout": "You won **${0}**.",
"blackjack_hit": "hit",
"blackjack_stand": "stand",
"boost_default_description": "Thanks for boosting, **{0}**!!",
"boost_default_title": "New Booster",
"case_case_field": "Case:",
"case_case_field_value": "`{0}`",
"case_duration_field": "Duration:",
"case_duration_field_value": "`{0}`",
"case_guild_cases_author": "All Cases in this Server",
"case_guild_no_cases": "this server doesn't have any mod cases yet.",
"case_guild_no_cases_author": "No Mod Cases",
"case_mod_cases_author": "Cases by Moderator ({0})",
"case_mod_no_cases": "this user has not handled any cases in this server.",
"case_mod_no_cases_author": "No Mod Cases",
"case_moderator_field": "Moderator:",
"case_moderator_field_value": "`{0}`",
"case_new_case_author": "New Case",
"case_reason_field": "Reason:",
"case_reason_field_value": "`{0}`",
"case_reason_update_author": "Case Reason Updated",
"case_reason_update_description": "case `{0}` reason has been updated.",
"case_target_field": "Target:",
"case_target_field_value": "`{0}` 🎯",
"case_type_field": "Type:",
"case_type_field_value": "`{0}`",
"case_type_field_value_with_duration": "`{0} ({1})`",
"config_author": "Server Configuration",
"config_birthday_channel_set": "birthday announcements will be sent in {0}.",
"config_birthday_module_already_disabled": "the birthday module was already disabled.",
"config_birthday_module_disabled": "the birthday module was successfully disabled.",
"config_boost_channel_set": "boost announcements will be sent in {0}.",
"config_boost_image_field": "New Image URL:",
"config_boost_image_original": "Original (default)",
"config_boost_image_updated": "the boost image has been updated.",
"config_boost_module_already_disabled": "the boost module was already disabled.",
"config_boost_module_disabled": "the boost module was successfully disabled.",
"config_boost_template_field": "New Template:",
"config_boost_template_updated": "the boost message template has been updated.",
"config_example_next_footer": "An example will be sent next.",
"config_level_channel_set": "all level announcements will be sent in {0}.",
"config_level_current_channel_set": "members will receive level announcements in their current channel.",
"config_level_module_already_enabled": "the Lumi XP system was already enabled.",
"config_level_module_disabled": "the Lumi XP system was successfully disabled.",
"config_level_module_disabled_warning": "Warning: this module is disabled, please do '/config levels enable'",
"config_level_module_enabled": "the Lumi XP system was successfully enabled.",
"config_level_template": "Template:",
"config_level_template_updated": "the level template was successfully updated.",
"config_level_type_example": "Example:",
"config_level_type_generic": "level announcements will be **generic messages**.",
"config_level_type_generic_example": "📈 | **lucas** you have reached **Level 15**.",
"config_level_type_whimsical": "level announcements will be **sarcastic comments**.",
"config_level_type_whimsical_example": "📈 | **lucas** Lol it took you this long to reach **Level 15**.",
"config_modlog_channel_set": "moderation logs will be sent in {0}.",
"config_modlog_info_author": "Moderation Log Configuration",
"config_modlog_info_commands_name": "📖 Case commands",
"config_modlog_info_commands_value": "`/cases` - View all cases in this server\n`/case <case_id>` - View a specific case\n`/editcase <case_id> <new_reason>` - Update a case reason",
"config_modlog_info_description": "This channel has been set as the moderation log channel for **{0}**. All moderation actions issued with Lumi will be logged here as cases.",
"config_modlog_info_warning_name": "⚠️ Warning",
"config_modlog_info_warning_value": "Changing the mod-log channel in the future will make old cases uneditable in this channel.",
"config_modlog_permission_error": "I don't have perms to send messages in that channel. Please fix & try again.",
"config_prefix_get": "the current prefix for this server is `{0}`",
"config_prefix_set": "the prefix has been set to `{0}`",
"config_prefix_too_long": "the prefix must be 25 characters or less.",
"config_show_author": "{0} Configuration",
"config_show_birthdays": "Birthdays",
"config_show_boost_announcements": "Boost announcements",
"config_show_default_enabled": "✅ Enabled (default)",
"config_show_disabled": "❌ Disabled",
"config_show_enabled": "✅ Enabled",
"config_show_guide": "Guide: {0}",
"config_show_level_announcements": "Level announcements",
"config_show_moderation_log": "Moderation Log",
"config_show_moderation_log_channel_deleted": "⚠️ **Not configured** (channel deleted?)",
"config_show_moderation_log_enabled": "✅ {0}",
"config_show_moderation_log_not_configured": "⚠️ **Not configured yet**",
"config_show_new_member_greets": "New member greets",
"config_welcome_channel_set": "I will announce new members in {0}.",
"config_welcome_module_already_disabled": "the greeting module was already disabled.",
"config_welcome_module_disabled": "the greeting module was successfully disabled.",
"config_welcome_template_field": "New Template:",
"config_welcome_template_updated": "the welcome message template has been updated.",
"daily_already_claimed_author": "Already Claimed",
"daily_already_claimed_description": "you can claim your daily reward again <t:{0}:R>.",
"daily_already_claimed_footer": "Daily reset is at 7 AM EST",
"daily_streak_footer": "You're on a streak of {0} days",
"daily_success_claim_author": "Reward Claimed",
"daily_success_claim_description": "you claimed your reward of **${0}**!",
"default_level_up_message": "**{0}** you have reached **Level {1}**.",
"error_actionable_hierarchy_bot": "I don't have permission to perform this action on this user due to role hierarchy.",
"error_actionable_hierarchy_user": "you don't have permission to perform this action on this user due to role hierarchy.",
"error_actionable_self": "you can't perform this action on yourself.",
"error_already_playing_blackjack": "you already have a game of blackjack running.",
"error_bad_argument_author": "Bad Argument",
"error_bad_argument_description": "{0}",
"error_birthdays_disabled_author": "Birthdays Disabled",
"error_birthdays_disabled_description": "birthdays are disabled in this server.",
"error_birthdays_disabled_footer": "Contact a mod to enable them.",
"error_blackjack_game_error": "something went wrong while playing blackjack.",
"error_boost_image_url_invalid": "the image URL must end with `.jpg` or `.png`.",
"error_bot_missing_permissions_author": "Bot Missing Permissions",
"error_bot_missing_permissions_description": "Lumi lacks the required permissions to run this command.",
"error_command_cooldown_author": "Command Cooldown",
"error_command_cooldown_description": "try again in **{0:02d}:{1:02d}**.",
"error_command_not_found": "No command called \"{0}\" found.",
"error_image_url_invalid": "invalid image URL.",
"error_invalid_bet": "the bet you entered is invalid.",
"error_invalid_duration": "Invalid duration: {0}",
"error_invalid_duration_author": "Invalid Duration",
"error_invalid_duration_description": "Please provide a valid duration between 1 minute and 30 days.",
"error_lumi_exception_author": "Lumi Exception",
"error_lumi_exception_description": "{0}",
"error_missing_permissions_author": "Missing Permissions",
"error_missing_permissions_description": "you lack the required permissions to run this command.",
"error_no_case_found_author": "Case Not Found",
"error_no_case_found_description": "no case found with that ID.",
"error_no_private_message_author": "Guild Only",
"error_no_private_message_description": "this command can only be used in servers.",
"error_not_enough_cash": "you don't have enough cash.",
"error_not_owner_author": "Owner Only",
"error_not_owner_description": "this command requires Lumi ownership permissions.",
"error_out_of_time": "you ran out of time.",
"error_out_of_time_economy": "you ran out of time. Your bet was forfeited.",
"error_private_message_only_author": "Private Message Only",
"error_private_message_only_description": "this command can only be used in private messages.",
"error_unknown_error_author": "Unknown Error",
"error_unknown_error_description": "an unknown error occurred. Please try again later.",
"greet_default_description": "_ _\n**Welcome** to **{0}**",
"greet_template_description": "↓↓↓\n{0}",
"help_use_prefix": "Please use Lumi's prefix to get help. Type `{0}help`",
"info_api_version": "**API:** v{0}\n",
"info_database_records": "**Database:** {0} records",
"info_latency": "**Latency:** {0}ms\n",
"info_memory": "**Memory:** {0:.2f} MB\n",
"info_service_footer": "Info Service",
"info_system": "**System:** {0} ({1})\n",
"info_uptime": "**Uptime:** <t:{0}:R>\n",
"intro_content": "Introduction by {0}",
"intro_content_footer": "Type .intro in my DMs to start",
"intro_no_channel": "the introduction channel is not set, please contact a moderator.",
"intro_no_channel_author": "Channel Not Set",
"intro_no_guild": "you're not in a server that supports introductions.",
"intro_no_guild_author": "Server Not Supported",
"intro_post_confirmation": "your introduction has been posted in {0}!",
"intro_preview_field": "**{0}:** {1}\n\n",
"intro_question_footer": "Type your answer below.",
"intro_service_name": "Introduction Service",
"intro_start": "this command will serve as a questionnaire for your entry to {0}. Please keep your answers \"PG-13\" and don't abuse this command.",
"intro_start_footer": "Click the button below to start",
"intro_stopped": "the introduction command was stopped.",
"intro_stopped_author": "Introduction Stopped",
"intro_timeout": "you took too long to answer the question, please try again.",
"intro_timeout_author": "Timeout",
"intro_too_long": "your answer was too long, please keep it below 200 characters.",
"intro_too_long_author": "Answer Too Long",
"invite_button_text": "Invite Lumi",
"invite_description": "Thanks for inviting me to your server!",
"level_up": "📈 | **{0}** you have reached **Level {1}**.",
"level_up_prefix": "📈 | **{0}** ",
"lumi_exception_blacklisted": "User is blacklisted",
"lumi_exception_generic": "An error occurred.",
"mod_ban_dm": "**{0}** you have been banned from `{1}`.\n\n**Reason:** `{2}`",
"mod_banned_author": "User Banned",
"mod_banned_user": "user with ID `{0}` has been banned.",
"mod_dm_not_sent": "Failed to notify them in DM",
"mod_dm_sent": "notified them in DM",
"mod_kick_dm": "**{0}** you have been kicked from `{1}`.\n\n**Reason:** `{2}`",
"mod_kicked_author": "User Kicked",
"mod_kicked_user": "user `{0}` has been kicked.",
"mod_no_reason": "No reason provided.",
"mod_not_banned": "user with ID `{0}` is not banned.",
"mod_not_banned_author": "User Not Banned",
"mod_not_timed_out": "user `{0}` is not timed out.",
"mod_not_timed_out_author": "User Not Timed Out",
"mod_reason": "Moderator: {0} | Reason: {1}",
"mod_softban_dm": "**{0}** you have been softbanned from `{1}`.\n\n**Reason:** `{2}`",
"mod_softban_unban_reason": "Softban by {0}",
"mod_softbanned_author": "User Softbanned",
"mod_softbanned_user": "user `{0}` has been softbanned.",
"mod_timed_out_author": "User Timed Out",
"mod_timed_out_user": "user `{0}` has been timed out.",
"mod_timeout_dm": "**{0}** you have been timed out in `{1}` for `{2}`.\n\n**Reason:** `{3}`",
"mod_unbanned": "user with ID `{0}` has been unbanned.",
"mod_unbanned_author": "User Unbanned",
"mod_untimed_out": "timeout has been removed for user `{0}`.",
"mod_untimed_out_author": "User Timeout Removed",
"mod_warn_dm": "**{0}** you have been warned in `{1}`.\n\n**Reason:** `{2}`",
"mod_warned_author": "User Warned",
"mod_warned_user": "user `{0}` has been warned.",
"ping_author": "I'm online!",
"ping_footer": "Latency: {0}ms",
"ping_pong": "Pong!",
"ping_uptime": "I've been online since <t:{0}:R>.",
"stats_blackjack": "🃏 | You've played **{0}** games of BlackJack, betting a total of **${1}**. You won **{2}** of those games with a total payout of **${3}**.",
"stats_slots": "🎰 | You've played **{0}** games of Slots, betting a total of **${1}**. Your total payout was **${2}**.",
"trigger_already_exists": "Failed to add custom reaction. This text already contains another trigger. To avoid unexpected behavior, please delete it before adding a new one.",
"trigger_limit_reached": "Failed to add custom reaction. You have reached the limit of 100 custom reactions for this server.",
"triggers_add_author": "Custom Reaction Created",
"triggers_add_description": "**Trigger Text:** `{0}`\n**Reaction Type:** {1}\n**Full Match:** `{2}`\n",
"triggers_add_emoji_details": "**Emoji ID:** `{0}`",
"triggers_add_text_details": "**Response:** `{0}`",
"triggers_delete_author": "Custom Reaction Deleted",
"triggers_delete_description": "custom reaction has been successfully deleted.",
"triggers_delete_not_found_author": "Custom Reaction Not Found",
"triggers_list_custom_reaction_id": "**ID:** {0}",
"triggers_list_custom_reactions_title": "Custom Reactions",
"triggers_list_emoji_id": "**Emoji ID:** `{0}`",
"triggers_list_full_match": "**Full Match:** `{0}`",
"triggers_list_reaction_type": "**Reaction Type:** {0}",
"triggers_list_response": "**Response:** `{0}`",
"triggers_list_trigger_text": "**Trigger Text:** `{0}`",
"triggers_list_usage_count": "**Usage Count:** `{0}`",
"triggers_no_reactions_description": "There are no custom reactions set up yet.\n\nTo create a new custom reaction, use the following commands:\n`/trigger add emoji` - Add a new custom emoji reaction.\n`/trigger add response` - Add a new custom text reaction.\n\n**Emoji Reaction:**\nAn emoji reaction will react with a specific emoji when the trigger text is detected.\n\n**Text Reaction:**\nA text reaction will respond with a specific text message when the trigger text is detected.",
"triggers_no_reactions_title": "No Custom Reactions Found",
"triggers_not_added": "failed to add custom reaction. Please try again.",
"triggers_not_deleted": "something went wrong while trying to delete this trigger.",
"triggers_not_found": "no custom reaction found with the provided ID.",
"triggers_reaction_service_footer": "Reaction Service",
"triggers_type_emoji": "Emoji",
"triggers_type_text": "Text",
"xkcd_description": "[Explainxkcd]({0}) | [Webpage]({1})",
"xkcd_footer": "Xkcd Service",
"xkcd_not_found": "failed to fetch this comic.",
"xkcd_not_found_author": "Comic Not Found",
"xkcd_title": "Xkcd {0} - {1}",
"xp_lb_author": "Level Leaderboard",
"xp_lb_cant_use_dropdown": "You can't use this menu, it's someone else's.",
"xp_lb_currency_author": "Currency Leaderboard",
"xp_lb_currency_field_value": "cash: **${0}**",
"xp_lb_dailies_author": "Daily Streak Leaderboard",
"xp_lb_dailies_field_value": "highest streak: **{0}**\nclaimed on: `{1}`",
"xp_lb_field_name": "#{0} - {1}",
"xp_lb_field_value": "level: **{0}**\nxp: `{1}/{2}`",
"xp_level": "Level {0}",
"xp_progress": "Progress to next level",
"xp_server_rank": "Server Rank: #{0}",
"balance_cash": "**Cash**: ${0}",
"balance_author": "{0}'s wallet",
"balance_footer": "check out /daily",
"give_error_self": "you can't give money to yourself.",
"give_error_bot": "you can't give money to a bot.",
"give_error_invalid_amount": "invalid amount.",
"give_error_insufficient_funds": "you don't have enough cash.",
"give_success": "**{0}** gave **${1}** to {2}.",
"error_cant_use_buttons": "You can't use these buttons, they're someone else's!"
}

View file

@ -1,103 +0,0 @@
---
info:
title: Luminara
author: wlinator
license: GNU General Public License v3.0
version: "2.9.0" # "Settings & Customizability" update
repository_url: https://git.wlinator.org/Luminara/Lumi
images:
allowed_image_extensions:
- .jpg
- .png
birthday_gif_url: https://media1.tenor.com/m/NXvU9jbBUGMAAAAC/fireworks.gif
colors:
color_default: 0xFF8C00
color_warning: 0xFF7600
color_error: 0xFF4500
economy:
daily_reward: 500
blackjack_multiplier: 1.4
blackjack_hit_emoji: <:hit:1119262723285467156>
blackjack_stand_emoji: <:stand:1118923298298929154>
slots_multipliers:
pair: 1.5
three_of_a_kind: 4
three_diamonds: 6
jackpot: 15
art:
fetch_url: https://git.wlinator.org/Luminara/Art/raw/branch/main/
logo:
opaque: lumi_logo.png
transparent: lumi_logo_transparent.png
icons:
boost: lumi_boost.png
check: lumi_check.png
cross: lumi_cross.png
exclaim: lumi_exclaim.png
hammer: lumi_hammer.png
money_bag: lumi_money_bag.png
money_coins: lumi_money_coins.png
question: lumi_question.png
streak: lumi_streak.png
streak_bronze: lumi_streak_bronze.png
streak_gold: lumi_streak_gold.png
streak_silver: lumi_streak_silver.png
warning: lumi_warning.png
juicybblue:
flowers: https://i.imgur.com/79XfsbS.png
teapot: https://i.imgur.com/wFsgSnr.png
muffin: https://i.imgur.com/hSauh7K.png
other:
cloud: https://i.imgur.com/rc68c43.png
trophy: https://i.imgur.com/dvIIr2G.png
emotes:
guild_id: 1038051105642401812
emote_ids:
slots_animated_id: 1119262805309259776
slots_0_id: 1119262803816095825
slots_1_id: 1119262801261760592
slots_2_id: 1119262800049614939
slots_3_id: 1119262796497039510
slots_4_id: 1119262794676715681
slots_5_id: 1119262792386621555
slots_6_id: 1119262791061229669
S_Wide: 1119286730302955651
L_Wide: 1119286763802857533
O_Wide: 1119286787169329203
T_Wide: 1119286804634406942
CBorderBLeft: 1119286973572595712
CBorderBRight: 1119286918459445408
CBorderTLeft: 1119287006464331806
CBorderTRight: 1119286865284051035
HBorderB: 1119286936155213835
HBorderT: 1119287027662344322
VBorder: 1119286889854279680
WSmall: 1119288536282173490
ISmall: 1119288552673517608
NSmall: 1119288579382857830
LCentered: 1119287296127156325
OCentered: 1119287563245584394
SCentered: 1119287327588634647
ECentered: 1119287343833165945
Blank: 1119287267001905283
lost: 1119288454212243607
introductions:
intro_guild_id: 719227135151046700
intro_channel_id: 973619250507972600
intro_question_mapping:
Nickname: How would you like to be identified in the server?
Age: How old are you?
Region: Where do you live?
Languages: Which languages do you speak?
Pronouns: What are your preferred pronouns?
Sexuality: What's your sexuality?
Relationship status: What's your current relationship status?
Likes & interests: Likes & interests
Dislikes: Dislikes
EXTRAS: "EXTRAS: job status, zodiac sign, hobbies, etc. Tell us about yourself!"