Upload 2 Cogs & Update README
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run

This commit is contained in:
Valerie 2025-05-23 01:30:53 -04:00
parent 8a7bce5995
commit 477974d53c
272 changed files with 50764 additions and 3 deletions

View file

@ -1,7 +1,7 @@
# Ruby Cogs
## This repository is being specifically built for our fork of Red-DiscordBot
- This will contain every cog we use on the instance, and modified accordingly for our use.
## This repository is being specifically gathered and modified for our instance of Red-DiscordBot
- This will contain every cog we use on our instance called 'Ruby', and modified accordingly for our use.
Credits:
[AAA3A Cogs](https://github.com/AAA3A-AAA3A/AAA3A-cogs)
@ -28,4 +28,5 @@ Credits:
[Vex Cogs](https://github.com/Vexed01/Vex-Cogs)
[x26 Cogs](https://github.com/Twentysix26/x26-Cogs)
[Toxic Cogs](https://github.com/NeuroAssassin/Toxic-Cogs)
[Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs)
[Karlo Cogs](https://github.com/karlsbjorn/karlo-cogs)
[Mister-42 Cogs](https://github.com/Mister-42/mr42-cogs)

148
appeals/README.md Normal file
View file

@ -0,0 +1,148 @@
Turn a secondary Discord into a ban appeal server
# [p]appealsfor
Get all appeal submissions for a specific user<br/>
- Usage: `[p]appealsfor <user>`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
# [p]viewappeal
View an appeal submission by ID<br/>
- Usage: `[p]viewappeal <submission_id>`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
# [p]appeal
Configure appeal server settings<br/>
- Usage: `[p]appeal`
- Restricted to: `ADMIN`
- Aliases: `appeals and appealset`
- Checks: `server_only`
## [p]appeal view
View the current appeal server settings<br/>
- Usage: `[p]appeal view`
- Checks: `ensure_db_connection`
## [p]appeal nukedb
Nuke the entire appeal database<br/>
- Usage: `[p]appeal nukedb <confirm>`
- Restricted to: `BOT_OWNER`
- Checks: `ensure_db_connection`
## [p]appeal addquestion
Add a question to the appeal form<br/>
- Usage: `[p]appeal addquestion <question>`
- Checks: `ensure_db_connection`
## [p]appeal server
Set the server ID where users will be unbanned from<br/>
**NOTES**<br/>
- This is the first step to setting up the appeal system<br/>
- This server will be the appeal server<br/>
- You must be the owner of the target server<br/>
- Usage: `[p]appeal server <server_id>`
- Restricted to: `GUILD_OWNER`
- Checks: `ensure_db_connection`
## [p]appeal editquestion
Edit a question in the appeal form<br/>
- Usage: `[p]appeal editquestion <question_id> <question>`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal wipeappeals
Wipe all appeal submissions<br/>
- Usage: `[p]appeal wipeappeals <confirm>`
- Checks: `ensure_db_connection`
## [p]appeal removequestion
Remove a question from the appeal form<br/>
- Usage: `[p]appeal removequestion <question_id>`
- Checks: `ensure_db_connection`
## [p]appeal refresh
Refresh the appeal message with the current appeal form<br/>
- Usage: `[p]appeal refresh`
- Checks: `ensure_db_connection`
## [p]appeal channel
Set the channel where submitted appeals will go<br/>
`channel_type` must be one of: pending, approved, denied<br/>
**NOTE**: All 3 channel types must be set for the appeal system to work properly.<br/>
- Usage: `[p]appeal channel <channel_type> <channel>`
- Checks: `ensure_db_connection`
## [p]appeal sortorder
Set the sort order for a question in the appeal form<br/>
- Usage: `[p]appeal sortorder <question_id> <sort_order>`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal approve
Approve an appeal submission by ID<br/>
- Usage: `[p]appeal approve <submission_id>`
- Checks: `ensure_db_connection`
## [p]appeal questions
Menu to view questions in the appeal form<br/>
- Usage: `[p]appeal questions`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal buttonstyle
Set the style of the appeal button<br/>
- Usage: `[p]appeal buttonstyle <style>`
- Checks: `ensure_db_connection`
## [p]appeal createappealmessage
Quickly create and set a pre-baked appeal message in the specified channel<br/>
- Usage: `[p]appeal createappealmessage <channel>`
- Checks: `ensure_db_connection`
## [p]appeal alertchannel
Set the channel ID where alerts for new appeals will be sent<br/>
This can be in either the appeal server or the target server.<br/>
Alert roles will not be pinged in this message.<br/>
- Usage: `[p]appeal alertchannel [channel]`
- Checks: `ensure_db_connection`
## [p]appeal help
How to set up the appeal system<br/>
- Usage: `[p]appeal help`
- Aliases: `info and setup`
- Checks: `ensure_db_connection`
## [p]appeal deny
Deny an appeal submission by ID<br/>
- Usage: `[p]appeal deny <submission_id>`
- Checks: `ensure_db_connection`
## [p]appeal questiondetails
Set specific data for a question in the appeal form<br/>
**Arguments**<br/>
- `required`: Whether the question is required or not<br/>
- `modal_style`: The style of the modal for the question<br/>
- `long`: The modal will be a long text input<br/>
- `short`: The modal will be a short text input<br/>
- `button_style`: The color of the button for the question<br/>
- `primary🔵`, `secondary⚫`, `success🟢`, `danger🔴`<br/>
- `placeholder`: The placeholder text for the input<br/>
- `default`: The default value for the input<br/>
- `max_length`: The maximum length for the input<br/>
- `min_length`: The minimum length for the input<br/>
- Usage: `[p]appeal questiondetails <question_id> <required> [modal_style=None] [button_style=None] [placeholder=None] [default=None] [max_length=None] [min_length=None]`
- Aliases: `questiondata, setquestiondata, qd, and details`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal listquestions
List all questions in the appeal form<br/>
Questions will be sorted by their sort order and then by creation date.<br/>
- Usage: `[p]appeal listquestions`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal buttonlabel
Set the label of the appeal button<br/>
- Usage: `[p]appeal buttonlabel <label>`
- Checks: `ensure_db_connection`
## [p]appeal appealmessage
Set the message where users will appeal from<br/>
Message format: `channelID-messageID`<br/>
- Usage: `[p]appeal appealmessage <message>`
- Checks: `ensure_db_connection`
## [p]appeal alertrole
Add/Remove roles to be pinged when a new appeal is submitted<br/>
These roles will be pinged in the appeal server, NOT the target server.<br/>
- Usage: `[p]appeal alertrole <role>`
- Checks: `ensure_db_connection`
## [p]appeal delete
Delete an appeal submission by ID<br/>
- Usage: `[p]appeal delete <submission_id>`
- Checks: `ensure_db_connection`
## [p]appeal viewquestion
View a question in the appeal form<br/>
- Usage: `[p]appeal viewquestion <question_id>`
- Checks: `ensure_appeal_system_ready and ensure_db_connection`
## [p]appeal buttonemoji
Set the emoji of the appeal button<br/>
- Usage: `[p]appeal buttonemoji [emoji=None]`
- Checks: `ensure_db_connection`

11
appeals/__init__.py Normal file
View file

@ -0,0 +1,11 @@
from redbot.core.bot import Red
from redbot.core.utils import get_end_user_data_statement
from .main import Appeals
__red_end_user_data_statement__ = get_end_user_data_statement(__file__)
async def setup(bot: Red):
cog = Appeals(bot)
await bot.add_cog(cog)

30
appeals/abc.py Normal file
View file

@ -0,0 +1,30 @@
import typing as t
from abc import ABC, ABCMeta, abstractmethod
import discord
from discord.ext.commands.cog import CogMeta
from piccolo.engine.sqlite import SQLiteEngine
from redbot.core.bot import Red
from .db.utils import DBUtils
class CompositeMetaClass(CogMeta, ABCMeta):
"""Type detection"""
class MixinMeta(ABC):
"""Type hinting"""
def __init__(self, *_args):
self.bot: Red
self.db: SQLiteEngine | None
self.db_utils: DBUtils
@abstractmethod
async def initialize(self) -> None:
raise NotImplementedError
@abstractmethod
async def conditions_met(self, guild: discord.Guild) -> t.Tuple[bool, t.Optional[str]]:
raise NotImplementedError

89
appeals/bugreport.py Normal file
View file

@ -0,0 +1,89 @@
import asyncio
from pathlib import Path
import aiosqlite
from piccolo.columns import BigInt, Integer, Numeric, Serial
from piccolo.engine import sqlite
from piccolo.engine.sqlite import SQLiteEngine, decode_to_string
from piccolo.table import Table
@decode_to_string
def convert_int_out_override(value: str) -> int:
return int(value)
setattr(sqlite, "convert_int_out", convert_int_out_override)
root = Path(__file__).parent
db_path = root / "db.sqlite"
if db_path.exists():
db_path.unlink()
DB = SQLiteEngine(path=str(db_path))
class SomeTable(Table, db=DB):
id: Serial
# Discord Guild ID
num1 = BigInt()
num2 = Integer()
num3 = Numeric()
async def column_bug():
# Create the table
await SomeTable.create_table()
guild_id = 625757527765811240
# Insert a row
guild = SomeTable(num1=guild_id, num2=guild_id, num3=guild_id)
await guild.save()
res = await SomeTable.select().first()
print("PICCOLO")
print("num1", res["num1"], type(res["num1"]))
print("num2", res["num2"], type(res["num2"]))
print("num3", res["num3"], type(res["num3"]))
await SomeTable.delete(force=True)
print("---")
print("aiosqlite")
# Now insert a row manually using aiosqlite
async with aiosqlite.connect(db_path) as conn:
await conn.execute(
"INSERT INTO some_table (num1, num2, num3) VALUES (?, ?, ?)",
(guild_id, guild_id, guild_id),
)
await conn.commit()
async with conn.execute("SELECT * FROM some_table") as cursor:
res = await cursor.fetchone()
print("num1", res[1], type(res[1]))
print("num2", res[2], type(res[2]))
print("num3", res[3], type(res[3]))
async def update_bug():
await SomeTable.create_table()
number = 123
guild = SomeTable(num1=number, num2=number, num3=number)
await guild.save()
res = await SomeTable.select().first()
print("BEFORE")
print("num1", res["num1"])
print("num2", res["num2"], type(res["num2"]))
print("num3", res["num3"])
print("---")
obj = await SomeTable.objects().get(SomeTable.num1 == number)
obj.num2 = 456
await obj.save([SomeTable.num2])
print("AFTER")
res = await SomeTable.select().first()
print("num1", res["num1"])
print("num2", res["num2"], type(res["num2"]))
print("num3", res["num3"])
if __name__ == "__main__":
asyncio.run(column_bug())
# asyncio.run(update_bug())

22
appeals/build.py Normal file
View file

@ -0,0 +1,22 @@
import asyncio
from pathlib import Path
from engine import engine
root = Path(__file__).parent
async def main():
try:
desc = input("Enter a description for the migration: ")
res = await engine.create_migrations(root, True, desc)
if "The command failed." in res:
raise Exception(res)
print(res)
except Exception as e:
print(f"Error: {e}")
print(await engine.diagnose_issues(root))
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,6 @@
from ..abc import CompositeMetaClass
from .admin import Admin
class Commands(Admin, metaclass=CompositeMetaClass):
"""Subclass all command classes"""

917
appeals/commands/admin.py Normal file
View file

@ -0,0 +1,917 @@
import logging
import typing as t
from copy import deepcopy
import discord
from redbot.core import commands
from redbot.core.utils.chat_formatting import box, pagify
from ..abc import MixinMeta
from ..common.checks import ensure_db_connection
from ..db.tables import AppealGuild, AppealQuestion, AppealSubmission
from ..views.appeal import AppealView
from ..views.dynamic_menu import DynamicMenu
log = logging.getLogger("red.vrt.appeals.commands.admin")
class MessageParser:
def __init__(self, argument):
if "-" not in argument:
raise commands.BadArgument("Invalid format, must be `channelID-messageID`")
try:
cid, mid = [i.strip() for i in argument.split("-")]
except ValueError:
raise commands.BadArgument("Invalid format, must be `channelID-messageID`")
try:
self.channel_id = int(cid)
except ValueError:
raise commands.BadArgument("Channel ID must be an integer")
try:
self.message_id = int(mid)
except ValueError:
raise commands.BadArgument("Message ID must be an integer")
class Admin(MixinMeta):
async def no_appealguild(self, ctx: commands.Context):
txt = (
"This server hasn't been set up for the appeal system yet!\n"
f"Type `{ctx.clean_prefix}appeal help` to get started."
)
return await ctx.send(txt)
async def appeal_guild_check(self, ctx: commands.Context):
if not await AppealGuild.exists().where(AppealGuild.id == ctx.guild.id):
txt = (
"This server hasn't been set up for the appeal system yet!\n"
f"Type `{ctx.clean_prefix}appeal help` to get started."
)
await ctx.send(txt)
return False
return True
@ensure_db_connection()
@commands.command(name="appealsfor")
@commands.bot_has_permissions(embed_links=True)
@commands.admin_or_can_manage_channel()
async def get_appeal_submissions(self, ctx: commands.Context, user: discord.User | discord.Member | int):
"""Get all appeal submissions for a specific user"""
if not await self.appeal_guild_check(ctx):
return
if isinstance(user, int):
user = self.bot.get_user(user)
user_id = user.id if isinstance(user, discord.User) else user
submissions = await AppealSubmission.objects().where(
(AppealSubmission.user_id == user_id) & (AppealSubmission.guild == ctx.guild.id)
)
if not submissions:
return await ctx.send("No submissions found for that user.")
pages = [submission.embed(user) for submission in submissions]
await DynamicMenu(ctx, pages).refresh()
@ensure_db_connection()
@commands.command(name="viewappeal")
@commands.bot_has_permissions(embed_links=True)
async def view_appeal_submission(self, ctx: commands.Context, submission_id: int):
"""View an appeal submission by ID"""
if not await self.appeal_guild_check(ctx):
return
submission = await AppealSubmission.objects().get(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
if not submission:
return await ctx.send("No submission found with that ID.")
member = ctx.guild.get_member(submission.user_id) or self.bot.get_user(submission.user_id)
embed = submission.embed(member)
await ctx.send(embed=embed)
@ensure_db_connection()
@commands.command(name="viewappeals")
@commands.bot_has_permissions(embed_links=True)
async def view_appeal_submissions(self, ctx: commands.Context):
"""View all appeal submissions in the server"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
submissions = await AppealSubmission.objects().where(AppealSubmission.guild == ctx.guild.id)
if not submissions:
return await ctx.send("No submissions found in this server.")
pages = []
page_count = len(submissions)
for idx, submission in enumerate(submissions):
member = ctx.guild.get_member(submission.user_id) or self.bot.get_user(submission.user_id)
embed = submission.embed(member)
page = f"Page {idx + 1}/{page_count}"
foot = embed.footer.text + f"\n{page}" # type: ignore
embed.set_footer(text=foot)
current_channel = getattr(appealguild, f"{submission.status}_channel")
jump_url = f"https://discord.com/channels/{ctx.guild.id}/{current_channel}/{submission.message_id}"
embed.add_field(name="Message", value=jump_url)
pages.append(embed)
await DynamicMenu(ctx, pages).refresh()
@commands.group(name="appeal", aliases=["appeals", "appealset"])
@commands.guild_only()
@commands.admin_or_permissions(administrator=True)
async def appealset(self, ctx: commands.Context):
"""Configure appeal server settings"""
@ensure_db_connection()
@appealset.command(name="gethelp", aliases=["info", "setup"])
@commands.bot_has_permissions(embed_links=True)
async def appeal_help(self, ctx: commands.Context):
"""How to set up the appeal system"""
p = ctx.clean_prefix
desc = (
f"**Step 1**: Set the target server to unban users from using `{p}appeal server <server_id>`\n"
f"**Step 2**: Set the channels for appeals using `{p}appeal channel <pending/approved/denied> <channel>`\n"
f"**Step 3**: Create a question for the appeal form using `{p}appeal addquestion <question>`\n"
f"**Step 4**: Quickly create an appeal message button for users using `{p}appeal createappealmessage <channel>`\n"
"This is the bare minimum to get the appeal system working.\n"
"### Additional Commands\n"
f"• Add/Remove alert roles using `{p}appeal alertroles <role>`\n"
f"• Set an alert channel using `{p}appeal alertchannel <channel>`, this can be in either the appeal or target server.\n"
f"• You can set an existing appeal message manually using `{p}appeal appealmessage <channelID-messageID>`\n"
)
embed = discord.Embed(
title="Appeal System Setup",
description=desc,
color=await self.bot.get_embed_color(ctx),
)
await ctx.send(embed=embed)
@ensure_db_connection()
@appealset.command(name="view")
@commands.bot_has_permissions(embed_links=True)
async def view_appeal_settings(self, ctx: commands.Context):
"""View the current appeal server settings"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if target := appealguild.target_guild_id:
target_guild = self.bot.get_guild(target)
if target_guild:
target_guild_name = target_guild.name
else:
target_guild_name = f"Not Found - `{target}`"
else:
target_guild_name = "Not Set"
def cname(cid: int):
if cid:
channel = self.bot.get_channel(cid)
if channel:
return channel.mention
else:
return f"Not Found - `{cid}`"
return "Not Set"
roles = [f"<@&{r}>" for r in appealguild.alert_roles]
if appealguild.appeal_channel and appealguild.appeal_message:
appeal_msg = (
f"https://discord.com/channels/{ctx.guild.id}/{appealguild.appeal_channel}/{appealguild.appeal_message}"
)
else:
appeal_msg = "Not set"
desc = (
f"**Target Server**: {target_guild_name}\n"
f"**Pending Channel**: {cname(appealguild.pending_channel)}\n"
f"**Approved Channel**: {cname(appealguild.approved_channel)}\n"
f"**Denied Channel**: {cname(appealguild.denied_channel)}\n"
f"**Appeal Message**: {appeal_msg}\n"
f"**Alert Channel**: {cname(appealguild.alert_channel)}\n"
f"**Appeal Limit**: {appealguild.appeal_limit}\n"
f"**Alert Roles**: {', '.join([r.mention for r in roles]) if roles else 'None set'}\n"
f"**Questions**: {await AppealQuestion.count().where(AppealQuestion.guild == ctx.guild.id)}"
)
embed = discord.Embed(
title="Appeal Server Settings",
description=desc,
color=await self.bot.get_embed_color(ctx),
)
await ctx.send(embed=embed)
@ensure_db_connection()
@appealset.command(name="nukedb")
@commands.is_owner()
async def nuke_appeal_db(self, ctx: commands.Context, confirm: bool):
"""Nuke the entire appeal database"""
if not confirm:
return await ctx.send("You must confirm this action by passing `True` as an argument.")
await AppealQuestion.delete(force=True)
await AppealSubmission.delete(force=True)
await AppealGuild.delete(force=True)
await ctx.send("Successfully nuked the appeal database.")
@ensure_db_connection()
@appealset.command(name="limit")
async def appeal_limit(self, ctx: commands.Context, limit: commands.positive_int):
"""Set the maximum number of appeals a user can submit"""
if limit < 1:
return await ctx.send("Appeal limit must be at least 1")
await AppealGuild.update({AppealGuild.appeal_limit: limit}).where(AppealGuild.id == ctx.guild.id)
await ctx.send(f"Successfully set the appeal limit to {limit}")
@ensure_db_connection()
@appealset.command(name="wipeappeals")
async def wipe_appeals(self, ctx: commands.Context, confirm: bool):
"""Wipe all appeal submissions"""
if not confirm:
return await ctx.send("You must confirm this action by passing `True` as an argument.")
await AppealSubmission.delete().where(AppealSubmission.guild == ctx.guild.id)
await ctx.send("Successfully wiped all appeal submissions.")
@ensure_db_connection()
@appealset.command(name="approve")
async def approve_appeal(self, ctx: commands.Context, submission_id: int, *, reason: str = None):
"""Approve an appeal submission by ID"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
submission = await AppealSubmission.objects().get(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
if not submission:
return await ctx.send("No submission found with that ID.")
if submission.status == "approved":
return await ctx.send("This submission has already been approved.")
elif submission.status == "denied":
return await ctx.send("This submission has already been denied.")
update_kwargs = {AppealSubmission.status: "approved"}
if reason:
update_kwargs[AppealSubmission.reason] = reason
await AppealSubmission.update(update_kwargs).where(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
submission.status = "approved"
submission.reason = reason or ""
await ctx.send(f"Successfully approved submission ID: {submission_id}")
member = ctx.guild.get_member(submission.user_id)
if not member:
member = await self.bot.get_or_fetch_user(submission.user_id)
# Send the submission to the approved channel and then delete from the pending channel
approved_channel = ctx.guild.get_channel(appealguild.approved_channel)
if approved_channel:
new_message = await approved_channel.send(embed=submission.embed(member))
await AppealSubmission.update({AppealSubmission.message_id: new_message.id}).where(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
pending_channel = ctx.guild.get_channel(appealguild.pending_channel)
if pending_channel:
if not pending_channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send(f"I do not have permissions to delete messages from {pending_channel.mention}")
else:
try:
message = await pending_channel.fetch_message(submission.message_id)
if message.thread:
await message.thread.delete()
await message.delete()
except discord.NotFound:
await ctx.send(f"Submission message not found in {pending_channel.mention}")
else:
await ctx.send("Pending channel not found, could not delete the message.")
# Now unban them from the target guild
target_guild = self.bot.get_guild(appealguild.target_guild_id)
if not target_guild:
return await ctx.send("Target guild not found! I can't unban the user.")
if member.id in [m.id for m in target_guild.members]:
return await ctx.send("User is already in the target guild!")
try:
await target_guild.fetch_ban(member)
try:
await target_guild.unban(member, reason=reason or "Appeal approved")
await ctx.send(f"Unbanned **{member}** (`{member.id}`) from {target_guild.name}")
except discord.Forbidden:
return await ctx.send("I don't have permission to unban the user from the target guild!")
except discord.NotFound:
await ctx.send("User is not banned from the target guild!")
try:
await member.send(
f"Your appeal was approved in **{ctx.guild.name}** but you weren't banned from **{target_guild.name}**."
)
except discord.Forbidden:
await ctx.send("I couldn't send a DM to the user to notify them that they weren't banned.")
return
if cog := ctx.bot.get_cog("ArkTools"):
try:
player = await cog.db_utils.get_player_discord(appealguild.id, member.id)
if player:
fake_ctx = deepcopy(ctx)
setattr(fake_ctx, "guild", target_guild)
await cog.ban_unban_player(
ctx=fake_ctx,
player_id=player.gameid,
ban=False,
reason=reason or "",
prompt=True,
)
except Exception as e:
log.error("Error unbanning player", exc_info=e)
await ctx.send(f"Error unbanning player from the Ark servers: {e}")
# Alert the user that their appeal has been approved
try:
await member.send(
f"Your appeal has been approved in **{ctx.guild.name}**. You have been unbanned from **{target_guild.name}**."
)
await ctx.send("User has been notified of the approval.")
except discord.Forbidden:
await ctx.send("I couldn't send a DM to the user to notify them of the approval.")
@ensure_db_connection()
@appealset.command(name="deny")
async def deny_appeal(self, ctx: commands.Context, submission_id: int, *, reason: str = None):
"""Deny an appeal submission by ID"""
appealguild: AppealGuild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
submission = await AppealSubmission.objects().get(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
if not submission:
return await ctx.send("No submission found with that ID.")
if submission.status == "denied":
return await ctx.send("This submission has already been denied.")
elif submission.status == "approved":
return await ctx.send("This submission has already been approved.")
update_kwargs = {AppealSubmission.status: "denied"}
if reason:
update_kwargs[AppealSubmission.reason] = reason
await AppealSubmission.update(update_kwargs).where(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
submission.status = "denied"
submission.reason = reason or ""
await ctx.send(f"Successfully denied submission ID: {submission_id}")
member = ctx.guild.get_member(submission.user_id) or self.bot.get_user(submission.user_id)
# Send the submission to the denied channel and then delete from the pending channel
denied_channel = ctx.guild.get_channel(appealguild.denied_channel)
if denied_channel:
new_embed = submission.embed(member)
new_message = await denied_channel.send(embed=new_embed)
await AppealSubmission.update({AppealSubmission.message_id: new_message.id}).where(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
pending_channel = ctx.guild.get_channel(appealguild.pending_channel)
if pending_channel:
if not pending_channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send(f"I do not have permissions to delete messages from {pending_channel.mention}")
else:
try:
message = await pending_channel.fetch_message(submission.message_id)
if message.thread:
await message.thread.delete()
await message.delete()
except discord.NotFound:
await ctx.send(f"Submission message not found in {pending_channel.mention}")
else:
await ctx.send("Pending channel not found, could not delete the message.")
# Alert the user that their appeal has been denied
target_guild = self.bot.get_guild(appealguild.target_guild_id)
targetname = f"**{target_guild.name}**" if target_guild else "the target server"
try:
txt = f"Your appeal has been denied in **{ctx.guild.name}**. You are still banned from {targetname}"
if reason:
txt += f"\n\n**Reason**: {reason}"
await member.send(txt)
await ctx.send("User has been notified of the denial.")
except discord.Forbidden:
await ctx.send("I couldn't send a DM to the user to notify them of the denial.")
@ensure_db_connection()
@appealset.command(name="delete")
async def delete_appeal(self, ctx: commands.Context, submission_id: int):
"""Delete an appeal submission by ID"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
submission = await AppealSubmission.objects().get(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
if not submission:
return await ctx.send("No submission found with that ID.")
channel = ctx.guild.get_channel(getattr(appealguild, f"{submission.status}_channel"))
if channel:
if not channel.permissions_for(ctx.guild.me).manage_messages:
await ctx.send(f"I do not have permissions to delete messages from {channel.mention}")
else:
try:
message = await channel.fetch_message(submission.message_id)
await message.delete()
except discord.NotFound:
await ctx.send(f"Submission message not found in {channel.mention}")
await submission.delete().where(
(AppealSubmission.id == submission_id) & (AppealSubmission.guild == ctx.guild.id)
)
await ctx.send(f"Successfully deleted submission ID: {submission_id}")
@commands.guildowner()
@ensure_db_connection()
@appealset.command(name="server")
async def set_target_server(self, ctx: commands.Context, server_id: int):
"""
Set the server ID where users will be unbanned from
**NOTES**
- This is the first step to setting up the appeal system
- This server will be the appeal server
- You must be the owner of the target server
"""
target_guild = self.bot.get_guild(server_id)
if not target_guild:
return await ctx.send("The server ID provided is invalid.")
if target_guild.id == ctx.guild.id:
return await ctx.send(
"You can't use the same server as the appeal server. (This server is the appeal server)"
)
if target_guild.owner_id != ctx.author.id:
return await ctx.send("You are not the owner of that server!")
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
appealguild = AppealGuild(id=ctx.guild.id, target_guild_id=server_id)
await appealguild.save()
txt = f"Successfully set the target server to **{target_guild.name}**\nUsers will come to **this** server to appeal for unbans."
return await ctx.send(txt)
if appealguild.target_guild_id == server_id:
return await ctx.send(f"This server is already set to unban users from **{target_guild.name}**")
await AppealGuild.update({AppealGuild.target_guild_id: server_id}).where(AppealGuild.id == ctx.guild.id)
await ctx.send(f"Updated the target server to **{target_guild.name}**")
@ensure_db_connection()
@appealset.command(name="channel")
async def set_channels(
self,
ctx: commands.Context,
channel_type: t.Literal["pending", "approved", "denied"],
channel: discord.TextChannel,
):
"""
Set the channel where submitted appeals will go
`channel_type` must be one of: pending, approved, denied
**NOTE**: All 3 channel types must be set for the appeal system to work properly.
"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if channel_type == "pending":
if channel.id == appealguild.pending_channel:
return await ctx.send("That channel is already set as the pending appeals channel.")
update_col = AppealGuild.pending_channel
await ctx.send(f"Successfully set the pending appeals channel to {channel.mention}")
elif channel_type == "approved":
if channel.id == appealguild.approved_channel:
return await ctx.send("That channel is already set as the approved appeals channel.")
update_col = AppealGuild.approved_channel
await ctx.send(f"Successfully set the approved appeals channel to {channel.mention}")
elif channel_type == "denied":
if channel.id == appealguild.denied_channel:
return await ctx.send("That channel is already set as the denied appeals channel.")
update_col = AppealGuild.denied_channel
await ctx.send(f"Successfully set the denied appeals channel to {channel.mention}")
else:
return await ctx.send("Invalid channel type provided.")
await AppealGuild.update({update_col: channel.id}).where(AppealGuild.id == ctx.guild.id)
await self.refresh(ctx)
@ensure_db_connection()
@appealset.command(name="createappealmessage", aliases=["create"])
async def create_appeal_message(self, ctx: commands.Context, channel: discord.TextChannel):
"""Quickly create and set a pre-baked appeal message in the specified channel"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if not channel.permissions_for(ctx.guild.me).send_messages:
return await ctx.send("I don't have permission to send messages in that channel.")
embed = discord.Embed(
title="Submit an Appeal",
description="Click the button below to submit an appeal.",
color=await self.bot.get_embed_color(ctx),
)
message = await channel.send(embed=embed, view=AppealView(custom_id=f"{ctx.guild.id}"))
await AppealGuild.update(
{
AppealGuild.appeal_channel: channel.id,
AppealGuild.appeal_message: message.id,
}
).where(AppealGuild.id == ctx.guild.id)
await ctx.send(f"Successfully created and set the appeal message in {channel.mention}")
@ensure_db_connection()
@appealset.command(name="appealmessage")
async def set_appeal_message(self, ctx: commands.Context, message: MessageParser):
"""
Set the message where users will appeal from
Message format: `channelID-messageID`
"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
channel = ctx.guild.get_channel(message.channel_id)
if not channel:
return await ctx.send("Invalid channel ID provided.")
try:
msg = await channel.fetch_message(message.message_id)
except discord.NotFound:
return await ctx.send("Invalid message ID provided.")
except discord.Forbidden:
return await ctx.send("I don't have permission to read messages in that channel.")
except discord.HTTPException:
return await ctx.send("An error occurred while fetching the message.")
await AppealGuild.update(
{
AppealGuild.appeal_channel: channel.id,
AppealGuild.appeal_message: message.id,
}
).where(AppealGuild.id == ctx.guild.id)
await ctx.send(f"Successfully set the appeal message to {msg.jump_url}")
await self.refresh(ctx)
@ensure_db_connection()
@appealset.command(name="addquestion")
async def add_appeal_question(self, ctx: commands.Context, *, question: str):
"""Add a question to the appeal form""" # TODO: menu system for extra options
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
count = await AppealQuestion.count().where(AppealQuestion.guild == appealguild)
if count >= 24:
return await ctx.send("You can only have up to 24 questions in the appeal form.")
question = AppealQuestion(
guild=ctx.guild.id,
question=question,
)
await question.save()
await ctx.send("Successfully added the question to the appeal form.")
await self.refresh(ctx)
@ensure_db_connection()
@appealset.command(name="removequestion")
async def remove_appeal_question(self, ctx: commands.Context, question_id: int):
"""Remove a question from the appeal form""" # TODO: menu system for extra options
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
count = await AppealQuestion.count().where(AppealQuestion.guild == appealguild)
if count < 1:
return await ctx.send("No questions have been created yet.")
if count == 1:
return await ctx.send("You can't remove the only question from the appeal system!")
questions = (
await AppealQuestion.delete()
.where((AppealQuestion.id == question_id) & (AppealQuestion.guild == appealguild))
.returning(AppealQuestion.question)
)
if not questions:
return await ctx.send("No question found with that ID.")
await ctx.send(f"Successfully removed the following question: {questions[0]['question']}")
@ensure_db_connection()
@appealset.command(name="questions")
async def appeal_question_menu(self, ctx: commands.Context):
"""Menu to view questions in the appeal form"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
questions = await self.db_utils.get_sorted_questions(ctx.guild.id)
if not questions:
return await ctx.send("No questions found have been created yet.")
pages: list[discord.Embed] = []
color = await self.bot.get_embed_color(ctx)
for idx, question in enumerate(questions):
embed = question.embed(color)
foot = (
f"Page {idx + 1}/{len(questions)}\n"
"The order of the pages is how the sort order will look in the appeal form."
)
embed.set_footer(text=foot)
pages.append(embed)
await DynamicMenu(ctx, pages).refresh()
@ensure_db_connection()
@appealset.command(name="listquestions")
async def list_appeal_questions(self, ctx: commands.Context):
"""
List all questions in the appeal form
Questions will be sorted by their sort order and then by creation date.
"""
if not await self.appeal_guild_check(ctx):
return
questions = await self.db_utils.get_sorted_questions(ctx.guild.id)
if not questions:
return await ctx.send("No questions found have been created yet.")
msg = "\n".join([f"{q.id}. [{q.sort_order}] {q.question}" for q in questions])
for page in pagify(msg, page_length=1800):
await ctx.send(f"**ID. [Sort Order] Question**\n{box(page)}")
@ensure_db_connection()
@appealset.command(name="viewquestion")
@commands.bot_has_permissions(embed_links=True)
async def view_appeal_question(self, ctx: commands.Context, question_id: int):
"""View a question in the appeal form"""
if not await self.appeal_guild_check(ctx):
return
question = await AppealQuestion.objects().get(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
if not question:
return await ctx.send("No question found with that ID.")
embed = discord.Embed(
title=f"Question ID: {question.id}",
description=question.question,
color=await self.bot.get_embed_color(ctx),
)
embed.add_field(
name="Created At",
value=f"{question.created('F')} ({question.created('R')})",
)
embed.add_field(
name="Last Modified",
value=f"{question.modified('F')} ({question.modified('R')})",
)
embed.add_field(name="Sort Order", value=question.sort_order)
embed.add_field(name="Required", value="Yes" if question.required else "No")
embed.add_field(name="Style", value=question.style)
embed.add_field(name="Button Style", value=question.button_style)
embed.add_field(name="Placeholder", value=question.placeholder or "Not set")
embed.add_field(name="Default Value", value=question.default or "Not set")
embed.add_field(name="Max Length", value=question.max_length or "Not set")
embed.add_field(name="Min Length", value=question.min_length or "Not set")
await ctx.send(embed=embed)
@ensure_db_connection()
@appealset.command(name="editquestion")
async def edit_appeal_question(self, ctx: commands.Context, question_id: int, *, question: str):
"""Edit a question in the appeal form"""
if not await self.appeal_guild_check(ctx):
return
original = await AppealQuestion.objects().get(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
if not original:
return await ctx.send("No question found with that ID.")
await (
AppealQuestion.update({AppealQuestion.question: question})
.where((AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id))
.returning(AppealQuestion.question)
)
await ctx.send(f"Question has been edited! Original content: {original.question}")
@ensure_db_connection()
@appealset.command(name="sortorder")
async def set_appeal_question_order(self, ctx: commands.Context, question_id: int, sort_order: int):
"""Set the sort order for a question in the appeal form"""
if not await self.appeal_guild_check(ctx):
return
question = await AppealQuestion.objects().get(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
if not question:
return await ctx.send("No question found with that ID.")
await AppealQuestion.update({AppealQuestion.sort_order: sort_order}).where(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
await ctx.send(f"Successfully updated the sort order for question ID: {question_id}")
@ensure_db_connection()
@appealset.command(
name="questiondetails",
aliases=["questiondata", "setquestiondata", "qd", "details"],
)
async def set_appeal_question_data(
self,
ctx: commands.Context,
question_id: int,
required: bool,
modal_style: str = None,
button_style: str = None,
placeholder: str = None,
default: str = None,
min_length: int = None,
max_length: int = None,
):
"""Set specific data for a question in the appeal form
**Arguments**
- `required`: Whether the question is required or not
- `modal_style`: The style of the modal for the question
- `long`: The modal will be a long text input
- `short`: The modal will be a short text input
- `button_style`: The color of the button for the question
- `primary🔵`, `secondary`, `success🟢`, `danger🔴`
- `placeholder`: The placeholder text for the input
- `default`: The default value for the input
- `min_length`: The minimum length for the input
- `max_length`: The maximum length for the input
"""
if not await self.appeal_guild_check(ctx):
return
if isinstance(placeholder, str) and "none" in placeholder.casefold():
placeholder = None
if isinstance(default, str) and "none" in default.casefold():
default = None
if max_length == 0:
max_length = None
if min_length == 0:
min_length = None
if isinstance(max_length, int) and max_length > 1024:
return await ctx.send("Max length must be 1024 characters or less.")
if isinstance(min_length, int) and min_length > 1023:
return await ctx.send("Min length must be 1023 characters or less.")
if isinstance(max_length, int) and isinstance(min_length, int) and max_length < min_length:
return await ctx.send("Max length must be greater than or equal to min length.")
if modal_style not in ("long", "short", None):
return await ctx.send("Modal style must be either `long` or `short`.")
if button_style not in ("primary", "secondary", "success", "danger", None):
return await ctx.send("Button style must be one of: primary, secondary, success, danger.")
question = await AppealQuestion.objects().get(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
if not question:
return await ctx.send("No question found with that ID.")
update_kwargs = {AppealQuestion.required: required}
if modal_style is not None:
update_kwargs[AppealQuestion.style] = modal_style
if button_style is not None:
update_kwargs[AppealQuestion.button_style] = button_style
if placeholder is not None:
update_kwargs[AppealQuestion.placeholder] = placeholder
if default is not None:
update_kwargs[AppealQuestion.default] = default
if min_length is not None:
update_kwargs[AppealQuestion.min_length] = min_length
if max_length is not None:
update_kwargs[AppealQuestion.max_length] = max_length
await AppealQuestion.update(update_kwargs).where(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
question = await AppealQuestion.objects().get(
(AppealQuestion.id == question_id) & (AppealQuestion.guild == ctx.guild.id)
)
embed = question.embed(await self.bot.get_embed_color(ctx))
await ctx.send(
f"Successfully updated the question data for question ID: {question_id}",
embed=embed,
)
@ensure_db_connection()
@appealset.command(name="alertrole")
async def set_alert_roles(self, ctx: commands.Context, *, role: discord.Role | int):
"""
Add/Remove roles to be pinged when a new appeal is submitted
These roles will be pinged in the appeal server, NOT the target server.
"""
rid = role.id if isinstance(role, discord.Role) else role
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if rid in appealguild.alert_roles:
appealguild.alert_roles.remove(rid)
await ctx.send(f"Removed {role.name} from the alert roles.")
else:
appealguild.alert_roles.append(rid)
await ctx.send(f"Added {role.name} to the alert roles.")
await AppealGuild.update({AppealGuild.alert_roles: appealguild.alert_roles}).where(
AppealGuild.id == ctx.guild.id
)
@ensure_db_connection()
@appealset.command(name="alertchannel")
async def set_alert_channel(self, ctx: commands.Context, *, channel: discord.TextChannel | int = None):
"""
Set the channel ID where alerts for new appeals will be sent
This can be in either the appeal server or the target server.
Alert roles will not be pinged in this message.
"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if isinstance(channel, int):
channel = self.bot.get_channel(channel)
if not channel:
return await ctx.send("Invalid channel ID provided.")
if channel:
await AppealGuild.update({AppealGuild.alert_channel: channel.id}).where(AppealGuild.id == ctx.guild.id)
return await ctx.send(f"Successfully set the alert channel to {channel.mention}")
await AppealGuild.update({AppealGuild.alert_channel: 0}).where(AppealGuild.id == ctx.guild.id)
await ctx.send("Successfully removed the alert channel.")
@ensure_db_connection()
@appealset.command(name="refresh")
async def refresh_appeal(self, ctx: commands.Context):
"""Refresh the appeal message with the current appeal form"""
await self.refresh(ctx)
async def refresh(self, ctx: discord.Guild | commands.Context) -> str | None:
guild = ctx.guild if isinstance(ctx, commands.Context) else ctx
ready, reason = await self.conditions_met(guild)
if not ready:
if isinstance(ctx, commands.Context):
await ctx.send(f"Appeal system not ready yet: {reason}")
return reason
appealguild = await AppealGuild.objects().get(AppealGuild.id == guild.id)
channel = guild.get_channel(appealguild.appeal_channel)
message = await channel.fetch_message(appealguild.appeal_message)
view = AppealView(custom_id=f"{appealguild.id}")
await message.edit(view=view)
@ensure_db_connection()
@appealset.command(name="buttonstyle")
async def set_button_style(
self,
ctx: commands.Context,
style: t.Literal["primary", "secondary", "success", "danger"],
):
"""Set the style of the appeal button"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
await AppealGuild.update({AppealGuild.button_style: style}).where(AppealGuild.id == ctx.guild.id)
view = discord.ui.View().add_item(
discord.ui.Button(
style=getattr(discord.ButtonStyle, style),
label=appealguild.button_label,
disabled=True,
emoji=appealguild.get_emoji(ctx.bot),
)
)
await ctx.send(f"Successfully set the button style to {style}", view=view)
await self.refresh(ctx)
@ensure_db_connection()
@appealset.command(name="buttonlabel")
async def set_button_label(self, ctx: commands.Context, *, label: str):
"""Set the label of the appeal button"""
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if len(label) > 45:
return await ctx.send("Button label must be 45 characters or less.")
await AppealGuild.update({AppealGuild.button_label: label}).where(AppealGuild.id == ctx.guild.id)
view = discord.ui.View().add_item(
discord.ui.Button(
style=getattr(discord.ButtonStyle, appealguild.button_style),
label=label,
disabled=True,
emoji=appealguild.get_emoji(ctx.bot),
)
)
await ctx.send(f"Successfully set the button label to {label}", view=view)
await self.refresh(ctx)
@ensure_db_connection()
@appealset.command(name="buttonemoji")
async def set_button_emoji(
self,
ctx: commands.Context,
emoji: discord.Emoji | discord.PartialEmoji | str = None,
):
"""Set the emoji of the appeal button"""
if isinstance(emoji, discord.Emoji) and not emoji.is_usable():
return await ctx.send("That emoji is not a usable emoji.")
elif isinstance(emoji, discord.PartialEmoji):
emoji = self.bot.get_emoji(emoji.id)
if not emoji:
return await ctx.send("That emoji is not found in this server.")
appealguild = await AppealGuild.objects().get(AppealGuild.id == ctx.guild.id)
if not appealguild:
return await self.no_appealguild(ctx)
if emoji:
tosave = str(emoji if isinstance(emoji, str) else emoji.id)
await AppealGuild.update({AppealGuild.button_emoji: tosave}).where(AppealGuild.id == ctx.guild.id)
view = discord.ui.View().add_item(
discord.ui.Button(
style=getattr(discord.ButtonStyle, appealguild.button_style),
label=appealguild.button_label,
disabled=True,
emoji=emoji,
)
)
return await ctx.send(f"Successfully set the button emoji to {emoji}", view=view)
await AppealGuild.update({AppealGuild.button_emoji: None}).where(AppealGuild.id == ctx.guild.id)
view = discord.ui.View().add_item(
discord.ui.Button(
style=getattr(discord.ButtonStyle, appealguild.button_style),
label=appealguild.button_label,
disabled=True,
)
)
await ctx.send("Successfully removed the button emoji", view=view)
await self.refresh(ctx)

View file

23
appeals/common/checks.py Normal file
View file

@ -0,0 +1,23 @@
from discord.ext.commands.core import check
from redbot.core import commands
def ensure_db_connection():
"""Decorator to ensure a database connection is active.
Example:
```python
@ensure_db_connection()
@commands.command()
async def mycommand(self, ctx):
await ctx.send("Database connection is active")
```
"""
async def predicate(ctx: commands.Context) -> bool:
if not ctx.cog.db:
txt = "Database connection is not active, try again later"
raise commands.UserFeedbackCheckFailure(txt)
return True
return check(predicate)

15
appeals/db/__init__.py Normal file
View file

@ -0,0 +1,15 @@
import sqlite3
from piccolo.engine import sqlite
### MONKEYPATCHING ###
# This is a workaround for a bug in Piccolo's SQLite engine where it doesn't handle integers correctly.
@sqlite.decode_to_string
def convert_int_out(value: str) -> int:
return int(value)
sqlite.CONVERTERS["INTEGER"] = convert_int_out
sqlite3.register_converter("INTEGER", convert_int_out)
### END MONKEYPATCHING ###

View file

@ -0,0 +1,729 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.base import OnDelete, OnUpdate
from piccolo.columns.column_types import (
JSON,
Array,
BigInt,
Boolean,
ForeignKey,
Integer,
Text,
Timestamptz,
)
from piccolo.columns.defaults.timestamptz import TimestamptzNow
from piccolo.columns.indexes import IndexMethod
from piccolo.table import Table
class AppealGuild(Table, tablename="appeal_guild", schema=None):
id = BigInt(
default=0,
null=False,
primary_key=True,
unique=False,
index=True,
index_method=IndexMethod.btree,
choices=None,
db_column_name=None,
secret=False,
)
ID = "2024-12-05T19:16:56:208214"
VERSION = "1.13.0"
DESCRIPTION = "Initial Migrations"
async def forwards():
manager = MigrationManager(migration_id=ID, app_name="appeals", description=DESCRIPTION)
manager.add_table(
class_name="AppealQuestion",
tablename="appeal_question",
schema=None,
columns=None,
)
manager.add_table(
class_name="AppealSubmission",
tablename="appeal_submission",
schema=None,
columns=None,
)
manager.add_table(
class_name="AppealGuild",
tablename="appeal_guild",
schema=None,
columns=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="created_at",
db_column_name="created_at",
column_class_name="Timestamptz",
column_class=Timestamptz,
params={
"default": TimestamptzNow(),
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="updated_on",
db_column_name="updated_on",
column_class_name="Timestamptz",
column_class=Timestamptz,
params={
"default": TimestamptzNow(),
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="guild",
db_column_name="guild",
column_class_name="ForeignKey",
column_class=ForeignKey,
params={
"references": AppealGuild,
"on_delete": OnDelete.cascade,
"on_update": OnUpdate.cascade,
"target_column": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="question",
db_column_name="question",
column_class_name="Text",
column_class=Text,
params={
"default": "",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="sort_order",
db_column_name="sort_order",
column_class_name="Integer",
column_class=Integer,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="required",
db_column_name="required",
column_class_name="Boolean",
column_class=Boolean,
params={
"default": True,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="default",
db_column_name="default",
column_class_name="Text",
column_class=Text,
params={
"default": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="placeholder",
db_column_name="placeholder",
column_class_name="Text",
column_class=Text,
params={
"default": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="max_length",
db_column_name="max_length",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="min_length",
db_column_name="min_length",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="style",
db_column_name="style",
column_class_name="Text",
column_class=Text,
params={
"default": "long",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealQuestion",
tablename="appeal_question",
column_name="button_style",
db_column_name="button_style",
column_class_name="Text",
column_class=Text,
params={
"default": "primary",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="created_at",
db_column_name="created_at",
column_class_name="Timestamptz",
column_class=Timestamptz,
params={
"default": TimestamptzNow(),
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="guild",
db_column_name="guild",
column_class_name="ForeignKey",
column_class=ForeignKey,
params={
"references": AppealGuild,
"on_delete": OnDelete.cascade,
"on_update": OnUpdate.cascade,
"target_column": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="user_id",
db_column_name="user_id",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="answers",
db_column_name="answers",
column_class_name="JSON",
column_class=JSON,
params={
"default": "{}",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="status",
db_column_name="status",
column_class_name="Text",
column_class=Text,
params={
"default": "pending",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="message_id",
db_column_name="message_id",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="id",
db_column_name="id",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": True,
"unique": False,
"index": True,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="created_at",
db_column_name="created_at",
column_class_name="Timestamptz",
column_class=Timestamptz,
params={
"default": TimestamptzNow(),
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="target_guild_id",
db_column_name="target_guild_id",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="appeal_channel",
db_column_name="appeal_channel",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="appeal_message",
db_column_name="appeal_message",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="pending_channel",
db_column_name="pending_channel",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="approved_channel",
db_column_name="approved_channel",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="denied_channel",
db_column_name="denied_channel",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="alert_roles",
db_column_name="alert_roles",
column_class_name="Array",
column_class=Array,
params={
"base_column": BigInt(
default=0,
null=False,
primary_key=False,
unique=False,
index=False,
index_method=IndexMethod.btree,
choices=None,
db_column_name=None,
secret=False,
),
"default": list,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="alert_channel",
db_column_name="alert_channel",
column_class_name="BigInt",
column_class=BigInt,
params={
"default": 0,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="button_style",
db_column_name="button_style",
column_class_name="Text",
column_class=Text,
params={
"default": "primary",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="button_label",
db_column_name="button_label",
column_class_name="Text",
column_class=Text,
params={
"default": "Submit Appeal",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="button_emoji",
db_column_name="button_emoji",
column_class_name="Text",
column_class=Text,
params={
"default": None,
"null": True,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
return manager

View file

@ -0,0 +1,36 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Text
from piccolo.columns.indexes import IndexMethod
ID = "2024-12-07T15:29:49:006538"
VERSION = "1.22.0"
DESCRIPTION = "Appeal reason"
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="appeals", description=DESCRIPTION
)
manager.add_column(
table_class_name="AppealSubmission",
tablename="appeal_submission",
column_name="reason",
db_column_name="reason",
column_class_name="Text",
column_class=Text,
params={
"default": "",
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
return manager

View file

@ -0,0 +1,36 @@
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
from piccolo.columns.column_types import Integer
from piccolo.columns.indexes import IndexMethod
ID = "2025-02-14T13:21:03:690552"
VERSION = "1.13.0"
DESCRIPTION = "appeal limit"
async def forwards():
manager = MigrationManager(
migration_id=ID, app_name="appeals", description=DESCRIPTION
)
manager.add_column(
table_class_name="AppealGuild",
tablename="appeal_guild",
column_name="appeal_limit",
db_column_name="appeal_limit",
column_class_name="Integer",
column_class=Integer,
params={
"default": 1,
"null": False,
"primary_key": False,
"unique": False,
"index": False,
"index_method": IndexMethod.btree,
"choices": None,
"db_column_name": None,
"secret": False,
},
schema=None,
)
return manager

11
appeals/db/piccolo_app.py Normal file
View file

@ -0,0 +1,11 @@
import os
from piccolo.conf.apps import AppConfig, table_finder
CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
APP_CONFIG = AppConfig(
app_name=os.getenv("APP_NAME"),
table_classes=table_finder(["db.tables"]),
migrations_folder_path=os.path.join(CURRENT_DIRECTORY, "migrations"),
)

View file

@ -0,0 +1,8 @@
import os
from piccolo.conf.apps import AppRegistry
from piccolo.engine.sqlite import SQLiteEngine
DB = SQLiteEngine(path=os.getenv("DB_PATH"))
APP_REGISTRY = AppRegistry(apps=["db.piccolo_app"])

137
appeals/db/tables.py Normal file
View file

@ -0,0 +1,137 @@
import typing as t
import discord
import orjson
from piccolo.columns import (
JSON,
Array,
BigInt,
Boolean,
ForeignKey,
Integer,
Serial,
Text,
Timestamptz,
)
from piccolo.columns.defaults.timestamptz import TimestamptzNow
from piccolo.table import Table, sort_table_classes
from redbot.core.bot import Red
class AppealGuild(Table):
id = BigInt(primary_key=True, index=True) # Guild ID
created_at = Timestamptz()
# Settings
target_guild_id = BigInt() # Target guild to be unbanned from
appeal_channel = BigInt() # Channel where the appeal message is located
appeal_message = BigInt() # Message ID of the appeal message with appeal button
pending_channel = BigInt() # Channel where pending appeals are stored
approved_channel = BigInt() # Channel where approved appeals are stored
denied_channel = BigInt() # Channel where denied appeals are stored
alert_roles = Array(BigInt()) # Roles to alert when a new appeal is submitted
alert_channel = BigInt() # Channel to alert when a new appeal is submitted
appeal_limit = Integer(default=1) # Maximum number of times a user can submit an appeal
# Appeal button
button_style = Text(default="primary") # can be `primary`, `secondary`, `success`, `danger`
button_label = Text(default="Submit Appeal")
button_emoji = Text(default=None, null=True) # int or string
def get_emoji(self, bot: Red) -> str | discord.Emoji | discord.PartialEmoji | None:
if not self.button_emoji:
return None
if self.button_emoji.isdigit():
return bot.get_emoji(int(self.button_emoji))
return self.button_emoji
class AppealQuestion(Table):
id: Serial
created_at = Timestamptz()
updated_on = Timestamptz(auto_update=TimestamptzNow().python)
guild = ForeignKey(references=AppealGuild, required=True)
question = Text(required=True) # Up to 256 characters
# menu based setup only
sort_order = Integer(default=0)
# Modal specific (menu based setup only)
required = Boolean(default=True)
default = Text(default=None, null=True)
placeholder = Text(default=None, null=True)
max_length = BigInt(default=None, null=True)
min_length = BigInt(default=None, null=True) # Up to 1024 characters
style = Text(default="long") # can be `short` or `long`
# Button specific (button based setup only)
button_style = Text(default="primary") # can be `primary`, `secondary`, `success`, `danger`
def embed(self, color: discord.Color = None) -> discord.Embed:
style_emojis = {
"danger": "🔴",
"success": "🟢",
"primary": "🔵",
"secondary": "",
}
embed = discord.Embed(title=f"Question ID: {self.id}", description=self.question, color=color)
embed.add_field(name="Sort Order", value=self.sort_order)
embed.add_field(name="Required", value="Yes" if self.required else "No")
embed.add_field(name="Modal Style", value=self.style)
embed.add_field(
name="Button Style",
value=self.button_style + style_emojis[self.button_style],
)
embed.add_field(name="Placeholder", value=self.placeholder or "Not set")
embed.add_field(name="Default Answer", value=self.default or "Not set")
embed.add_field(name="Max Length", value=self.max_length or "Not set")
embed.add_field(name="Min Length", value=self.min_length or "Not set")
return embed
def created(self, type: t.Literal["t", "T", "d", "D", "f", "F", "R"]) -> str:
return f"<t:{int(self.created_at.timestamp())}:{type}>"
def modified(self, type: t.Literal["t", "T", "d", "D", "f", "F", "R"]) -> str:
return f"<t:{int(self.updated_on.timestamp())}:{type}>"
class AppealSubmission(Table):
id: Serial
created_at = Timestamptz()
guild = ForeignKey(references=AppealGuild)
user_id = BigInt() # Person who submitted the appeal
answers = JSON() # {question: answer}
status = Text(default="pending") # can be `pending`, `approved`, `denied`
message_id = BigInt() # Message ID of the submission message
reason = Text() # Reason for denial
def created(self, type: t.Literal["t", "T", "d", "D", "f", "F", "R"]) -> str:
return f"<t:{int(self.created_at.timestamp())}:{type}>"
def embed(self, user: discord.Member | discord.User = None) -> discord.Embed:
colors = {
"pending": discord.Color.blurple(),
"approved": discord.Color.green(),
"denied": discord.Color.red(),
}
if user:
desc = f"Submitted by **{user.name}** ({user.id})\nMention: {user.mention}"
else:
desc = f"Submitted by **Unknown** ({self.user_id})"
embed = discord.Embed(
description=desc,
color=colors[self.status],
timestamp=self.created_at,
)
embed.set_author(
name=f"{self.status.capitalize()} Submission",
icon_url=user.display_avatar if user else None,
)
embed.set_footer(text=f"Submission ID: {self.id}")
answers = orjson.loads(self.answers) if isinstance(self.answers, str) else self.answers
for question, answer in answers.items():
embed.add_field(name=question, value=answer, inline=False)
if self.reason and self.status == "denied":
embed.add_field(name="Reason for Denial", value=self.reason)
elif self.reason and self.status == "approved":
embed.add_field(name="Reason for Approval", value=self.reason)
return embed
TABLES: list[Table] = sort_table_classes([AppealGuild, AppealQuestion, AppealSubmission])

10
appeals/db/utils.py Normal file
View file

@ -0,0 +1,10 @@
from .tables import AppealQuestion
class DBUtils:
async def get_sorted_questions(self, guild_id: int) -> list[AppealQuestion]:
return (
await AppealQuestion.objects()
.where(AppealQuestion.guild == guild_id)
.order_by(AppealQuestion.sort_order, AppealQuestion.created_at)
)

View file

@ -0,0 +1,11 @@
from .engine import diagnose_issues, register_cog, reverse_migration, run_migrations
from .errors import DirectoryError, UNCPathError
__all__ = [
"DirectoryError",
"UNCPathError",
"diagnose_issues",
"register_cog",
"reverse_migration",
"run_migrations",
]

93
appeals/engine/common.py Normal file
View file

@ -0,0 +1,93 @@
import asyncio
import inspect
import os
import subprocess
import sys
from pathlib import Path
from discord.ext import commands
from redbot.core.data_manager import cog_data_path
def get_root(cog_instance: commands.Cog | Path) -> Path:
"""Get the root path of the cog"""
if isinstance(cog_instance, Path):
return cog_instance
return Path(inspect.getfile(cog_instance.__class__)).parent
def is_unc_path(path: Path) -> bool:
"""Check if path is a UNC path"""
return path.is_absolute() and str(path).startswith(r"\\\\")
def is_windows() -> bool:
"""Check if the OS is Windows"""
return os.name == "nt"
def find_piccolo_executable() -> Path:
"""Find the piccolo executable in the system's PATH."""
for path in os.environ["PATH"].split(os.pathsep):
for executable_name in ["piccolo", "piccolo.exe"]:
executable = Path(path) / executable_name
if executable.exists():
return executable
# Fetch the lib path from downloader
lib_path = cog_data_path(raw_name="Downloader") / "lib"
if lib_path.exists():
for folder in lib_path.iterdir():
for executable_name in ["piccolo", "piccolo.exe"]:
executable = folder / executable_name
if executable.exists():
return executable
default_path = Path(sys.executable).parent / "piccolo"
if default_path.exists():
return default_path
raise FileNotFoundError("Piccolo package not found!")
def get_env(cog_instance: commands.Cog | Path, postgres_config: dict = None) -> dict:
"""Create mock environment for subprocess"""
env = os.environ.copy()
if "PICCOLO_CONF" not in env:
# Dont want to overwrite the user's config
env["PICCOLO_CONF"] = "db.piccolo_conf"
env["APP_NAME"] = get_root(cog_instance).stem
if isinstance(cog_instance, Path):
env["DB_PATH"] = str(cog_instance / "db.sqlite")
else:
env["DB_PATH"] = str(cog_data_path(cog_instance) / "db.sqlite")
if is_windows():
env["PYTHONIOENCODING"] = "utf-8"
if postgres_config is not None:
env["POSTGRES_USER"] = postgres_config.get("user", "postgres")
env["POSTGRES_PASSWORD"] = postgres_config.get("password", "postgres")
env["POSTGRES_DATABASE"] = postgres_config.get("database", "postgres")
env["POSTGRES_HOST"] = postgres_config.get("host", "localhost")
env["POSTGRES_PORT"] = postgres_config.get("port", "5432")
return env
async def run_shell(
cog_instance: commands.Cog | Path,
commands: list[str],
is_shell: bool,
) -> str:
"""Run a shell command in a separate thread"""
def _exe() -> str:
res = subprocess.run(
commands,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=is_shell,
cwd=str(get_root(cog_instance)),
env=get_env(cog_instance),
)
return res.stdout.decode(encoding="utf-8", errors="ignore").replace("👍", "!")
return await asyncio.to_thread(_exe)

169
appeals/engine/engine.py Normal file
View file

@ -0,0 +1,169 @@
import logging
from pathlib import Path
from discord.ext import commands
from piccolo.engine.sqlite import SQLiteEngine
from piccolo.table import Table
from redbot.core.data_manager import cog_data_path
from .common import find_piccolo_executable, get_root, is_unc_path, run_shell
from .errors import DirectoryError, UNCPathError
log = logging.getLogger("red.vrt.appeals.engine")
async def register_cog(
cog_instance: commands.Cog | Path,
tables: list[type[Table]],
*,
trace: bool = False,
skip_migrations: bool = False,
) -> SQLiteEngine:
"""Registers a Discord cog with a database connection and runs migrations.
Args:
cog_instance (commands.Cog | Path): The instance/path of the cog to register.
tables (list[type[Table]]): List of Piccolo Table classes to associate with the database engine.
trace (bool, optional): Whether to enable tracing for migrations. Defaults to False.
skip_migrations (bool, optional): Whether to skip running migrations. Defaults to False.
Raises:
TypeError: If the cog instance is not a subclass of discord.ext.commands.Cog or a valid directory path.
UNCPathError: If the cog path is a UNC path, which is not supported.
DirectoryError: If the cog files are not in a valid directory.
Returns:
SQLiteEngine: The database engine associated with the registered cog.
"""
if isinstance(cog_instance, commands.Cog):
save_path = cog_data_path(cog_instance)
elif cog_instance.is_dir():
save_path = cog_instance
else:
# Must be a cog instance or directory
raise TypeError(f"Invalid cog instance: {cog_instance}, must be a cog or directory path")
if is_unc_path(save_path):
raise UNCPathError(f"UNC paths are not supported, please move the cog's location: {save_path}")
if not save_path.is_dir():
raise DirectoryError(f"Cog files are not in a valid directory: {save_path}")
if not skip_migrations:
log.info("Running migrations, if any")
result = await run_migrations(cog_instance, trace)
if "No migrations need to be run" in result:
log.info("No migrations needed!")
else:
log.info(f"Migration result...\n{result}")
if "Traceback" in result:
diagnoses = await diagnose_issues(cog_instance)
log.error(diagnoses + "\nOne or more migrations failed to run!")
log.debug("Fetching database engine")
db = SQLiteEngine(path=str(save_path / "db.sqlite"))
for table_class in tables:
table_class._meta.db = db
return db
async def run_migrations(
cog_instance: commands.Cog | Path,
trace: bool = False,
) -> str:
"""Runs database migrations for the cog
Args:
cog_instance (commands.Cog | Path): The instance of the cog for which to run migrations.
trace (bool, optional): Whether to enable tracing for migrations. Defaults to False.
Returns:
str: The result of the migration process, including any output messages.
"""
commands = [
str(find_piccolo_executable()),
"migrations",
"forwards",
get_root(cog_instance).stem,
]
if trace:
commands.append("--trace")
return await run_shell(cog_instance, commands, False)
async def reverse_migration(
cog_instance: commands.Cog | Path,
timestamp: str,
trace: bool = False,
) -> str:
"""Reverses database migrations for the cog
Args:
cog_instance (commands.Cog | Path): The instance of the cog for which to reverse the migration.
timestamp (str): The timestamp of the migration to reverse to.
trace (bool, optional): Whether to enable tracing for migrations. Defaults to False.
Returns:
str: The result of the migration process, including any output messages.
"""
commands = [
str(find_piccolo_executable()),
"migrations",
"backwards",
get_root(cog_instance).stem,
timestamp,
]
if trace:
commands.append("--trace")
return await run_shell(cog_instance, commands, False)
async def create_migrations(
cog_instance: commands.Cog | Path,
trace: bool = False,
description: str = None,
) -> str:
"""Creates new database migrations for the cog
THIS SHOULD BE RUN MANUALLY!
Args:
cog_instance (commands.Cog | Path): The instance of the cog to create migrations for.
name (str): The name of the migration to create.
Returns:
str: The result of the migration process, including any output messages.
"""
commands = [
str(find_piccolo_executable()),
"migrations",
"new",
get_root(cog_instance).stem,
"--auto",
]
if trace:
commands.append("--trace")
if description is not None:
commands.append(f"--desc={description}")
return await run_shell(cog_instance, commands, True)
async def diagnose_issues(cog_instance: commands.Cog | Path) -> str:
"""Diagnose issues with the cog's database connection
Args:
cog_instance (commands.Cog | Path): The instance of the cog to diagnose.
Returns:
str: The result of the diagnosis process, including any output messages.
"""
piccolo_path = find_piccolo_executable()
diagnoses = await run_shell(
cog_instance,
[str(piccolo_path), "--diagnose"],
False,
)
check = await run_shell(
cog_instance,
[str(piccolo_path), "migrations", "check"],
False,
)
return f"{diagnoses}\n{check}"

6
appeals/engine/errors.py Normal file
View file

@ -0,0 +1,6 @@
class UNCPathError(Exception):
message: str
class DirectoryError(Exception):
message: str

16
appeals/info.json Normal file
View file

@ -0,0 +1,16 @@
{
"author": ["Vertyco"],
"description": "Turn a secondary Discord into a ban appeal server, with intuitive and customizable settings.",
"disabled": false,
"end_user_data_statement": "This cog stores Discord IDs and ban appeal data, which includes user responses to ban appeal questions.",
"hidden": false,
"install_msg": "Thank you for installing Appeals! Type `[p]appeals help` to get started.\n\nDOCUMENTATION: https://github.com/vertyco/vrt-cogs/blob/main/appeals/README.md",
"min_bot_version": "3.5.3",
"min_python_version": [3, 10, 0],
"permissions": ["ban_members"],
"required_cogs": {},
"requirements": ["piccolo[sqlite]", "aiosqlite"],
"short": "Intuitive ban appeal system.",
"tags": ["appeal", "appeals", "ban", "bans", "vrt", "vert", "banappeals"],
"type": "COG"
}

View file

@ -0,0 +1,10 @@
from ..abc import CompositeMetaClass
from .messages import MessageListener
class Listeners(MessageListener, metaclass=CompositeMetaClass):
"""
Subclass all listeners in this directory so you can import this single Listeners class in your cog's class constructor.
See `commands` directory for the same pattern.
"""

View file

@ -0,0 +1,82 @@
import logging
import discord
from redbot.core import commands
from ..abc import MixinMeta
from ..db.tables import AppealGuild, AppealSubmission
log = logging.getLogger("red.vrt.appeals.listeners.messages")
class MessageListener(MixinMeta):
@commands.Cog.listener()
async def on_message_delete(self, message: discord.Message):
if message.author.id != self.bot.user.id:
return
if not message.embeds:
return
if not message.embeds[0].footer:
return
if not message.embeds[0].footer.text:
return
footer = message.embeds[0].footer.text
parts = footer.split("Submission ID: ")
if len(parts) != 2:
return
try:
submission_id = int(parts[1])
except ValueError:
return
submission: dict = (
await AppealSubmission.select(AppealSubmission.all_columns())
.where(AppealSubmission.id == submission_id)
.output(load_json=True)
.first()
)
if not submission:
return
if submission["message_id"] != message.id:
# Old message, dont delete
return
await AppealSubmission.delete().where(AppealSubmission.id == submission_id)
log.info(f"Deleted submission {submission_id} due to message deletion")
appealguild = await AppealGuild.objects().get(
AppealGuild.id == message.guild.id
)
if not appealguild:
return
channel = self.bot.get_channel(appealguild.alert_channel)
if not channel:
return
perms = [
channel.permissions_for(message.guild.me).view_channel,
channel.permissions_for(message.guild.me).send_messages,
channel.permissions_for(message.guild.me).embed_links,
]
if not all(perms):
return
desc = f"Deleted submission `{submission_id}` due to message deletion"
embed = discord.Embed(
description=desc, color=await self.bot.get_embed_color(channel)
)
try:
user = await self.bot.get_or_fetch_user(submission["user_id"])
username = (
f"{user.name} ({user.id})"
if user
else f"Unknown User ({submission['user_id']})"
)
except discord.HTTPException:
username = f"Unknown User ({submission['user_id']})"
embed.add_field(name="Appeal Created By", value=username)
embed.add_field(name="Status", value=submission["status"].capitalize())
embed.set_footer(text=f"Submission ID: {submission_id}")
if user:
embed.set_thumbnail(url=user.display_avatar)
for question, answer in submission["answers"].items():
embed.add_field(name=question, value=answer, inline=False)
await channel.send(embed=embed)

191
appeals/main.py Normal file
View file

@ -0,0 +1,191 @@
import asyncio
import json
import logging
import typing as t
from io import BytesIO
import discord
from piccolo.engine.sqlite import SQLiteEngine
from redbot.core import commands
from redbot.core.bot import Red
from .abc import CompositeMetaClass
from .commands import Commands
from .db.tables import TABLES, AppealGuild, AppealQuestion, AppealSubmission
from .db.utils import DBUtils
from .engine import engine
from .listeners import Listeners
from .views.appeal import AppealView
log = logging.getLogger("red.vrt.appeals")
RequestType = t.Literal["discord_deleted_user", "owner", "user", "user_strict"]
class Appeals(Commands, Listeners, commands.Cog, metaclass=CompositeMetaClass):
"""Straightforward ban appeal system for Discord servers."""
__author__ = "[vertyco](https://github.com/vertyco/vrt-cogs)"
__version__ = "0.1.2"
def __init__(self, bot: Red):
super().__init__()
self.bot: Red = bot
self.db: SQLiteEngine = None
self.db_utils: DBUtils = DBUtils()
def format_help_for_context(self, ctx: commands.Context):
helpcmd = super().format_help_for_context(ctx)
txt = "Version: {}\nAuthor: {}".format(self.__version__, self.__author__)
return f"{helpcmd}\n\n{txt}"
async def red_get_data_for_user(self, *, user_id: int) -> dict[str, BytesIO]:
submissions = (
await AppealSubmission.select(AppealSubmission.all_columns())
.where(AppealSubmission.user_id == user_id)
.output(load_json=True)
)
data = {}
for submission in submissions:
data[str(submission.id)] = BytesIO(json.dumps(submission).encode())
return data
async def red_delete_data_for_user(self, *, requester: RequestType, user_id: int):
# Nothing to delete, saved appeals are required for the appeal system to function
pass
async def cog_load(self) -> None:
asyncio.create_task(self.initialize())
async def cog_unload(self) -> None:
pass
async def initialize(self) -> None:
await self.bot.wait_until_red_ready()
logging.getLogger("aiosqlite").setLevel(logging.INFO)
try:
self.db = await engine.register_cog(
cog_instance=self,
tables=TABLES,
trace=True,
)
except Exception as e:
log.error("Failed to initialize database", exc_info=e)
res = await engine.diagnose_issues(self)
log.error(res)
appealguilds = await AppealGuild.objects()
for appealguild in appealguilds:
ready, __ = await self.conditions_met(appealguild)
if not ready:
continue
view = AppealView(custom_id=f"{appealguild.id}")
self.bot.add_view(view, message_id=appealguild.appeal_message)
log.info("Cog initialized")
async def conditions_met(self, guild: discord.Guild | AppealGuild) -> t.Tuple[bool, t.Optional[str]]:
"""Check if conditions are met for the current guild to use the appeal system."""
if isinstance(guild, discord.Guild):
appealguild = await AppealGuild.objects().get(AppealGuild.id == guild.id)
else:
appealguild = guild
guild = self.bot.get_guild(guild.id)
prefixes = await self.bot.get_valid_prefixes(guild)
p = prefixes[0]
if not appealguild:
return (
False,
f"Appeal system is not setup for this guild, set with `{p}appeal server`",
)
if not appealguild.target_guild_id:
return False, f"Target guild is not set, set with `{p}appeal server`"
target_guild = self.bot.get_guild(appealguild.target_guild_id)
if not target_guild:
return (
False,
f"Target guild `{appealguild.target_guild_id}` was not found, set with `{p}appeal server`",
)
if not target_guild.me.guild_permissions.ban_members:
return False, "Bot does not have ban members permission in target guild"
if not appealguild.appeal_channel:
return (
False,
f"Appeal message is not set, set with `{p}appeal createappealmessage`",
)
appeal_channel = guild.get_channel(appealguild.appeal_channel)
if not appeal_channel:
return (
False,
f"Appeal message channel is not found, please set a new one with `{p}appeal appealmessage",
)
if not appeal_channel.permissions_for(guild.me).view_channel:
return (
False,
"Bot does not have view channel permission in appeal message channel",
)
if not appeal_channel.permissions_for(guild.me).send_messages:
return (
False,
"Bot does not have send messages permission in appeal message channel",
)
if not appealguild.appeal_message:
return (
False,
f"Appeal message is not set, you can quickly create one with `{p}appeal createappealmessage`",
)
try:
await appeal_channel.fetch_message(appealguild.appeal_message)
except discord.NotFound:
return False, "Appeal message is not found"
if not appealguild.pending_channel:
return (
False,
f"Pending channel is not set, set with `{p}appeal channel pending <channel>`",
)
channel = guild.get_channel(appealguild.pending_channel)
if not channel:
return False, "Pending channel is not found"
if not channel.permissions_for(guild.me).view_channel:
return False, "Bot does not have view channel permission in pending channel"
if not channel.permissions_for(guild.me).send_messages:
return (
False,
"Bot does not have send messages permission in pending channel",
)
if not appealguild.approved_channel:
return (
False,
f"Approved channel is not set, set with `{p}appeal channel approved <channel>`",
)
channel = guild.get_channel(appealguild.approved_channel)
if not channel:
return False, "Approved channel is not found"
if not channel.permissions_for(guild.me).view_channel:
return (
False,
"Bot does not have view channel permission in approved channel",
)
if not channel.permissions_for(guild.me).send_messages:
return (
False,
"Bot does not have send messages permission in approved channel",
)
if not appealguild.denied_channel:
return (
False,
f"Denied channel is not set, set with `{p}appeal channel denied <channel>`",
)
channel = guild.get_channel(appealguild.denied_channel)
if not channel:
return False, "Denied channel is not found"
if not channel.permissions_for(guild.me).view_channel:
return False, "Bot does not have view channel permission in denied channel"
if not channel.permissions_for(guild.me).send_messages:
return False, "Bot does not have send messages permission in denied channel"
if not await AppealQuestion.exists().where(AppealQuestion.guild == guild.id):
return (
False,
f"No questions are setup for this server, create one with `{p}appeal addquestion`",
)
return True, None

View file

119
appeals/views/appeal.py Normal file
View file

@ -0,0 +1,119 @@
import discord
from redbot.core import commands
from redbot.core.bot import Red
from ..abc import MixinMeta
from ..db.tables import AppealGuild, AppealSubmission
from .submission import SubmissionView
class AppealView(discord.ui.View):
def __init__(self, custom_id: str) -> None:
super().__init__(timeout=None)
self.submit_appeal.custom_id = custom_id
self.cooldown = commands.CooldownMapping.from_cooldown(1, 60, commands.BucketType.user)
async def on_timeout(self) -> None:
await super().on_timeout()
async def on_error(self, error: Exception, item: discord.ui.Item, interaction: discord.Interaction) -> None:
await super().on_error(error, item, interaction)
@discord.ui.button(label="Submit Appeal", style=discord.ButtonStyle.primary)
async def submit_appeal(self, interaction: discord.Interaction, button: discord.Button):
bucket = self.cooldown.get_bucket(interaction.message)
retry_after = bucket.update_rate_limit()
if retry_after:
return await interaction.response.send_message(f"Try again in {retry_after:.0f} seconds", ephemeral=True)
bot: Red = interaction.client
cog: MixinMeta | None = bot.get_cog("Appeals")
if not cog:
return await interaction.response.send_message(
"The Appeals cog is not loaded, try again later", ephemeral=True
)
if not cog.db:
return await interaction.response.send_message(
"Database connection is not active, try again later", ephemeral=True
)
ready, reason = await cog.conditions_met(interaction.guild)
if not ready:
return await interaction.response.send_message(f"Appeal system not ready: {reason}", ephemeral=True)
appealguild = await AppealGuild.objects().get(AppealGuild.id == interaction.guild.id)
if not appealguild:
return await interaction.response.send_message(
"Appeal system is no longer setup for this server", ephemeral=True
)
target_guild = bot.get_guild(appealguild.target_guild_id)
if not target_guild:
return await interaction.response.send_message(
"The server you're appealing for is no longer available", ephemeral=True
)
if not target_guild.me.guild_permissions.ban_members:
return await interaction.response.send_message(
"I don't have permission to unban members in the server you're appealing for!", ephemeral=True
)
is_admin = await bot.is_admin(interaction.user)
if not is_admin and interaction.user.id in [m.id for m in target_guild.members]:
return await interaction.response.send_message(
"You're already a member of the server you're appealing for, which means you aren't banned!",
ephemeral=True,
)
refresh = False
if self.submit_appeal.label != appealguild.button_label:
self.submit_appeal.label = appealguild.button_label
refresh = True
if self.submit_appeal.style != getattr(discord.ButtonStyle, appealguild.button_style):
self.submit_appeal.style = getattr(discord.ButtonStyle, appealguild.button_style)
refresh = True
if self.submit_appeal.emoji != appealguild.get_emoji(bot):
self.submit_appeal.emoji = appealguild.get_emoji(bot)
refresh = True
if refresh:
await interaction.response.edit_message(view=self)
else:
await interaction.response.defer(ephemeral=True, thinking=True)
if not is_admin:
try:
await target_guild.fetch_ban(interaction.user)
except discord.NotFound:
return await interaction.followup.send(
"You're not banned from the server you're appealing for!", ephemeral=True
)
if appealguild.appeal_limit == 1:
existing = await AppealSubmission.objects().get(
(AppealSubmission.guild == interaction.guild.id) & (AppealSubmission.user_id == interaction.user.id)
)
if existing:
ts, relative = existing.created("F"), existing.created("R")
txt = f"You have already submitted an appeal on {ts} ({relative})"
if existing.status != "pending":
txt += f" and it was **{existing.status.capitalize()}**"
return await interaction.followup.send(txt, ephemeral=True)
else:
pending = await AppealSubmission.objects().get(
(AppealSubmission.guild == interaction.guild.id)
& (AppealSubmission.user_id == interaction.user.id)
& (AppealSubmission.status == "pending")
)
if pending:
ts, relative = pending.created("F"), pending.created("R")
txt = f"You still have a pending appeal submitted on {ts} ({relative})"
return await interaction.followup.send(txt, ephemeral=True)
existing = await AppealSubmission.objects().where(
(AppealSubmission.guild == interaction.guild.id) & (AppealSubmission.user_id == interaction.user.id)
)
if len(existing) >= appealguild.appeal_limit:
return await interaction.followup.send(
f"You have already submitted {len(existing)} appeals which is the maximum allowed",
ephemeral=True,
)
questions = await cog.db_utils.get_sorted_questions(interaction.guild.id)
view = SubmissionView(questions)
embed = await view.make_embed()
await interaction.followup.send(embed=embed, view=view, ephemeral=True)

View file

@ -0,0 +1,287 @@
import asyncio
import logging
import typing as t
from contextlib import suppress
from io import BytesIO
import discord
from rapidfuzz import fuzz
from redbot.core import commands
log = logging.getLogger("red.vrt.appeals.dynamic_menu")
class SearchModal(discord.ui.Modal):
def __init__(self, current: t.Optional[str] = None):
super().__init__(title="Search", timeout=240)
self.query = current
self.input = discord.ui.TextInput(label="Enter Search Query or Page", default=current)
self.add_item(self.input)
async def on_submit(self, interaction: discord.Interaction):
self.query = self.input.value
await interaction.response.defer()
self.stop()
class DynamicMenu(discord.ui.View):
def __init__(
self,
ctx: commands.Context,
pages: t.Union[t.List[discord.Embed], t.List[str]],
message: t.Optional[t.Union[discord.Message, discord.InteractionMessage, None]] = None,
page: int = 0,
timeout: t.Union[int, float, None] = 300,
image_bytes: t.Optional[bytes] = None,
):
super().__init__(timeout=timeout)
self.check_pages(pages) # Modifies pages in place
self.ctx = ctx
self.author = ctx.author
self.channel = ctx.channel
self.guild = ctx.guild
self.pages = pages
self.message = message
self.page = page
self.image_bytes = image_bytes
self.page_count = len(pages)
def check_pages(self, pages: t.List[t.Union[discord.Embed, str]]):
# Ensure pages are either all embeds or all strings
if isinstance(pages[0], discord.Embed):
if not all(isinstance(page, discord.Embed) for page in pages):
raise TypeError("All pages must be Embeds or strings.")
# If the first page has no footer, add one to all pages for page number
if pages[0].footer:
return
page_count = len(pages)
for idx in range(len(pages)):
pages[idx].set_footer(text=f"Page {idx + 1}/{page_count}")
else:
if not all(isinstance(page, str) for page in pages):
raise TypeError("All pages must be Embeds or strings.")
async def interaction_check(self, interaction: discord.Interaction):
if interaction.user.id != self.author.id:
await interaction.response.send_message("This isn't your menu!", ephemeral=True)
return False
return True
async def on_timeout(self) -> None:
if self.message:
with suppress(discord.NotFound, discord.Forbidden, discord.HTTPException):
await self.message.edit(view=None)
await self.ctx.tick()
async def refresh(self, interaction: discord.Interaction = None):
"""Call this to start and refresh the menu."""
try:
await self.__refresh(interaction)
except Exception as e:
current_page = self.pages[self.page]
if isinstance(current_page, discord.Embed):
content = current_page.description or current_page.title
if not content:
content = ""
for field in current_page.fields:
content += f"{field.name}\n{field.value}\n"
else:
content = current_page
log.error(f"Error refreshing menu, current page: {content}", exc_info=e)
async def __refresh(self, interaction: discord.Interaction = None):
self.clear_items()
single = [self.close]
small = [self.left] + single + [self.right]
large = small + [self.left10, self.search, self.right10]
buttons = large if self.page_count > 10 else small if self.page_count > 1 else single
for button in buttons:
self.add_item(button)
if len(buttons) == 1 and isinstance(self.pages[self.page], discord.Embed):
for embed in self.pages:
embed.set_footer(text=None)
attachments = []
file = None
if self.image_bytes:
file = discord.File(BytesIO(self.image_bytes), filename="image.webp")
attachments.append(file)
kwargs = {"view": self}
if isinstance(self.pages[self.page], discord.Embed):
kwargs["embed"] = self.pages[self.page]
kwargs["content"] = None
else:
kwargs["content"] = self.pages[self.page]
if (self.message or interaction) and attachments:
kwargs["attachments"] = attachments
elif (not self.message and not interaction) and file:
kwargs["file"] = file # Need to send new message
if interaction and self.message is not None:
# We are refreshing due to a button press
if not interaction.response.is_done():
try:
await interaction.response.edit_message(**kwargs)
return self
except discord.HTTPException:
try:
await self.message.edit(**kwargs)
except discord.HTTPException:
kwargs.pop("attachments", None)
kwargs["file"] = file
self.message = await self.ctx.send(**kwargs)
else:
try:
await interaction.edit_original_response(**kwargs)
return self
except discord.HTTPException:
try:
await self.message.edit(**kwargs)
except discord.HTTPException:
kwargs.pop("attachments", None)
kwargs["file"] = file
self.message = await self.ctx.send(**kwargs)
return self
if self.message:
try:
await self.message.edit(**kwargs)
except discord.HTTPException:
kwargs.pop("attachments", None)
kwargs["file"] = file
self.message = await self.ctx.send(**kwargs)
else:
self.message = await self.ctx.send(**kwargs)
return self
@discord.ui.button(
emoji="\N{BLACK LEFT-POINTING DOUBLE TRIANGLE}",
style=discord.ButtonStyle.primary,
row=1,
)
async def left10(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page -= 10
self.page %= self.page_count
await self.refresh(interaction)
@discord.ui.button(
emoji="\N{LEFTWARDS BLACK ARROW}\N{VARIATION SELECTOR-16}",
style=discord.ButtonStyle.primary,
)
async def left(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page -= 1
self.page %= self.page_count
await self.refresh(interaction)
@discord.ui.button(
emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}",
style=discord.ButtonStyle.danger,
)
async def close(self, interaction: discord.Interaction, button: discord.ui.Button):
with suppress(discord.HTTPException):
await interaction.response.defer()
try:
await interaction.delete_original_response()
except discord.HTTPException:
if self.message:
with suppress(discord.HTTPException):
await self.message.delete()
self.stop()
@discord.ui.button(
emoji="\N{BLACK RIGHTWARDS ARROW}\N{VARIATION SELECTOR-16}",
style=discord.ButtonStyle.primary,
)
async def right(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page += 1
self.page %= self.page_count
await self.refresh(interaction)
@discord.ui.button(
emoji="\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE}",
style=discord.ButtonStyle.primary,
row=1,
)
async def right10(self, interaction: discord.Interaction, button: discord.ui.Button):
self.page += 10
self.page %= self.page_count
await self.refresh(interaction)
@discord.ui.button(
emoji="\N{LEFT-POINTING MAGNIFYING GLASS}",
style=discord.ButtonStyle.secondary,
row=1,
)
async def search(self, interaction: discord.Interaction, button: discord.ui.Button):
modal = SearchModal(str(self.page + 1))
await interaction.response.send_modal(modal)
await modal.wait()
if modal.query is None:
return
if modal.query.isnumeric():
self.page = int(modal.query) - 1
self.page %= self.page_count
return await self.refresh(interaction)
if isinstance(self.pages[self.page], str):
for i, page in enumerate(self.pages):
if modal.query.casefold() in page.casefold():
self.page = i
return await self.refresh(interaction)
with suppress(discord.HTTPException):
await interaction.followup.send("No page found matching that query.", ephemeral=True)
return
# Pages are embeds
for i, embed in enumerate(self.pages):
if embed.title and modal.query.casefold() in embed.title.casefold():
self.page = i
return await self.refresh(interaction)
if modal.query.casefold() in embed.description.casefold():
self.page = i
return await self.refresh(interaction)
if embed.footer and modal.query.casefold() in embed.footer.text.casefold():
self.page = i
return await self.refresh(interaction)
for field in embed.fields:
if modal.query.casefold() in field.name.casefold():
self.page = i
return await self.refresh(interaction)
if modal.query.casefold() in field.value.casefold():
self.page = i
return await self.refresh(interaction)
# No results found, resort to fuzzy matching
def _fuzzymatch() -> list[tuple[int, int]]:
# [(match, index)]
matches: list[tuple[int, int]] = []
for i, embed in enumerate(self.pages):
matches.append((fuzz.ratio(modal.query.lower(), embed.title.lower()), i))
matches.append((fuzz.ratio(modal.query.lower(), embed.description.lower()), i))
if embed.footer:
matches.append((fuzz.ratio(modal.query.lower(), embed.footer.text.lower()), i))
for field in embed.fields:
matches.append((fuzz.ratio(modal.query.lower(), field.name.lower()), i))
matches.append((fuzz.ratio(modal.query.lower(), field.value.lower()), i))
if matches:
matches.sort(key=lambda x: x[0], reverse=True)
return matches
matches = await asyncio.to_thread(_fuzzymatch)
# Sort by best match
best_score, best_index = matches[0]
if best_score < 50:
with suppress(discord.HTTPException):
await interaction.followup.send("No page found matching that query.", ephemeral=True)
return
self.page = best_index
await self.refresh(interaction)
await interaction.followup.send("Found closest match of {}%".format(int(best_score)), ephemeral=True)

222
appeals/views/submission.py Normal file
View file

@ -0,0 +1,222 @@
import typing as t
import discord
from redbot.core.bot import Red
# from ..abc import MixinMeta
from ..db.tables import AppealGuild, AppealQuestion, AppealSubmission
class AnswerModal(discord.ui.Modal):
def __init__(self, question: AppealQuestion, question_number: int, answers: dict[str, str]) -> None:
super().__init__(timeout=None, title=f"Question {question_number}")
self.question = question
self.question_number = question_number
self.answers = answers
self.input = discord.ui.TextInput(
label=(question.question if len(question.question) <= 45 else f"{question.question[:42]}..."),
required=question.required,
default=question.default or answers.get(question.question),
placeholder=question.placeholder,
min_length=question.min_length,
max_length=question.max_length,
style=discord.TextStyle.paragraph if question.style == "long" else discord.TextStyle.short,
)
self.add_item(self.input)
async def on_error(self, interaction: discord.Interaction, error: Exception) -> None:
return await super().on_error(interaction, error)
async def on_submit(self, interaction: discord.Interaction):
await interaction.response.defer()
if self.input.value:
self.answers[self.question.question] = self.input.value
else:
self.answers.pop(self.question.question, None)
self.stop()
class MenuButton(discord.ui.Button):
def __init__(
self,
question: AppealQuestion,
question_number: int,
answers: dict[str, str],
response_func: t.Callable,
emoji: str | discord.Emoji | discord.PartialEmoji | None = None,
style: discord.ButtonStyle = discord.ButtonStyle.primary,
label: str | None = None,
disabled: bool = False,
row: int | None = None,
):
super().__init__(style=style, label=label, disabled=disabled, emoji=emoji, row=row)
self.question = question
self.question_number = question_number
self.answers = answers
self.func = response_func
async def callback(self, interaction: discord.Interaction):
modal = AnswerModal(self.question, self.question_number, self.answers)
await interaction.response.send_modal(modal)
await modal.wait()
if modal.input.value:
self.style = discord.ButtonStyle.secondary
else:
self.style = getattr(discord.ButtonStyle, self.question.button_style)
await self.func(interaction, self)
class SubmissionView(discord.ui.View):
def __init__(self, questions: list[AppealQuestion]) -> None:
super().__init__(timeout=None)
self.questions = questions
self.answers: dict[str, str] = {} # {question: answer}
self.buttons: dict[str, MenuButton] = {}
for i, question in enumerate(questions):
button = MenuButton(
question=question,
question_number=i + 1,
answers=self.answers,
response_func=self.response,
label=f"Question {i + 1}",
style=getattr(discord.ButtonStyle, question.button_style),
)
self.add_item(button)
self.buttons[question.question] = button
async def on_timeout(self) -> None:
await super().on_timeout()
async def on_error(self, error: Exception, item: discord.ui.Item, interaction: discord.Interaction) -> None:
await super().on_error(error, item, interaction)
async def response(self, interaction: discord.Interaction, button: MenuButton):
# Intearaction has already been responded to
# bot: Red = interaction.client
# cog: MixinMeta = bot.get_cog("Appeals")
embed = await self.make_embed()
self.toggle_submit_button()
await interaction.edit_original_response(embed=embed, view=self)
async def make_embed(self):
color = discord.Color.green() if len(self.answers) == len(self.questions) else discord.Color.blue()
embed = discord.Embed(title="Appeal Submission", color=color)
if self.can_submit():
embed.set_footer(text="Required questions have been answered. You may submit when ready.")
else:
embed.set_footer(text="Click the buttons that correspond to the questions to answer them.")
for i, question in enumerate(self.questions):
name = f"{i + 1}. {question.question}"
value = self.answers.get(question.question)
if not value:
value = "Required" if question.required else "Optional"
embed.add_field(name=name, value=value, inline=False)
return embed
def can_submit(self) -> bool:
required_questions = [q.question for q in self.questions if q.required]
required_answers = [q for q in self.answers if q in required_questions]
return len(required_questions) == len(required_answers)
def toggle_submit_button(self):
self.submit_appeal.disabled = not self.can_submit()
async def send(
self,
interaction: discord.Interaction,
content: str = None,
embed: discord.Embed = None,
ephemeral: bool = False,
):
try:
await interaction.response.send_message(content=content, embed=embed, ephemeral=ephemeral)
except discord.HTTPException:
await interaction.followup.send(content=content, embed=embed, ephemeral=ephemeral)
@discord.ui.button(label="Submit", style=discord.ButtonStyle.success, disabled=True, row=4)
async def submit_appeal(self, interaction: discord.Interaction, button: discord.ui.Button):
bot: Red = interaction.client
# cog: MixinMeta = bot.get_cog("Appeals")
if not self.can_submit():
# This shouldn't happen since the button will be disabled until all required questions are answered
return await self.send(
interaction,
"You must answer all required questions before submitting.",
ephemeral=True,
)
appealguild: AppealGuild = (
await AppealGuild.select(
AppealGuild.pending_channel,
AppealGuild.alert_roles,
AppealGuild.alert_channel,
)
.where(AppealGuild.id == interaction.guild.id)
.first()
)
if not appealguild:
return await self.send(interaction, "Appeal system is no longer setup for this server.")
pending_channel = interaction.guild.get_channel(appealguild["pending_channel"])
if not pending_channel:
return await self.send(
interaction,
"Appeal system is no longer setup for this server as the pending channel is missing.",
)
perms = [
pending_channel.permissions_for(interaction.guild.me).view_channel,
pending_channel.permissions_for(interaction.guild.me).send_messages,
pending_channel.permissions_for(interaction.guild.me).embed_links,
]
if not all(perms):
return await self.send(
interaction,
"I don't have the required permissions to send messages in the pending channel.",
)
try:
await interaction.response.edit_message(content="Submission complete!", embed=None, view=None)
except discord.HTTPException:
await interaction.edit_original_response(content="Submission complete!", embed=None, view=None)
final_answers = {}
for question in self.questions:
answer = self.answers.get(question.question, "*Not answered*")
final_answers[question.question] = answer
submission = AppealSubmission(
guild=interaction.guild.id,
user_id=interaction.user.id,
answers=final_answers,
)
await submission.save()
embed = submission.embed(interaction.user)
allowed_mentions = discord.AllowedMentions(users=True, roles=True)
mentions = None
if alert_roles := appealguild["alert_roles"]:
mentions = ", ".join([f"<@&{r}>" for r in alert_roles])
message = await pending_channel.send(content=mentions, embed=embed, allowed_mentions=allowed_mentions)
# If alert channel exists and bot has permissions to send messages in it, ping there instead
# otherwise ping the pending channel
alert_channel = bot.get_channel(appealguild["alert_channel"])
if alert_channel:
perms = [
alert_channel.permissions_for(alert_channel.guild.me).view_channel,
alert_channel.permissions_for(alert_channel.guild.me).send_messages,
alert_channel.permissions_for(alert_channel.guild.me).embed_links,
]
if all(perms):
desc = f"New appeal submission from **{interaction.user.name}** (`{interaction.user.id}`)"
desc += f"\n[View Appeal]({message.jump_url})"
embed = discord.Embed(description=desc, color=discord.Color.yellow())
embed.set_thumbnail(url=interaction.user.display_avatar)
await alert_channel.send(embed=embed, allowed_mentions=allowed_mentions)
await AppealSubmission.update({AppealSubmission.message_id: message.id}).where(
AppealSubmission.id == submission.id
)

40
levelup/CHANGELOG.md Normal file
View file

@ -0,0 +1,40 @@
# [4.1.1](https://github.com/vertyco/vrt-cogs/commit/7b66eb14a2a885ca391c0ee783b738d11b270717) (2024-07-24)
## Release Highlights
- Allow listening to other bots
- Allow list commands
- Improved ignored channels
- Various bugfixes and docstrinc improvements
## New Features
- Added `[p]lvlset allowlist` commands
- Added `[p]lvlowner ignorebots` to let bots have profiles too
# [4.0.0](https://github.com/vertyco/vrt-cogs/commit/60c7eedb14c770304f1e29449456596eb5949426) (2024-06-18)
## Release Highlights
- Complete rewrite of the cog to be more performant, user friendly, and easier to maintain
- Moved away from Red's `Config` driver to a custom Pydantic based configuration system
- Total rework of how voice time is tracked (way more efficient)
- [Documented functions](https://github.com/vertyco/vrt-cogs/tree/main/levelup/shared) for other 3rd party cogs to use (WIP)
- Optional external [API framework](https://github.com/vertyco/vrt-cogs/blob/main/levelup/generator/README.md) for image generation offloading
- Removed `[p]lvlset admin` group and split into two separate groups: `[p]lvldata` for backups and data related commands and `[p]lvlowner` for owner only commands
## New Features
- Image profiles can now render gifs both for the background and the profile picture
- Added a new Runescape stylized profile image style
- Added framework for an external API to offload image generation
- Added "Exp Role Groups" so many users can contribute to a grouped leaderboard
- This is mostly for fun and doesn't do any role assignment or bonuses
- User level roles are now synced more intelligently, admins should rarely if ever need to sync them manually
- LevelUp images can now be rendered as gifs
- Profile configuration commands are now hybrids
- Setting colors now support a ton of names instead of just hex codes
- Backing up the cog now prettyfies the JSON output with indentation
- The new config system keeps 3 backups of itself at all times
- Setting profile backgrounds now supports Discord [Tenor](https://developers.google.com/tenor/guides/quickstart) links directly (Must set api key with `[p]set api tenor api_key <key>`)
- Added command to set server-wide profile style override

754
levelup/README.md Normal file
View file

@ -0,0 +1,754 @@
Your friendly neighborhood leveling system<br/><br/>Earn experience by chatting in text and voice channels, compare levels with your friends, customize your profile and view various leaderboards!
# [p]weekly
View Weekly Leaderboard<br/>
- Usage: `[p]weekly [stat=exp] [displayname=True]`
- Aliases: `week`
- Checks: `server_only`
# [p]lastweekly
View Last Week's Leaderboard<br/>
- Usage: `[p]lastweekly`
- Checks: `server_only`
# [p]weeklyset
Configure Weekly LevelUp Settings<br/>
- Usage: `[p]weeklyset`
- Restricted to: `ADMIN`
- Aliases: `wset`
- Checks: `server_only`
## [p]weeklyset autoreset
Toggle auto reset of weekly stats<br/>
- Usage: `[p]weeklyset autoreset`
## [p]weeklyset winners
Set number of winners to display<br/>
Due to Discord limitations with max embed field count, the maximum number of winners is 25<br/>
- Usage: `[p]weeklyset winners <count>`
## [p]weeklyset toggle
Toggle weekly stat tracking<br/>
- Usage: `[p]weeklyset toggle`
## [p]weeklyset channel
Set channel to announce weekly winners<br/>
- Usage: `[p]weeklyset channel <channel>`
## [p]weeklyset bonus
Set bonus exp for top weekly winners<br/>
- Usage: `[p]weeklyset bonus <bonus>`
## [p]weeklyset role
Set role to award top weekly winners<br/>
- Usage: `[p]weeklyset role <role>`
## [p]weeklyset roleall
Toggle whether all winners get the role<br/>
- Usage: `[p]weeklyset roleall`
## [p]weeklyset view
View the current weekly settings<br/>
- Usage: `[p]weeklyset view`
## [p]weeklyset hour
Set hour for weekly stats reset<br/>
- Usage: `[p]weeklyset hour <hour>`
## [p]weeklyset reset
Reset the weekly leaderboard manually and announce winners<br/>
- Usage: `[p]weeklyset reset <yes_or_no>`
## [p]weeklyset day
Set day for weekly stats reset<br/>
0 = Monday<br/>
1 = Tuesday<br/>
2 = Wednesday<br/>
3 = Thursday<br/>
4 = Friday<br/>
5 = Saturday<br/>
6 = Sunday<br/>
- Usage: `[p]weeklyset day <day>`
## [p]weeklyset ping
Toggle whether to ping winners in announcement<br/>
- Usage: `[p]weeklyset ping`
## [p]weeklyset autoremove
Remove role from previous winner when new one is announced<br/>
- Usage: `[p]weeklyset autoremove`
# [p]leveltop (Hybrid Command)
View the LevelUp leaderboard<br/>
- Usage: `[p]leveltop [stat=exp] [globalstats=False] [displayname=True]`
- Slash Usage: `/leveltop [stat=exp] [globalstats=False] [displayname=True]`
- Aliases: `lvltop, topstats, membertop, and topranks`
- Checks: `server_only`
# [p]roletop
View the leaderboard for roles<br/>
- Usage: `[p]roletop`
- Checks: `server_only`
# [p]profile (Hybrid Command)
View User Profile<br/>
- Usage: `[p]profile [user]`
- Slash Usage: `/profile [user]`
- Aliases: `pf`
- Cooldown: `3 per 10.0 seconds`
- Checks: `server_only`
# [p]prestige (Hybrid Command)
Prestige your rank!<br/>
Once you have reached this servers prestige level requirement, you can<br/>
reset your level and experience to gain a prestige level and any perks associated with it<br/>
If you are over level and xp when you prestige, your xp and levels will carry over<br/>
- Usage: `[p]prestige`
- Slash Usage: `/prestige`
- Checks: `server_only`
# [p]setprofile (Hybrid Command)
Customize your profile<br/>
- Usage: `[p]setprofile`
- Slash Usage: `/setprofile`
- Aliases: `myprofile, mypf, and pfset`
- Checks: `server_only`
## [p]setprofile shownick (Hybrid Command)
Toggle whether your nickname or username is shown in your profile<br/>
- Usage: `[p]setprofile shownick`
- Slash Usage: `/setprofile shownick`
## [p]setprofile backgrounds (Hybrid Command)
View the all available backgrounds<br/>
- Usage: `[p]setprofile backgrounds`
- Slash Usage: `/setprofile backgrounds`
- Cooldown: `1 per 5.0 seconds`
## [p]setprofile namecolor (Hybrid Command)
Set a color for your username<br/>
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**<br/>
Set to `default` to randomize the color each time your profile is generated<br/>
- Usage: `[p]setprofile namecolor <color>`
- Slash Usage: `/setprofile namecolor <color>`
- Aliases: `name`
## [p]setprofile font (Hybrid Command)
Set a font for your profile<br/>
To view available fonts, type `[p]myprofile fonts`<br/>
To revert to the default font, use `default` for the `font_name` argument<br/>
- Usage: `[p]setprofile font <font_name>`
- Slash Usage: `/setprofile font <font_name>`
## [p]setprofile blur (Hybrid Command)
Toggle a slight blur effect on the background image where the text is displayed.<br/>
- Usage: `[p]setprofile blur`
- Slash Usage: `/setprofile blur`
## [p]setprofile remfont (Hybrid Command)
Remove a default font from the cog's fonts folder<br/>
- Usage: `[p]setprofile remfont <filename>`
- Slash Usage: `/setprofile remfont <filename>`
- Restricted to: `BOT_OWNER`
## [p]setprofile style (Hybrid Command)
Set your profile image style<br/>
- `default` is the default profile style, very customizable<br/>
- `runescape` is a runescape style profile, less customizable but more nostalgic<br/>
- (WIP) - more to come<br/>
- Usage: `[p]setprofile style <style>`
- Slash Usage: `/setprofile style <style>`
## [p]setprofile background (Hybrid Command)
Set a background for your profile<br/>
This will override your profile banner as the background<br/>
**WARNING**<br/>
The default profile style is wide (1050 by 450 pixels) with an aspect ratio of 21:9.<br/>
Portrait images will be cropped.<br/>
Tip: Googling "dual monitor backgrounds" gives good results for the right images<br/>
Here are some good places to look.<br/>
[dualmonitorbackgrounds](https://www.dualmonitorbackgrounds.com/)<br/>
[setaswall](https://www.setaswall.com/dual-monitor-wallpapers/)<br/>
[pexels](https://www.pexels.com/photo/panoramic-photography-of-trees-and-lake-358482/)<br/>
[teahub](https://www.teahub.io/searchw/dual-monitor/)<br/>
**Additional Options**<br/>
- Leave `url` blank or specify `default` to reset back to using your profile banner (or random if you don't have one)<br/>
- `random` will randomly select from a pool of default backgrounds each time<br/>
- `filename` run `[p]mypf backgrounds` to view default options you can use by including their filename<br/>
- Usage: `[p]setprofile background [url=None]`
- Slash Usage: `/setprofile background [url=None]`
- Aliases: `bg`
## [p]setprofile rembackground (Hybrid Command)
Remove a default background from the cog's backgrounds folder<br/>
- Usage: `[p]setprofile rembackground <filename>`
- Slash Usage: `/setprofile rembackground <filename>`
- Restricted to: `BOT_OWNER`
## [p]setprofile addfont (Hybrid Command)
Add a custom font to the cog from discord<br/>
**Arguments**<br/>
`preferred_filename` - If a name is given, it will be saved as this name instead of the filename<br/>
**Note:** do not include the file extension in the preferred name, it will be added automatically<br/>
- Usage: `[p]setprofile addfont [preferred_filename=None]`
- Slash Usage: `/setprofile addfont [preferred_filename=None]`
- Restricted to: `BOT_OWNER`
## [p]setprofile bgpath (Hybrid Command)
Get the folder paths for this cog's backgrounds<br/>
- Usage: `[p]setprofile bgpath`
- Slash Usage: `/setprofile bgpath`
- Restricted to: `BOT_OWNER`
## [p]setprofile view (Hybrid Command)
View your profile settings<br/>
- Usage: `[p]setprofile view`
- Slash Usage: `/setprofile view`
## [p]setprofile addbackground (Hybrid Command)
Add a custom background to the cog from discord<br/>
**Arguments**<br/>
`preferred_filename` - If a name is given, it will be saved as this name instead of the filename<br/>
**DISCLAIMER**<br/>
- Do not replace any existing file names with custom images<br/>
- If you add broken or corrupt images it can break the cog<br/>
- Do not include the file extension in the preferred name, it will be added automatically<br/>
- Usage: `[p]setprofile addbackground [preferred_filename=None]`
- Slash Usage: `/setprofile addbackground [preferred_filename=None]`
- Restricted to: `BOT_OWNER`
## [p]setprofile fonts (Hybrid Command)
View the available fonts you can use<br/>
- Usage: `[p]setprofile fonts`
- Slash Usage: `/setprofile fonts`
- Cooldown: `1 per 5.0 seconds`
## [p]setprofile fontpath (Hybrid Command)
Get folder paths for this cog's fonts<br/>
- Usage: `[p]setprofile fontpath`
- Slash Usage: `/setprofile fontpath`
- Restricted to: `BOT_OWNER`
## [p]setprofile statcolor (Hybrid Command)
Set a color for your server stats<br/>
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**<br/>
Set to `default` to randomize the color each time your profile is generated<br/>
- Usage: `[p]setprofile statcolor <color>`
- Slash Usage: `/setprofile statcolor <color>`
- Aliases: `stat`
## [p]setprofile barcolor (Hybrid Command)
Set a color for your level bar<br/>
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**<br/>
Set to `default` to randomize the color each time your profile is generated<br/>
- Usage: `[p]setprofile barcolor <color>`
- Slash Usage: `/setprofile barcolor <color>`
- Aliases: `levelbar, lvlbar, and bar`
# [p]stars (Hybrid Command)
Reward a good noodle<br/>
- Usage: `[p]stars [user]`
- Slash Usage: `/stars [user]`
- Aliases: `givestar, addstar, and thanks`
- Checks: `server_only`
# [p]startop
View the Star Leaderboard<br/>
- Usage: `[p]startop [globalstats=False] [displayname=True]`
- Aliases: `topstars, starleaderboard, and starlb`
- Checks: `server_only`
# [p]starset
Configure LevelUp Star Settings<br/>
- Usage: `[p]starset`
- Restricted to: `ADMIN`
- Checks: `server_only`
## [p]starset view
View Star Settings<br/>
- Usage: `[p]starset view`
## [p]starset mentiondelete
Toggle whether the bot auto-deletes the star mentions<br/>
Set to 0 to disable auto-delete<br/>
- Usage: `[p]starset mentiondelete <delete_after>`
## [p]starset cooldown
Set the star cooldown<br/>
- Usage: `[p]starset cooldown <cooldown>`
## [p]starset mention
Toggle star reaction mentions<br/>
- Usage: `[p]starset mention`
# [p]levelowner
Owner Only LevelUp Settings<br/>
- Usage: `[p]levelowner`
- Restricted to: `BOT_OWNER`
- Aliases: `lvlowner`
- Checks: `server_only`
## [p]levelowner ignorebots
Toggle ignoring bots for XP and profiles<br/>
**USE AT YOUR OWN RISK**<br/>
Allowing your bot to listen to other bots is a BAD IDEA and should NEVER be enabled on public bots.<br/>
- Usage: `[p]levelowner ignorebots`
## [p]levelowner internalapi
Enable internal API for parallel image generation<br/>
Setting a port will spin up a detatched but cog-managed FastAPI server to handle image generation.<br/>
The process ID will be attached to the bot object and persist through reloads.<br/>
**USE AT YOUR OWN RISK!!!**<br/>
Using the internal API will spin up multiple subprocesses to handle bulk image generation.<br/>
If your bot crashes, the API subprocess will not be killed and will need to be manually terminated!<br/>
It is HIGHLY reccommended to host the api separately!<br/>
Set to 0 to disable the internal API<br/>
**Notes**<br/>
- This will spin up a 1 worker per core on the bot's cpu.<br/>
- If the API fails, the cog will fall back to the default image generation method.<br/>
- Usage: `[p]levelowner internalapi <port>`
## [p]levelowner externalapi
Set the external API URL for image generation<br/>
Set to an `none` to disable the external API<br/>
**Notes**<br/>
- If the API fails, the cog will fall back to the default image generation method.<br/>
- Usage: `[p]levelowner externalapi <url>`
## [p]levelowner rendergifs
Toggle rendering of GIFs for animated profiles<br/>
- Usage: `[p]levelowner rendergifs`
- Aliases: `rendergif and gif`
## [p]levelowner cache
Set the cache time for user profiles<br/>
- Usage: `[p]levelowner cache <seconds>`
## [p]levelowner maxbackups
Set the maximum number of backups to keep<br/>
- Usage: `[p]levelowner maxbackups <backups>`
## [p]levelowner ignore
Add/Remove a server from the ignore list<br/>
- Usage: `[p]levelowner ignore <server_id>`
## [p]levelowner backupinterval
Set the interval for backups<br/>
- Usage: `[p]levelowner backupinterval <interval>`
## [p]levelowner view
View Global LevelUp Settings<br/>
- Usage: `[p]levelowner view`
## [p]levelowner forceembeds
Toggle enforcing profile embeds<br/>
If enabled, profiles will only use embeds on all servers.<br/>
This disables image generation globally.<br/>
- Usage: `[p]levelowner forceembeds`
- Aliases: `forceembed`
## [p]levelowner autoclean
Toggle purging of config data for servers the bot is no longer in<br/>
- Usage: `[p]levelowner autoclean`
# [p]leveldata
Admin Only Data Commands<br/>
- Usage: `[p]leveldata`
- Restricted to: `ADMIN`
- Aliases: `lvldata and ldata`
- Checks: `server_only`
## [p]leveldata reset
Reset all user data in this server<br/>
- Usage: `[p]leveldata reset`
## [p]leveldata resetglobal
Reset user data for all servers<br/>
- Usage: `[p]leveldata resetglobal`
- Restricted to: `BOT_OWNER`
## [p]leveldata restorecog
Restore the cog's data<br/>
- Usage: `[p]leveldata restorecog`
- Restricted to: `BOT_OWNER`
## [p]leveldata importamari
Import levels and exp from AmariBot<br/>
**Arguments**<br/>
`import_by` - Import by level or exp<br/>
• If `level`, it will import their level and calculate exp from that.<br/>
• If `exp`, it will import their exp directly and calculate level from that.<br/>
`replace` - Replace existing data (True/False)<br/>
• If True, it will replace existing data.<br/>
`api_key` - Your [AmariBot API key](https://docs.google.com/forms/d/e/1FAIpQLScQDCsIqaTb1QR9BfzbeohlUJYA3Etwr-iSb0CRKbgjA-fq7Q/viewform?usp=send_form)<br/>
`all_users` - Import all users regardless of if they're in the server (True/False)<br/>
- Usage: `[p]leveldata importamari <import_by> <replace> <api_key> <all_users>`
- Restricted to: `GUILD_OWNER`
## [p]leveldata importmalarne
Import levels and exp from Malarne's Leveler cog<br/>
**Arguments**<br/>
`import_by` - Import by level or exp<br/>
• If `level`, it will import their level and calculate exp from that.<br/>
• If `exp`, it will import their exp directly and calculate level from that.<br/>
`replace` - Replace existing data (True/False)<br/>
• If True, it will replace existing data.<br/>
`all_users` - Import all users regardless of if they're in the server (True/False)<br/>
- Usage: `[p]leveldata importmalarne <import_by> <replace> <all_users>`
- Restricted to: `BOT_OWNER`
## [p]leveldata importmee6
Import levels and exp from MEE6<br/>
**Arguments**<br/>
`import_by` - Import by level or exp<br/>
• If `level`, it will import their level and calculate exp from that.<br/>
• If `exp`, it will import their exp directly and calculate level from that.<br/>
`replace` - Replace existing data (True/False)<br/>
`include_settings` - Include MEE6 settings (True/False)<br/>
`all_users` - Import all users regardless of if they're in the server (True/False)<br/>
- Usage: `[p]leveldata importmee6 <import_by> <replace> <include_settings> <all_users>`
- Restricted to: `GUILD_OWNER`
## [p]leveldata backup
Backup this server's data<br/>
- Usage: `[p]leveldata backup`
## [p]leveldata importpolaris
Import levels and exp from Polaris<br/>
**Make sure your server's leaderboard is public!**<br/>
**Arguments**<br/>
`replace` - Replace existing data (True/False)<br/>
`include_settings` - Include Polaris settings (True/False)<br/>
`all_users` - Import all users regardless of if they're in the server (True/False)<br/>
[Polaris](https://gdcolon.com/polaris/)<br/>
- Usage: `[p]leveldata importpolaris <replace> <include_settings> <all_users>`
- Restricted to: `GUILD_OWNER`
## [p]leveldata resetcog
Reset the ENTIRE cog's data<br/>
- Usage: `[p]leveldata resetcog`
- Restricted to: `BOT_OWNER`
## [p]leveldata restore
Restore this server's data<br/>
- Usage: `[p]leveldata restore`
## [p]leveldata backupcog
Backup the cog's data<br/>
- Usage: `[p]leveldata backupcog`
- Restricted to: `BOT_OWNER`
## [p]leveldata cleanup
Cleanup the database<br/>
Performs the following actions:<br/>
- Delete data for users no longer in the server<br/>
- Removes channels and roles that no longer exist<br/>
- Usage: `[p]leveldata cleanup`
## [p]leveldata importfixator
Import data from Fixator's Leveler cog<br/>
This will overwrite existing LevelUp level data and stars<br/>
It will also import XP range level roles, and ignored channels<br/>
*Obviously you will need MongoDB running while you run this command*<br/>
- Usage: `[p]leveldata importfixator`
- Restricted to: `BOT_OWNER`
# [p]levelset
Configure LevelUp Settings<br/>
- Usage: `[p]levelset`
- Restricted to: `ADMIN`
- Aliases: `lvlset and lset`
- Checks: `server_only`
## [p]levelset showbalance
Toggle whether to show user's economy credit balance in their profile<br/>
- Usage: `[p]levelset showbalance`
- Aliases: `showbal`
## [p]levelset voice
Voice settings<br/>
- Usage: `[p]levelset voice`
### [p]levelset voice invisible
Ignore invisible voice users<br/>
Toggle whether invisible users in a voice channel can gain voice XP<br/>
- Usage: `[p]levelset voice invisible`
### [p]levelset voice deafened
Ignore deafened voice users<br/>
Toggle whether deafened users in a voice channel can gain voice XP<br/>
- Usage: `[p]levelset voice deafened`
### [p]levelset voice xp
Set voice XP gain<br/>
Sets the amount of XP gained per minute in a voice channel (default is 2)<br/>
- Usage: `[p]levelset voice xp <voice_xp>`
### [p]levelset voice rolebonus
Add a range of bonus XP to apply to certain roles<br/>
This bonus applies to voice time xp<br/>
Set both min and max to 0 to remove the role bonus<br/>
- Usage: `[p]levelset voice rolebonus <role> <min_xp> <max_xp>`
### [p]levelset voice solo
Ignore solo voice users<br/>
Toggle whether solo users in a voice channel can gain voice XP<br/>
- Usage: `[p]levelset voice solo`
### [p]levelset voice streambonus
Add a range of bonus XP to users who are Discord streaming<br/>
This bonus applies to voice time xp<br/>
Set both min and max to 0 to remove the bonus<br/>
- Usage: `[p]levelset voice streambonus <min_xp> <max_xp>`
### [p]levelset voice muted
Ignore muted voice users<br/>
Toggle whether self-muted users in a voice channel can gain voice XP<br/>
- Usage: `[p]levelset voice muted`
### [p]levelset voice channelbonus
Add a range of bonus XP to apply to certain channels<br/>
This bonus applies to voice time xp<br/>
Set both min and max to 0 to remove the role bonus<br/>
- Usage: `[p]levelset voice channelbonus <channel> <min_xp> <max_xp>`
## [p]levelset starcooldown
Set the star cooldown<br/>
Users can give another user a star every X seconds<br/>
- Usage: `[p]levelset starcooldown <seconds>`
## [p]levelset prestige
Prestige settings<br/>
- Usage: `[p]levelset prestige`
### [p]levelset prestige level
Set the level required to prestige<br/>
- Usage: `[p]levelset prestige level <level>`
### [p]levelset prestige stack
Toggle stacking roles on prestige<br/>
For example each time you prestige, you keep the previous prestige roles<br/>
- Usage: `[p]levelset prestige stack`
### [p]levelset prestige keeproles
Keep level roles after prestiging<br/>
- Usage: `[p]levelset prestige keeproles`
### [p]levelset prestige add
Add a role to a prestige level<br/>
- Usage: `[p]levelset prestige add <prestige> <role> <emoji>`
- Checks: `bot_has_server_permissions`
### [p]levelset prestige remove
Remove a prestige level<br/>
- Usage: `[p]levelset prestige remove <prestige>`
- Aliases: `rem and del`
## [p]levelset roles
Level role assignment<br/>
- Usage: `[p]levelset roles`
### [p]levelset roles add
Assign a role to a level<br/>
- Usage: `[p]levelset roles add <level> <role>`
### [p]levelset roles autoremove
Automatic removal of previous level roles<br/>
- Usage: `[p]levelset roles autoremove`
### [p]levelset roles initialize
Initialize level roles<br/>
This command is for if you added level roles after users have achieved that level,<br/>
it will apply all necessary roles to a user according to their level and prestige<br/>
- Usage: `[p]levelset roles initialize`
- Aliases: `init`
- Cooldown: `1 per 240.0 seconds`
### [p]levelset roles remove
Unassign a role from a level<br/>
- Usage: `[p]levelset roles remove <level>`
- Aliases: `rem and del`
## [p]levelset levelupmessages
Level up alert messages<br/>
**Arguments**<br/>
The following placeholders can be used:<br/>
`{username}`: The user's name<br/>
`{mention}`: Mentions the user<br/>
`{displayname}`: The user's display name<br/>
`{level}`: The level the user just reached<br/>
`{server}`: The server the user is in<br/>
**If using dmrole or msgrole**<br/>
`{role}`: The role the user just recieved<br/>
- Usage: `[p]levelset levelupmessages`
- Aliases: `lvlalerts, levelalerts, lvlmessages, and lvlmsg`
### [p]levelset levelupmessages msgrole
Set the message sent when a user levels up and recieves a role.<br/>
**Arguments**<br/>
The following placeholders can be used:<br/>
`{username}`: The user's name<br/>
`{mention}`: Mentions the user<br/>
`{displayname}`: The user's display name<br/>
`{level}`: The level the user just reached<br/>
`{server}`: The server the user is in<br/>
`{role}`: The role the user just recieved<br/>
- Usage: `[p]levelset levelupmessages msgrole [message]`
### [p]levelset levelupmessages msg
Set the message sent when a user levels up.<br/>
**Arguments**<br/>
The following placeholders can be used:<br/>
`{username}`: The user's name<br/>
`{mention}`: Mentions the user<br/>
`{displayname}`: The user's display name<br/>
`{level}`: The level the user just reached<br/>
`{server}`: The server the user is in<br/>
- Usage: `[p]levelset levelupmessages msg [message]`
### [p]levelset levelupmessages dmrole
Set the DM a user gets when they level up and recieve a role.<br/>
**Arguments**<br/>
The following placeholders can be used:<br/>
`{username}`: The user's name<br/>
`{mention}`: Mentions the user<br/>
`{displayname}`: The user's display name<br/>
`{level}`: The level the user just reached<br/>
`{server}`: The server the user is in<br/>
`{role}`: The role the user just recieved<br/>
- Usage: `[p]levelset levelupmessages dmrole [message]`
### [p]levelset levelupmessages dm
Set the DM a user gets when they level up (Without recieving a role).<br/>
**Arguments**<br/>
The following placeholders can be used:<br/>
`{username}`: The user's name<br/>
`{mention}`: Mentions the user<br/>
`{displayname}`: The user's display name<br/>
`{level}`: The level the user just reached<br/>
`{server}`: The server the user is in<br/>
- Usage: `[p]levelset levelupmessages dm [message]`
### [p]levelset levelupmessages view
View the current level up alert messages<br/>
- Usage: `[p]levelset levelupmessages view`
## [p]levelset view
View all LevelUP settings<br/>
- Usage: `[p]levelset view`
## [p]levelset addxp
Add XP to a user or role<br/>
- Usage: `[p]levelset addxp <user_or_role> <xp>`
## [p]levelset setlevel
Set a user's level<br/>
**Arguments**<br/>
`user` - The user to set the level for<br/>
`level` - The level to set the user to<br/>
- Usage: `[p]levelset setlevel <user> <level>`
## [p]levelset forcestyle
Force a profile style for all users<br/>
Specify `none` to disable the forced style<br/>
- Usage: `[p]levelset forcestyle <style>`
## [p]levelset starmentiondelete
Toggle whether the bot auto-deletes the star mentions<br/>
Set to 0 to disable auto-delete<br/>
- Usage: `[p]levelset starmentiondelete <deleted_after>`
## [p]levelset removexp
Remove XP from a user or role<br/>
- Usage: `[p]levelset removexp <user_or_role> <xp>`
## [p]levelset levelchannel
Set LevelUp log channel<br/>
Set a channel for all level up messages to send to.<br/>
If level notify is off and mention is on, the bot will mention the user in the channel<br/>
- Usage: `[p]levelset levelchannel [channel=None]`
## [p]levelset resetemojis
Reset the emojis to default<br/>
- Usage: `[p]levelset resetemojis`
## [p]levelset setprestige
Set a user to a specific prestige level<br/>
Prestige roles will need to be manually added/removed when using this command<br/>
- Usage: `[p]levelset setprestige <user> <prestige>`
## [p]levelset messages
Message settings<br/>
- Usage: `[p]levelset messages`
- Aliases: `message and msg`
### [p]levelset messages channelbonus
Add a range of bonus XP to apply to certain channels<br/>
This bonus applies to message xp<br/>
Set both min and max to 0 to remove the role bonus<br/>
- Usage: `[p]levelset messages channelbonus <channel> <min_xp> <max_xp>`
### [p]levelset messages xp
Set message XP range<br/>
Set the Min and Max amount of XP that a message can gain<br/>
Default is 3 min and 6 max<br/>
- Usage: `[p]levelset messages xp <min_xp> <max_xp>`
### [p]levelset messages rolebonus
Add a range of bonus XP to apply to certain roles<br/>
This bonus applies to message xp<br/>
Set both min and max to 0 to remove the role bonus<br/>
- Usage: `[p]levelset messages rolebonus <role> <min_xp> <max_xp>`
### [p]levelset messages length
Set minimum message length for XP<br/>
Minimum length a message must be to count towards XP gained<br/>
Set to 0 to disable<br/>
- Usage: `[p]levelset messages length <length>`
### [p]levelset messages cooldown
Cooldown threshold for message XP<br/>
When a user sends a message they will have to wait X seconds before their message<br/>
counts as XP gained<br/>
- Usage: `[p]levelset messages cooldown <cooldown>`
## [p]levelset rolegroup
Add or remove a role to the role group<br/>
These roles gain their own experience points as a group<br/>
When a member gains xp while having this role, the xp they earn is also added to the role group<br/>
- Usage: `[p]levelset rolegroup <role>`
## [p]levelset levelnotify
Send levelup message in the channel the user is typing in<br/>
Send a message in the channel a user is typing in when they level up<br/>
- Usage: `[p]levelset levelnotify`
## [p]levelset commandxp
Toggle whether users can gain Exp from running commands<br/>
- Usage: `[p]levelset commandxp`
## [p]levelset allowed
Base command for all allowed lists<br/>
- Usage: `[p]levelset allowed`
### [p]levelset allowed role
Add/Remove a role in the allowed list<br/>
If the allow list is not empty, only roles in the list will gain XP<br/>
Use the command with a role already in the allowed list to remove it<br/>
- Usage: `[p]levelset allowed role <role>`
### [p]levelset allowed channel
Add/Remove a channel in the allowed list<br/>
If the allow list is not empty, only channels in the list will gain XP<br/>
Use the command with a channel already in the allowed list to remove it<br/>
- Usage: `[p]levelset allowed channel <channel>`
## [p]levelset dm
Toggle DM notifications<br/>
Determines whether LevelUp messages are DM'd to the user<br/>
- Usage: `[p]levelset dm`
## [p]levelset starmention
Toggle star reaction mentions<br/>
Toggle whether the bot mentions that a user reacted to a message with a star<br/>
- Usage: `[p]levelset starmention`
## [p]levelset seelevels
Test the level algorithm<br/>
View the first 20 levels using the current algorithm to test experience curve<br/>
- Usage: `[p]levelset seelevels`
## [p]levelset ignore
Base command for all ignore lists<br/>
- Usage: `[p]levelset ignore`
### [p]levelset ignore channel
Add/Remove a channel in the ignore list<br/>
Channels in the ignore list don't gain XP<br/>
Use the command with a channel already in the ignore list to remove it<br/>
- Usage: `[p]levelset ignore channel <channel>`
### [p]levelset ignore role
Add/Remove a role in the ignore list<br/>
Members with roles in the ignore list don't gain XP<br/>
Use the command with a role already in the ignore list to remove it<br/>
- Usage: `[p]levelset ignore role <role>`
### [p]levelset ignore user
Add/Remove a user in the ignore list<br/>
Members in the ignore list don't gain XP<br/>
Use the command with a user already in the ignore list to remove them<br/>
- Usage: `[p]levelset ignore user <user>`
## [p]levelset algorithm
Customize the leveling algorithm for your server<br/>
• Default base is 100<br/>
• Default exp is 2<br/>
**Equation**<br/>
➣ Getting required XP for a level<br/>
`base * (level ^ exp) = XP`<br/>
➣ Getting required level for an XP value<br/>
`level = (XP / base) ^ (1 / exp)`<br/>
**Arguments**<br/>
`part` - The part of the algorithm to change<br/>
`value` - The value to set it to<br/>
- Usage: `[p]levelset algorithm <part> <value>`
- Aliases: `algo`
## [p]levelset mention
Toggle whether to mention the user in the level up message<br/>
If level notify is on AND a log channel is set, the user will only be mentioned in the channel they are in.<br/>
- Usage: `[p]levelset mention`
## [p]levelset toggle
Toggle the LevelUp system<br/>
- Usage: `[p]levelset toggle`
## [p]levelset emojis
Set the emojis used to represent each stat type<br/>
- Usage: `[p]levelset emojis <level> <prestige> <star> <chat> <voicetime> <experience> <balance>`
## [p]levelset embeds
Toggle using embeds or generated pics<br/>
- Usage: `[p]levelset embeds`

10
levelup/__init__.py Normal file
View file

@ -0,0 +1,10 @@
from redbot.core.bot import Red
from redbot.core.utils import get_end_user_data_statement
from .main import LevelUp
__red_end_user_data_statement__ = get_end_user_data_statement(__file__)
async def setup(bot: Red):
await bot.add_cog(LevelUp(bot))

129
levelup/abc.py Normal file
View file

@ -0,0 +1,129 @@
import asyncio
import multiprocessing as mp
import typing as t
from abc import ABC, ABCMeta, abstractmethod
from datetime import datetime
from pathlib import Path
import discord
from discord.ext.commands.cog import CogMeta
from redbot.core import commands
from redbot.core.bot import Red
from .common.models import DB, GuildSettings, Profile, VoiceTracking
from .generator.tenor.converter import TenorAPI
class CompositeMetaClass(CogMeta, ABCMeta):
"""Type detection"""
class MixinMeta(ABC):
"""Type hinting"""
def __init__(self, *_args):
self.bot: Red
# Cache
self.db: DB
self.lastmsg: t.Dict[int, t.Dict[int, float]]
self.voice_tracking: t.Dict[int, t.Dict[int, VoiceTracking]]
self.profile_cache: t.Dict[int, t.Dict[int, t.Tuple[str, bytes]]]
self.stars: t.Dict[int, t.Dict[int, datetime]]
self.cog_path: Path
self.bundled_path: Path
# Custom
self.custom_fonts: Path
self.custom_backgrounds: Path
# Bundled
self.stock: Path
self.fonts: Path
self.backgrounds: Path
# Save state
self.last_save: float
# Tenor
self.tenor: TenorAPI
# Internal API
self.api_proc: t.Union[asyncio.subprocess.Process, mp.Process]
@abstractmethod
def save(self) -> None:
raise NotImplementedError
@abstractmethod
async def start_api(self) -> bool:
raise NotImplementedError
@abstractmethod
async def stop_api(self) -> bool:
raise NotImplementedError
@abstractmethod
async def initialize_voice_states(self) -> int:
raise NotImplementedError
# -------------------------- levelups.py --------------------------
@abstractmethod
async def check_levelups(
self,
guild: discord.Guild,
member: discord.Member,
profile: Profile,
conf: GuildSettings,
message: t.Optional[discord.Message] = None,
channel: t.Optional[
t.Union[discord.TextChannel, discord.VoiceChannel, discord.Thread, discord.ForumChannel]
] = None,
) -> bool:
raise NotImplementedError
@abstractmethod
async def ensure_roles(
self,
member: discord.Member,
conf: t.Optional[GuildSettings] = None,
reason: t.Optional[str] = None,
) -> t.Tuple[t.List[discord.Role], t.List[discord.Role]]:
raise NotImplementedError
# -------------------------- weeklyreset.py --------------------------
@abstractmethod
async def reset_weekly(self, guild: discord.Guild, ctx: commands.Context = None) -> bool:
raise NotImplementedError
# -------------------------- profile.py --------------------------
@abstractmethod
async def add_xp(self, member: discord.Member, xp: int) -> int:
raise NotImplementedError
@abstractmethod
async def set_xp(self, member: discord.Member, xp: int) -> int:
raise NotImplementedError
@abstractmethod
async def remove_xp(self, member: discord.Member, xp: int) -> int:
raise NotImplementedError
@abstractmethod
async def get_profile_background(
self, user_id: int, profile: Profile, try_return_url: bool = False
) -> t.Union[bytes, str]:
raise NotImplementedError
@abstractmethod
async def get_banner(self, user_id: int) -> t.Optional[str]:
raise NotImplementedError
@abstractmethod
async def get_user_profile(
self, member: discord.Member, reraise: bool = False
) -> t.Union[discord.Embed, discord.File]:
raise NotImplementedError
@abstractmethod
async def get_user_profile_cached(self, member: discord.Member) -> t.Union[discord.File, discord.Embed]:
raise NotImplementedError

View file

@ -0,0 +1,19 @@
from ..abc import CompositeMetaClass
from .admin import Admin
from .data import DataAdmin
from .owner import Owner
from .stars import Stars
from .user import User
from .weekly import Weekly
class Commands(
Admin,
DataAdmin,
Owner,
Stars,
User,
Weekly,
metaclass=CompositeMetaClass,
):
"""Subclass all command classes"""

1359
levelup/commands/admin.py Normal file

File diff suppressed because it is too large Load diff

759
levelup/commands/data.py Normal file
View file

@ -0,0 +1,759 @@
import json
import logging
import math
import typing as t
from contextlib import suppress
from datetime import datetime
import discord
import orjson
from pydantic import ValidationError
from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import box, pagify, text_to_file
from ..abc import MixinMeta
from ..common import utils
from ..common.models import DB, GuildSettings
from ..views.dynamic_menu import DynamicMenu
_ = Translator("LevelUp", __file__)
log = logging.getLogger("red.vrt.levelup.commands.data")
@cog_i18n(_)
class DataAdmin(MixinMeta):
@commands.group(name="leveldata", aliases=["lvldata", "ldata"])
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def lvldata(self, ctx: commands.Context):
"""Admin Only Data Commands"""
@lvldata.command(name="cleanup")
async def cleanup_data(self, ctx: commands.Context):
"""Cleanup the database
Performs the following actions:
- Delete data for users no longer in the server
- Removes channels and roles that no longer exist
"""
conf = self.db.get_conf(ctx.guild)
txt = ""
pruned = 0
for user_id in list(conf.users.keys()):
member = ctx.guild.get_member(user_id)
if not member:
del conf.users[user_id]
pruned += 1
continue
if member.bot and self.db.ignore_bots:
del conf.users[user_id]
pruned += 1
if pruned:
txt += _("Pruned {} users from the database\n").format(pruned)
pruned = 0
for user_id in list(conf.users_weekly.keys()):
if not ctx.guild.get_member(user_id):
del conf.users_weekly[user_id]
pruned += 1
if pruned:
txt += _("Pruned {} users from the weekly database\n").format(pruned)
pruned = 0
for level in list(conf.levelroles.keys()):
if not ctx.guild.get_role(conf.levelroles[level]):
del conf.levelroles[level]
pruned += 1
if pruned:
txt += _("Pruned {} level roles from the database\n").format(pruned)
pruned = 0
for role_id in list(conf.role_groups.keys()):
if not ctx.guild.get_role(role_id):
del conf.role_groups[role_id]
pruned += 1
if pruned:
txt += _("Pruned {} role groups from the database\n").format(pruned)
pruned = 0
for channel_id in list(conf.ignoredchannels):
if not ctx.guild.get_channel(channel_id):
conf.ignoredchannels.remove(channel_id)
pruned += 1
if pruned:
txt += _("Pruned {} ignored channels from the database\n").format(pruned)
pruned = 0
for role_id in list(conf.ignoredroles):
if not ctx.guild.get_role(role_id):
conf.ignoredroles.remove(role_id)
pruned += 1
if pruned:
txt += _("Pruned {} ignored roles from the database\n").format(pruned)
pruned = 0
for role_id in list(conf.rolebonus.msg.keys()):
if not ctx.guild.get_role(role_id):
del conf.rolebonus.msg[role_id]
pruned += 1
if pruned:
txt += _("Pruned {} role bonuses from the database\n").format(pruned)
pruned = 0
for role_id in list(conf.rolebonus.voice.keys()):
if not ctx.guild.get_role(role_id):
del conf.rolebonus.voice[role_id]
pruned += 1
if pruned:
txt += _("Pruned {} voice role bonuses from the database\n").format(pruned)
pruned = 0
for channel_id in list(conf.channelbonus.msg.keys()):
if not ctx.guild.get_channel(channel_id):
del conf.channelbonus.msg[channel_id]
pruned += 1
if pruned:
txt += _("Pruned {} channel bonuses from the database\n").format(pruned)
pruned = 0
for channel_id in list(conf.channelbonus.voice.keys()):
if not ctx.guild.get_channel(channel_id):
del conf.channelbonus.voice[channel_id]
pruned += 1
if pruned:
txt += _("Pruned {} voice channel bonuses from the database\n").format(pruned)
if not txt:
await ctx.send(_("No data to prune!"))
self.save()
@lvldata.command(name="resetglobal")
@commands.is_owner()
async def reset_global(self, ctx: commands.Context):
"""Reset user data for all servers"""
msg = await ctx.send(_("This will reset all user data for all servers. Are you sure?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Reset cancelled!"))
for guild_id in list(self.db.configs.keys()):
self.db.configs[guild_id].users = {}
self.db.configs[guild_id].users_weekly = {}
self.save()
await msg.edit(content=_("Global data reset!"))
@lvldata.command(name="reset")
async def reset_user(self, ctx: commands.Context):
"""Reset all user data in this server"""
msg = await ctx.send(_("This will reset all user data for this server. Are you sure?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Reset cancelled!"))
conf = self.db.get_conf(ctx.guild)
conf.users = {}
conf.users_weekly = {}
self.save()
await msg.edit(content=_("Server data reset!"))
@lvldata.command(name="resetcog")
@commands.is_owner()
async def reset_cog(self, ctx: commands.Context):
"""Reset the ENTIRE cog's data"""
msg = await ctx.send(_("This will reset all data for this cog. Are you sure?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Reset cancelled!"))
self.db = DB()
self.save()
await msg.edit(content=_("Cog data reset!"))
@lvldata.command(name="backupcog")
@commands.is_owner()
@commands.bot_has_permissions(attach_files=True)
async def backup_cog(self, ctx: commands.Context):
"""Backup the cog's data"""
dump = self.db.dumpjson(pretty=True)
now = datetime.now().strftime("%m-%d-%Y-%H-%M-%S")
filename = f"LevelUp {now}.json"
file = text_to_file(dump, filename=filename)
await ctx.send(file=file)
@lvldata.command(name="backup")
async def backup_server(self, ctx: commands.Context):
"""Backup this server's data"""
server_name = ctx.guild.name
# Make sure the server name is safe for a filename
server_name = "".join([c for c in server_name if c.isalnum() or c in " -_"])
conf = self.db.get_conf(ctx.guild)
dump = conf.dumpjson(pretty=True)
now = datetime.now().strftime("%m-%d-%Y-%H-%M-%S")
filename = f"LevelUp {server_name} {now}.json"
file = text_to_file(dump, filename=filename)
await ctx.send(file=file)
@lvldata.command(name="restore")
async def restore_server(self, ctx: commands.Context):
"""Restore this server's data"""
attachments = utils.get_attachments(ctx)
if not attachments:
return await ctx.send(_("Please attach a backup file to the command message"))
att = attachments[0]
if not att.filename.endswith(".json"):
return await ctx.send(_("Backup file must be a JSON file"))
try:
conf = GuildSettings.loadjson(await att.read())
except ValidationError as e:
pages = [f"Errors\n{box(i, lang='json')}" for i in pagify(e.json(indent=2), page_length=1900)]
await ctx.send(_("Failed to restore data!"))
await DynamicMenu(ctx, pages).refresh()
return
self.db.configs[ctx.guild.id] = conf
self.save()
await ctx.send(_("Server data restored!"))
@lvldata.command(name="restorecog")
@commands.is_owner()
async def restore_cog(self, ctx: commands.Context):
"""Restore the cog's data"""
attachments = utils.get_attachments(ctx)
if not attachments:
return await ctx.send(_("Please attach a backup file to the command message"))
att = attachments[0]
if not att.filename.endswith(".json"):
return await ctx.send(_("Backup file must be a JSON file"))
try:
self.db = DB.loadjson(await att.read())
except ValidationError as e:
pages = [f"Errors\n{box(i, lang='json')}" for i in pagify(e.json(indent=2), page_length=1900)]
await ctx.send(_("Failed to restore data!"))
await DynamicMenu(ctx, pages).refresh()
return
self.save()
await ctx.send(_("Cog data restored!"))
@lvldata.command(name="importamari")
@commands.guildowner()
async def import_amari_data(
self,
ctx: commands.Context,
import_by: t.Literal["level", "exp"],
replace: bool,
api_key: str,
all_users: bool,
):
"""Import levels and exp from AmariBot
**Arguments**
`import_by` - Import by level or exp
If `level`, it will import their level and calculate exp from that.
If `exp`, it will import their exp directly and calculate level from that.
`replace` - Replace existing data (True/False)
If True, it will replace existing data.
`api_key` - Your [AmariBot API key](https://docs.google.com/forms/d/e/1FAIpQLScQDCsIqaTb1QR9BfzbeohlUJYA3Etwr-iSb0CRKbgjA-fq7Q/viewform?usp=send_form)
`all_users` - Import all users regardless of if they're in the server (True/False)
"""
with suppress(discord.HTTPException):
await ctx.message.delete()
msg = await ctx.send(_("Are you sure you want to import data from Amari bot's API?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Import cancelled!"))
await msg.edit(content=_("Fetching AmariBot leaderboard data, this could take a while..."))
# player_schema = {
# "id": int,
# "level": int,
# "exp": int,
# "weeklyExp": int,
# "username": str,
# }
players: t.List[t.Dict[str, t.Union[int, str]]] = []
pages = math.ceil(len(ctx.guild.members) / 1000)
failed_pages = 0
async with ctx.typing():
for i in range(pages):
try:
data, status = await utils.fetch_amari_payload(ctx.guild.id, i, api_key)
except Exception as e:
log.warning(
f"Failed to import page {i} of AmariBot leaderboard data in {ctx.guild}",
exc_info=e,
)
await ctx.send(f"Failed to import page {i} of AmariBot leaderboard data: {e}")
failed_pages += 1
if isinstance(e, json.JSONDecodeError):
await msg.edit(content=_("AmariBot is rate limiting too heavily! Import Failed!"))
return
continue
error_msg = data.get("error", None)
if status == 501:
# No more users
break
if status != 200:
if error_msg:
return await ctx.send(error_msg)
else:
return await ctx.send(_("No data found!"))
player_data = data.get("data")
if not player_data:
break
players.extend(player_data)
if failed_pages:
await ctx.send(
_("{} pages failed to fetch from AmariBot api, check logs for more info").format(str(failed_pages))
)
if not players:
return await ctx.send(_("No leaderboard data found!"))
await msg.edit(content=_("Data retrieved, importing..."))
conf = self.db.get_conf(ctx.guild)
imported = 0
failed = 0
async with ctx.typing():
for player in players:
uid = player["id"]
xp = player["exp"]
level = player["level"]
weekly_xp = player["weeklyExp"]
member = ctx.guild.get_member(int(uid))
if not member and not all_users:
failed += 1
continue
profile = conf.get_profile(int(uid))
weekly = conf.get_weekly_profile(int(uid)) if conf.weeklysettings.on else None
if replace:
if import_by == "level":
profile.level = level
profile.xp = conf.algorithm.get_xp(level)
else:
profile.xp = xp
profile.level = conf.algorithm.get_level(xp)
if weekly:
weekly.xp = weekly_xp
else:
if import_by == "level":
if level:
profile.level += level
profile.xp = conf.algorithm.get_xp(profile.level)
else:
if xp:
profile.xp += xp
profile.level = conf.algorithm.get_level(profile.xp)
if weekly:
weekly.xp += weekly_xp
imported += 1
if not imported and not failed:
await msg.edit(content=_("No AmariBot stats were found"))
else:
txt = _("Imported {} User(s)").format(str(imported))
if failed:
txt += _(" ({} skipped since they are no longer in the discord)").format(str(failed))
await msg.edit(content=txt)
await ctx.tick()
self.save()
@lvldata.command(name="importfixator")
@commands.is_owner()
async def import_from_fixator(self, ctx: commands.Context):
"""
Import data from Fixator's Leveler cog
This will overwrite existing LevelUp level data and stars
It will also import XP range level roles, and ignored channels
*Obviously you will need MongoDB running while you run this command*
"""
path = self.cog_path / "Leveler" / "settings.json"
if not path.exists():
return await ctx.send(_("No config found for Fixator's Leveler cog!"))
data = orjson.loads(path.read_text())
# Get the first key in the dict
# Usually 78008101945374987542543513523680608657
config_id = list(data.keys())[0]
data = data[config_id]
default_mongo_config = {
"host": "localhost",
"port": 27017,
"username": None,
"password": None,
"db_name": "leveler",
}
mongo_config = data.get("MONGODB", default_mongo_config)
global_config = data["GLOBAL"]
guild_config = data["GUILD"]
# If leveler is installed then libs should import fine
try:
from motor.motor_asyncio import AsyncIOMotorClient # type: ignore
from pymongo import errors as mongoerrors # type: ignore
except Exception as e:
log.warning(f"pymongo Import Error: {e}")
txt = _(
"Failed to import `pymongo` and `motor` libraries. Run `{}pipinstall pymongo` and `{}pipinstall motor`"
).format(ctx.clean_prefix, ctx.clean_prefix)
return await ctx.send(txt)
msg = await ctx.send(_("Are you sure you want to import data from Fixator's Leveler cog?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Import cancelled!"))
await msg.edit(content=_("Importing data..."))
try:
client = AsyncIOMotorClient(**{k: v for k, v in mongo_config.items() if k != "db_name"})
await client.server_info()
db = client[mongo_config["db_name"]]
except (
mongoerrors.ServerSelectionTimeoutError,
mongoerrors.ConfigurationError,
mongoerrors.OperationFailure,
) as e:
log.error(f"Failed to connect to MongoDB: {e}")
return await ctx.send(_("Failed to connect to MongoDB. Check your connection and try again."))
imported = 0
async with ctx.typing():
min_message_length = global_config.get("message_length", 0)
mention = global_config.get("mention", False)
xp_range = global_config.get("xp", [1, 5])
for guild in self.bot.guilds:
guild_id = str(guild.id)
conf = self.db.get_conf(guild)
conf.min_length = min_message_length
conf.mention = mention
conf.xp_range = xp_range
conf.ignoredchannels = guild_config.get(guild_id, {}).get("ignored_channels", [])
if server_roles := await db.roles.find_one({"guild_id": guild_id}):
for rolename, data in server_roles["roles"].items():
role = guild.get_role(int(rolename))
if not role:
continue
conf.levelroles[int(data["level"])] = role.id
for user in guild.members:
user_id = str(user.id)
try:
userinfo = await db.users.find_one({"user_id": user_id})
except Exception as e:
log.info(f"No data found for {user_id}: {e}")
continue
if not userinfo:
continue
profile = conf.get_profile(user)
if guild_id in userinfo["servers"]:
profile.level = userinfo["servers"][guild_id]["level"]
profile.xp = conf.algorithm.get_xp(profile.level)
profile.stars = int(userinfo["rep"]) if userinfo["rep"] else 0
imported += 1
if not imported:
return await msg.edit(content=_("There was no data to import!"))
self.save()
await msg.edit(content=_("Imported data for {} users from Fixator's Leveler cog!").format(imported))
@lvldata.command(name="importmalarne")
@commands.is_owner()
async def import_from_malarne(
self,
ctx: commands.Context,
import_by: t.Literal["level", "exp"],
replace: bool,
all_users: bool,
):
"""Import levels and exp from Malarne's Leveler cog
**Arguments**
`import_by` - Import by level or exp
If `level`, it will import their level and calculate exp from that.
If `exp`, it will import their exp directly and calculate level from that.
`replace` - Replace existing data (True/False)
If True, it will replace existing data.
`all_users` - Import all users regardless of if they're in the server (True/False)
"""
path = self.cog_path / "UserProfile" / "settings.json"
if not path.exists():
return await ctx.send(_("No config found for Malarne's Leveler cog!"))
msg = await ctx.send(_("Are you sure you want to import data from Malarne's Leveler cog?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Import cancelled!"))
await msg.edit(content=_("Fetching Mee6 leaderboard data, this could take a while..."))
data = orjson.loads(path.read_text())["1099710897114110101"]["MEMBER"]
imported = 0
async with ctx.typing():
for guild_id, profiles in data.items():
guild = self.bot.get_guild(int(guild_id))
if not guild and not all_users:
continue
conf = self.db.get_conf(int(guild_id))
for user_id, data in profiles.items():
user = guild.get_member(int(user_id)) if guild else None
if not user and not all_users:
continue
level = data.get("level", 0)
xp = data.get("exp", 0)
if not level and not xp:
continue
profile = conf.get_profile(int(user_id))
if replace:
if import_by == "level" and level:
profile.level = level
profile.xp = conf.algorithm.get_xp(level)
elif xp:
profile.xp = xp
profile.level = conf.algorithm.get_level(xp)
else:
if import_by == "level" and level:
profile.level += level
profile.xp = conf.algorithm.get_xp(profile.level)
elif xp:
profile.xp += xp
profile.level = conf.algorithm.get_level(profile.xp)
imported += 1
if not imported:
return await ctx.send(_("There were no profiles to import"))
txt = _("Imported {} profile(s)").format(imported)
await ctx.send(txt)
self.save()
@lvldata.command(name="importmee6")
@commands.guildowner()
async def import_from_mee6(
self,
ctx: commands.Context,
import_by: t.Literal["level", "exp"],
replace: bool,
include_settings: bool,
all_users: bool,
):
"""Import levels and exp from MEE6
**Arguments**
`import_by` - Import by level or exp
If `level`, it will import their level and calculate exp from that.
If `exp`, it will import their exp directly and calculate level from that.
`replace` - Replace existing data (True/False)
`include_settings` - Include MEE6 settings (True/False)
`all_users` - Import all users regardless of if they're in the server (True/False)
"""
msg = await ctx.send(_("Are you sure you want to import data from Mee6?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Import cancelled!"))
await msg.edit(content=_("Fetching Mee6 leaderboard data, this could take a while..."))
pages = math.ceil(len(ctx.guild.members) / 1000)
# player_schema = {"id": int, "exp": int}
players: t.List[t.Dict[str, t.Union[int, str]]] = []
failed_pages = 0
settings_imported = False
conf = self.db.get_conf(ctx.guild)
async with ctx.typing():
for i in range(pages):
try:
data, status = await utils.fetch_mee6_payload(ctx.guild.id, i)
except Exception as e:
log.warning(
f"Failed to import page {i} of Mee6 leaderboard data in {ctx.guild}",
exc_info=e,
)
await ctx.send(f"Failed to import page {i} of Mee6 leaderboard data: {e}")
failed_pages += 1
if isinstance(e, json.JSONDecodeError):
await msg.edit(content=_("Mee6 is rate limiting too heavily! Import Failed!"))
return
continue
error = data.get("error", {})
error_msg = error.get("message", None)
if status != 200:
if status == 401:
return await ctx.send(_("Your leaderboard needs to be set to public!"))
elif error_msg:
return await ctx.send(error_msg)
else:
return await ctx.send(_("No data found!"))
if include_settings and not settings_imported:
settings_imported = True
if xp_rate := data.get("xp_rate"):
conf.algorithm.base = round(xp_rate * 100)
if xp_per_message := data.get("xp_per_message"):
conf.xp = xp_per_message
if role_rewards := data.get("role_rewards"):
for entry in role_rewards:
role_id = int(entry["role_id"])
if not ctx.guild.get_role(role_id):
continue
conf.levelroles[int(entry["rank"])] = role_id
await ctx.send("Settings imported!")
player_data = data.get("players")
if not player_data:
break
players.extend(player_data)
if failed_pages:
await ctx.send(
_("{} pages failed to fetch from mee6 api, check logs for more info").format(str(failed_pages))
)
if not players:
return await ctx.send(_("No leaderboard data found!"))
await msg.edit(content=_("Data retrieved, importing..."))
imported = 0
failed = 0
async with ctx.typing():
for user in players:
uid = str(user["id"])
lvl = int(user["level"])
xp = float(user["xp"])
member = ctx.guild.get_member(int(uid))
if not member and not all_users:
failed += 1
continue
if replace:
if import_by == "level":
profile = conf.get_profile(int(uid))
profile.level = lvl
profile.xp = conf.algorithm.get_xp(lvl)
else:
profile = conf.get_profile(int(uid))
profile.xp = xp
profile.level = conf.algorithm.get_level(xp)
else:
if import_by == "level":
profile = conf.get_profile(int(uid))
profile.level += lvl
profile.xp = conf.algorithm.get_xp(profile.level)
else:
profile = conf.get_profile(int(uid))
profile.xp += xp
profile.level = conf.algorithm.get_level(profile.xp)
imported += 1
if not imported and not failed:
await msg.edit(content=_("No MEE6 stats were found"))
else:
txt = _("Imported {} User(s)").format(str(imported))
if failed:
txt += _(" ({} skipped since they are no longer in the discord)").format(str(failed))
await msg.edit(content=txt)
await ctx.tick()
self.save()
@lvldata.command(name="importpolaris")
@commands.guildowner()
async def import_from_polaris(
self,
ctx: commands.Context,
replace: bool,
include_settings: bool,
all_users: bool,
):
"""
Import levels and exp from Polaris
**Make sure your guild's leaderboard is public!**
**Arguments**
`replace` - Replace existing data (True/False)
`include_settings` - Include Polaris settings (True/False)
`all_users` - Import all users regardless of if they're in the server (True/False)
[Polaris](https://gdcolon.com/polaris/)
"""
msg = await ctx.send(_("Are you sure you want to import data from Polaris?"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Import cancelled!"))
await msg.edit(content=_("Fetching Polaris leaderboard data, this could take a while..."))
# player_schema = {"id": int, "exp": int}
players: t.List[t.Dict[str, t.Union[int, str]]] = []
failed_pages = 0
settings_imported = False
conf = self.db.get_conf(ctx.guild)
async with ctx.typing():
for page in range(10):
try:
data, status = await utils.fetch_polaris_payload(ctx.guild.id, page)
except Exception as e:
log.warning(
f"Failed to import page {page} of Polaris leaderboard data in {ctx.guild}",
exc_info=e,
)
await ctx.send(f"Failed to import page {page} of Polaris leaderboard data: {e}")
failed_pages += 1
if isinstance(e, json.JSONDecodeError):
await msg.edit(content=_("Polaris is rate limiting too heavily! Import Failed!"))
return
continue
error = data.get("error", {})
error_msg = error.get("message", None)
if status != 200:
if status == 401:
return await ctx.send(_("Your leaderboard needs to be set to public!"))
elif error_msg:
return await ctx.send(error_msg)
else:
return await ctx.send(_("No data found!"))
if include_settings and not settings_imported:
settings_imported = True
if settings := data.get("settings"):
if gain := settings.get("gain"):
conf.xp = [gain["min"], gain["max"]]
conf.cooldown = gain["time"]
if curve := settings.get("curve"):
# The cubic curve doesn't translate to quadratic easily, so we won't import this
# cubed = curve["3"]
# squared = curve["2"]
# base = curve["1"]
conf.algorithm.base = curve["1"]
if role_rewards := data.get("rewards"):
for entry in role_rewards:
role = ctx.guild.get_role(int(entry["id"]))
if not role:
continue
conf.levelroles[int(entry["level"])] = role.id
await ctx.send(_("Settings Imported!"))
player_data = data.get("leaderboard")
if not player_data:
break
players.extend(player_data)
if failed_pages:
await ctx.send(
_("{} pages failed to fetch from Polaris api, check logs for more info").format(str(failed_pages))
)
if not players:
return await ctx.send(_("No leaderboard data found!"))
await msg.edit(content=_("Data retrieved, importing..."))
imported = 0
failed = 0
async with ctx.typing():
for user in players:
uid = str(user["id"])
xp = float(user["xp"])
member = ctx.guild.get_member(int(user["id"]))
if not member and not all_users:
failed += 1
continue
profile = conf.get_profile(int(uid))
if replace:
profile.xp = xp
profile.level = conf.algorithm.get_level(xp)
elif xp:
profile.xp += xp
profile.level = conf.algorithm.get_level(profile.xp)
imported += 1
if not imported and not failed:
await msg.edit(content=_("No Polaris stats were found"))
else:
txt = _("Imported {} User(s)").format(str(imported))
if failed:
txt += _(" ({} skipped since they are no longer in the discord)").format(str(failed))
await msg.edit(content=txt)
await ctx.tick()
self.save()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

299
levelup/commands/owner.py Normal file
View file

@ -0,0 +1,299 @@
import asyncio
import random
from io import BytesIO, StringIO
import discord
from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n
from ..abc import MixinMeta
from ..common import utils
from ..generator import imgtools, levelalert
_ = Translator("LevelUp", __file__)
@cog_i18n(_)
class Owner(MixinMeta):
@commands.group(name="levelowner", aliases=["lvlowner"])
@commands.guild_only()
@commands.is_owner()
async def lvlowner(self, ctx: commands.Context):
"""Owner Only LevelUp Settings"""
pass
@lvlowner.command(name="view")
@commands.bot_has_permissions(embed_links=True)
async def lvlowner_view(self, ctx: commands.Context):
"""View Global LevelUp Settings"""
def _size():
size_bytes = utils.deep_getsizeof(self.db)
size_bytes += utils.deep_getsizeof(self.lastmsg)
size_bytes += utils.deep_getsizeof(self.voice_tracking)
size_bytes += utils.deep_getsizeof(self.profile_cache)
return size_bytes
embed = discord.Embed(color=await self.bot.get_embed_color(ctx))
size = await asyncio.to_thread(_size)
embed.add_field(
name=_("Global Settings"),
value=_("`Profile Cache Time: `{}\n" "`Cache Size: `{}\n").format(
utils.humanize_delta(self.db.cache_seconds),
utils.humanize_size(size),
),
inline=False,
)
if self.db.ignore_bots:
txt = _("Bots are ignored for all servers and cannot gain XP or have profiles")
else:
txt = _("Bots can gain XP and have profiles")
embed.add_field(
name=_("Ignore Bots"),
value=txt,
inline=False,
)
if self.db.render_gifs:
txt = _("Users with animated profiles will render as a GIF")
else:
txt = _("Profiles will always be static images")
embed.add_field(
name=_("GIF Profile Rendering"),
value=txt,
inline=False,
)
if self.db.force_embeds:
txt = _("Profile embeds are enforced for all servers")
else:
txt = _("Profile embeds are optional for all servers")
embed.add_field(
name=_("Profile Embed Enforcement"),
value=txt,
inline=False,
)
tokens = await self.bot.get_shared_api_tokens("tenor")
txt = _("*The Tenor API can be used to set gifs as profile backgrounds easier from within discord.*\n")
if "api_key" in tokens:
txt += _("Tenor API Key is set!")
else:
txt += _("Tenor API Key is not set! You can set it with {}").format(
f"`{ctx.clean_prefix}set api tenor api_key YOUR_KEY`"
)
embed.add_field(
name=_("Tenor API"),
value=txt,
inline=False,
)
ignored_servers = StringIO()
for guild_id in self.db.ignored_guilds:
guild = self.bot.get_guild(guild_id)
if guild:
ignored_servers.write(f"{guild_id} ({guild.name})\n")
else:
ignored_servers.write(_("{} (Bot not in server)\n").format(guild_id))
# Imgen API section
txt = _(
"*If an internal API port is specified, the bot will spin up subprocesses to handle image generation.*\n"
"- **Internal API Port:** {}\n"
"*If an external API URL is specified, the bot will use that URL for image generation.*\n"
"- **External API URL:** {}\n"
).format(
self.db.internal_api_port or _("Not Using"),
self.db.external_api_url or _("Not Using"),
)
embed.add_field(
name=_("API Settings"),
value=txt,
inline=False,
)
status = _("Enabled") if self.db.auto_cleanup else _("Disabled")
embed.add_field(
name=_("Auto-Cleanup ({})").format(status),
value=_("If enabled, the bot will auto-purge configs of guilds that the bot is no longer in."),
inline=False,
)
embed.add_field(
name=_("Ignored Servers"),
value=ignored_servers.getvalue() or _("None"),
inline=False,
)
await ctx.send(embed=embed)
@lvlowner.command(name="ignorebots")
async def toggle_ignore_bots(self, ctx: commands.Context):
"""Toggle ignoring bots for XP and profiles
**USE AT YOUR OWN RISK**
Allowing your bot to listen to other bots is a BAD IDEA and should NEVER be enabled on public bots.
"""
if self.db.ignore_bots:
self.db.ignore_bots = False
await ctx.send(_("Bots can now have profiles and gain XP like normal users. Proceed with caution..."))
else:
self.db.ignore_bots = True
await ctx.send(_("Bots are now ignored entirely by LevelUp"))
self.save()
@lvlowner.command(name="autoclean")
async def toggle_auto_cleanup(self, ctx: commands.Context):
"""Toggle purging of config data for guilds the bot is no longer in"""
if self.db.auto_cleanup:
self.db.auto_cleanup = False
await ctx.send(_("Auto-Cleanup disabled."))
else:
self.db.auto_cleanup = True
await ctx.send(_("Auto-Cleanup enabled."))
bad_keys = [i for i in self.db.configs if not self.bot.get_guild(i)]
for key in bad_keys:
del self.db.configs[key]
if bad_keys:
await ctx.send(_("Purged {} guilds from the database.").format(len(bad_keys)))
self.save()
@lvlowner.command(name="internalapi")
async def set_internal_api(self, ctx: commands.Context, port: int):
"""
Enable internal API for parallel image generation
Setting a port will spin up a detatched but cog-managed FastAPI server to handle image generation.
The process ID will be attached to the bot object and persist through reloads.
**USE AT YOUR OWN RISK!!!**
Using the internal API will spin up multiple subprocesses to handle bulk image generation.
If your bot crashes, the API subprocess will not be killed and will need to be manually terminated!
It is HIGHLY reccommended to host the api separately!
Set to 0 to disable the internal API
**Notes**
- This will spin up a 1 worker per core on the bot's cpu.
- If the API fails, the cog will fall back to the default image generation method.
"""
if port:
if self.db.internal_api_port == port:
return await ctx.send(_("Internal API port already set to {}, no change.").format(port))
self.db.internal_api_port = port
if self.api_proc:
# Changing port so stop and start the server
await ctx.send(_("Internal API port changed to {}, Restarting workers").format(port))
await self.stop_api()
await self.start_api()
else:
await ctx.send(_("Internal API port set to {}, Spinning up workers").format(port))
await self.start_api()
else:
self.db.internal_api_port = port
await ctx.send(_("Internal API disabled, shutting down workers."))
await self.stop_api()
self.save()
@lvlowner.command(name="externalapi")
async def set_external_api(self, ctx: commands.Context, url: str):
"""
Set the external API URL for image generation
Set to an `none` to disable the external API
**Notes**
- If the API fails, the cog will fall back to the default image generation method.
"""
if url == "none":
txt = _("External API disabled")
self.db.external_api_url = ""
self.save()
# If interal api is set, start it up
if self.db.internal_api_port:
await self.start_api()
txt += _("\nInternal API started since port was set.")
return await ctx.send(txt)
if not url.startswith("http"):
return await ctx.send(_("Invalid URL"))
self.db.external_api_url = url
await ctx.send(_("External API URL set to `{}`").format(url))
self.save()
@lvlowner.command(name="rendergifs", aliases=["rendergif", "gif"])
async def toggle_gif_rendering(self, ctx: commands.Context):
"""Toggle rendering of GIFs for animated profiles"""
if self.db.render_gifs:
self.db.render_gifs = False
await ctx.send(_("GIF rendering disabled."))
else:
self.db.render_gifs = True
await ctx.send(_("GIF rendering enabled."))
self.save()
@lvlowner.command(name="forceembeds", aliases=["forceembed"])
async def toggle_force_embeds(self, ctx: commands.Context):
"""Toggle enforcing profile embeds
If enabled, profiles will only use embeds on all servers.
This disables image generation globally.
"""
if self.db.force_embeds:
self.db.force_embeds = False
await ctx.send(_("Profile embeds are now optional for other servers."))
else:
self.db.force_embeds = True
await ctx.send(_("Profile embeds are now enforced on all servers."))
self.save()
@lvlowner.command(name="ignore")
async def ignore_server(self, ctx: commands.Context, guild_id: int):
"""Add/Remove a server from the ignore list"""
if guild_id in self.db.ignored_guilds:
self.db.ignored_guilds.remove(guild_id)
await ctx.send(_("Server no longer ignored."))
else:
self.db.ignored_guilds.append(guild_id)
await ctx.send(_("Server ignored."))
self.save()
@lvlowner.command(name="cache")
async def set_cache(self, ctx: commands.Context, seconds: int):
"""Set the cache time for user profiles"""
self.db.cache_seconds = seconds
await ctx.send(_("Cache time set to {} seconds.").format(seconds))
self.save()
@commands.command(name="mocklvl", hidden=True)
@commands.is_owner()
@commands.bot_has_permissions(attach_files=True)
async def test_levelup(self, ctx: commands.Context):
"""Test LevelUp Image Generation"""
conf = self.db.get_conf(ctx.guild)
profile = conf.get_profile(ctx.author)
async with ctx.typing():
avatar = await ctx.author.display_avatar.read()
banner = await ctx.author.banner.read() if ctx.author.banner else None
if not banner:
banner_url = await self.get_banner(ctx.author.id)
if banner_url:
banner = await utils.get_content_from_url(banner_url)
level = random.randint(1, 100)
fonts = list(imgtools.DEFAULT_FONTS.glob("*.ttf"))
font = str(random.choice(fonts))
if profile.font:
if (self.fonts / profile.font).exists():
font = str(self.fonts / profile.font)
elif (self.custom_fonts / profile.font).exists():
font = str(self.custom_fonts / profile.font)
def _run() -> discord.File:
img_bytes, animated = levelalert.generate_level_img(
background_bytes=banner,
avatar_bytes=avatar,
level=level,
font_path=font,
color=ctx.author.color.to_rgb(),
render_gif=self.db.render_gifs,
)
ext = "gif" if animated else "webp"
return discord.File(BytesIO(img_bytes), filename=f"levelup.{ext}")
file = await asyncio.to_thread(_run)
await ctx.send(file=file)

169
levelup/commands/stars.py Normal file
View file

@ -0,0 +1,169 @@
import asyncio
import typing as t
from datetime import datetime, timedelta
import discord
from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n
from ..abc import MixinMeta
from ..common import formatter, utils
from ..views.dynamic_menu import DynamicMenu
_ = Translator("LevelUp", __file__)
@cog_i18n(_)
class Stars(MixinMeta):
@commands.hybrid_command(name="stars", aliases=["givestar", "addstar", "thanks"])
@commands.guild_only()
async def give_stars(self, ctx: commands.Context, *, user: t.Optional[discord.Member] = None):
"""Reward a good noodle"""
if user and user.id == ctx.author.id:
return await ctx.send(_("You can't give stars to yourself!"), ephemeral=True)
if user and user.bot and self.db.ignore_bots:
return await ctx.send(_("You can't give stars to bots!"), ephemeral=True)
last_used = self.stars.setdefault(ctx.guild.id, {}).get(ctx.author.id)
conf = self.db.get_conf(ctx.guild)
now = datetime.now()
if not user and not last_used:
# User has not given a star yet, just send help
return await ctx.send_help()
elif not user and last_used:
# User has given a star, but they didnt mention anyone
can_use_after = last_used + timedelta(seconds=conf.starcooldown)
if now > can_use_after:
txt = _("You can give more stars now! Just mention a user in this command.")
else:
ts = f"<t:{int(can_use_after.timestamp())}:R>"
txt = _("You can give more stars {}").format(ts)
return await ctx.send(txt, ephemeral=True)
elif not user:
return await ctx.send(_("You need to mention a user to give them a star!"))
elif last_used:
can_use_after = last_used + timedelta(seconds=conf.starcooldown)
if now < can_use_after:
ts = f"<t:{int(can_use_after.timestamp())}:R>"
return await ctx.send(
_("You can give more stars {}").format(ts),
ephemeral=True,
)
self.stars[ctx.guild.id][ctx.author.id] = now
profile = conf.get_profile(user)
profile.stars += 1
if conf.weeklysettings.on:
weekly = conf.get_weekly_profile(user)
weekly.stars += 1
self.save()
name = user.mention if conf.starmention else f"**{user.display_name}**"
kwargs = {"ephemeral": True}
if conf.starmentionautodelete:
kwargs["delete_after"] = conf.starmentionautodelete
await ctx.send(_("You just gave a star to {}!").format(name), **kwargs)
@commands.command(name="startop", aliases=["topstars", "starleaderboard", "starlb"])
@commands.guild_only()
async def startop(
self,
ctx: commands.Context,
globalstats: bool = False,
displayname: bool = True,
):
"""View the Star Leaderboard"""
stat = "stars"
pages = await asyncio.to_thread(
formatter.get_leaderboard,
bot=self.bot,
guild=ctx.guild,
db=self.db,
stat=stat,
lbtype="lb",
is_global=globalstats,
member=ctx.author,
use_displayname=displayname,
color=await self.bot.get_embed_color(ctx),
)
if isinstance(pages, str):
return await ctx.send(pages)
await DynamicMenu(ctx, pages).refresh()
@commands.group(name="starset")
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def starset(self, ctx: commands.Context) -> None:
"""Configure LevelUp Star Settings"""
pass
@starset.command(name="view")
@commands.bot_has_permissions(embed_links=True)
async def starset_view(self, ctx: commands.Context) -> None:
"""View Star Settings"""
conf = self.db.get_conf(ctx.guild)
embed = discord.Embed(
title=_("Star Settings"),
color=await self.bot.get_embed_color(ctx),
)
delta = utils.humanize_delta(conf.starcooldown)
embed.add_field(
name=_("Cooldown"),
value=_("Users can give stars every {}").format(delta),
inline=False,
)
embed.add_field(
name=_("Star Mention"),
value=_("The bot will{} send a message when someone gives a star").format(
"" if conf.starmention else _(" **not**")
),
inline=False,
)
if conf.starmentionautodelete:
txt = _("The bot will delete the star message after {}").format(
utils.humanize_delta(conf.starmentionautodelete)
)
else:
txt = _("The bot will **not** delete the star message after sending it")
embed.add_field(
name=_("Star Mention Auto Delete"),
value=txt,
inline=False,
)
await ctx.send(embed=embed)
@starset.command(name="cooldown")
async def starset_cooldown(self, ctx: commands.Context, cooldown: int) -> None:
"""Set the star cooldown"""
conf = self.db.get_conf(ctx.guild)
conf.starcooldown = cooldown
await ctx.send(_("Cooldown set to {}").format(utils.humanize_delta(cooldown)))
self.save()
@starset.command(name="mention")
async def starset_mention(self, ctx: commands.Context) -> None:
"""Toggle star reaction mentions"""
conf = self.db.get_conf(ctx.guild)
if conf.starmention:
conf.starmention = False
await ctx.send(_("Star mention disabled"))
else:
conf.starmention = True
await ctx.send(_("Star mention enabled"))
self.save()
@starset.command(name="mentiondelete")
async def starset_mentionautodelete(self, ctx: commands.Context, delete_after: int) -> None:
"""Toggle whether the bot auto-deletes the star mentions
Set to 0 to disable auto-delete
"""
conf = self.db.get_conf(ctx.guild)
conf.starmentionautodelete = delete_after
if delete_after:
await ctx.send(_("Star mention auto delete set to {}").format(delete_after))
else:
await ctx.send(_("Star mention auto delete disabled"))
self.save()

846
levelup/commands/user.py Normal file
View file

@ -0,0 +1,846 @@
import asyncio
import logging
import typing as t
from contextlib import suppress
from io import BytesIO
from pathlib import Path
import discord
from aiocache import cached
from discord import app_commands
from discord.app_commands import Choice
from redbot.core import commands
from redbot.core.bot import Red
from redbot.core.i18n import Translator, cog_i18n
from redbot.core.utils.chat_formatting import humanize_list
from ..abc import MixinMeta
from ..common import const, formatter, utils
from ..generator import imgtools
from ..generator.tenor.converter import sanitize_url
from ..views.dynamic_menu import DynamicMenu
_ = Translator("LevelUp", __file__)
log = logging.getLogger("red.vrt.levelup.commands.user")
@app_commands.context_menu(name="View Profile")
@app_commands.guild_only()
async def view_profile_context(interaction: discord.Interaction, member: discord.Member):
"""View a user's profile"""
bot: Red = interaction.client
cog = bot.get_cog("LevelUp")
if not cog:
return await interaction.response.send_message(_("LevelUp is not loaded!"), ephemeral=True)
if member.bot and cog.db.ignore_bots:
return await interaction.response.send_message(_("Bots cannot have profiles!"), ephemeral=True)
if not isinstance(interaction.user, discord.Member):
return await interaction.response.send_message(_("This user is no longer in the server!"), ephemeral=True)
if not isinstance(member, discord.Member):
return await interaction.response.send_message(_("This user is no longer in the server!"), ephemeral=True)
with suppress(discord.HTTPException):
await interaction.response.defer(ephemeral=True)
result = await cog.get_user_profile_cached(member)
try:
if isinstance(result, discord.Embed):
await interaction.followup.send(embed=result, ephemeral=True)
else:
await interaction.followup.send(file=result, ephemeral=True)
except discord.HTTPException:
if isinstance(result, discord.Embed):
await interaction.channel.send(embed=result)
else:
result.fp.seek(0)
await interaction.channel.send(file=result)
@cog_i18n(_)
class User(MixinMeta):
@commands.hybrid_command(name="leveltop", aliases=["lvltop", "topstats", "membertop", "topranks"])
@commands.guild_only()
async def leveltop(
self,
ctx: commands.Context,
stat: str = "xp",
globalstats: bool = False,
displayname: bool = True,
):
"""View the LevelUp leaderboard
**Arguments**
`stat` - The stat to view the leaderboard for, defaults to `exp` but can be any of the following:
- `xp` - Experience
- `level` - Level
- `voice` - Voicetime
- `messages` - Messages
- `stars` - Stars
`globalstats` - View the global leaderboard instead of the server leaderboard
`displayname` - Use display names instead of usernames
"""
stat = stat.lower()
pages = await asyncio.to_thread(
formatter.get_leaderboard,
bot=self.bot,
guild=ctx.guild,
db=self.db,
stat=stat,
lbtype="lb",
is_global=globalstats,
member=ctx.author,
use_displayname=displayname,
color=await self.bot.get_embed_color(ctx),
)
if isinstance(pages, str):
return await ctx.send(pages)
await DynamicMenu(ctx, pages).refresh()
@commands.command(name="roletop")
@commands.guild_only()
async def role_group_leaderboard(self, ctx: commands.Context):
"""View the leaderboard for roles"""
conf = self.db.get_conf(ctx.guild)
if not conf.role_groups:
return await ctx.send(_("Role groups have not been configured in this server yet!"))
pages = await asyncio.to_thread(
formatter.get_role_leaderboard,
rolegroups=conf.role_groups,
color=await self.bot.get_embed_color(ctx),
)
if not pages:
return await ctx.send(_("No data available yet!"))
await DynamicMenu(ctx, pages).refresh()
@commands.command(name="profiledata")
@commands.guild_only()
@commands.mod_or_permissions(manage_messages=True)
async def profile_data(self, ctx: commands.Context, user_id: int):
"""View a user's profile by ID
Useful if user has left the server
"""
conf = self.db.get_conf(ctx.guild)
if not conf.enabled:
return await ctx.send(_("Leveling is disabled in this server!"))
if user_id not in conf.users:
return await ctx.send(_("That user has no level data yet!"))
profile = conf.get_profile(user_id)
txt = _(
"XP: **{}**\nLevel: **{}**\nPrestige: **{}**\nVoicetime: **{}**\nMessages: **{}**\nStars: **{}**\n"
).format(
profile.xp,
profile.level,
profile.prestige,
utils.humanize_delta(int(profile.voice)),
profile.messages,
profile.stars,
)
await ctx.send(txt)
@commands.hybrid_command(name="profile", aliases=["pf"])
@commands.guild_only()
@commands.cooldown(3, 10, commands.BucketType.user)
async def profile(self, ctx: commands.Context, *, user: t.Optional[discord.Member] = None):
"""View User Profile"""
conf = self.db.get_conf(ctx.guild)
if not conf.enabled:
txt = _("Leveling is disabled in this server!")
if await self.bot.is_admin(ctx.author):
txt += _("\nYou can enable it with `{}`").format(f"{ctx.clean_prefix}lset toggle")
return await ctx.send(txt)
if not user:
user = ctx.author
if user.bot and self.db.ignore_bots:
return await ctx.send(_("Bots cannot have profiles!"))
profile = conf.get_profile(user)
new_user_txt = None
if user.id == ctx.author.id and profile.show_tutorial:
# New user, tell them about how they can customize their profile
new_user_txt = _(
"Welcome to LevelUp!\n"
"Use {} to view your profile settings and the available customization commands!\n"
"*You can use {} to view your profile settings at any time*"
).format(f"`{ctx.clean_prefix}setprofile`", f"`{ctx.clean_prefix}setprofile view`")
profile.show_tutorial = False
self.save()
try:
if ctx.interaction is None:
async with ctx.typing():
result = await self.get_user_profile_cached(user)
conf = self.db.get_conf(ctx.guild)
if isinstance(result, discord.Embed):
try:
await ctx.reply(content=new_user_txt, embed=result, mention_author=conf.notifymention)
except discord.HTTPException:
await ctx.send(content=new_user_txt, embed=result)
else: # File
try:
await ctx.reply(content=new_user_txt, file=result, mention_author=conf.notifymention)
except discord.HTTPException:
result.fp.seek(0)
await ctx.send(content=new_user_txt, file=result)
else:
await ctx.defer(ephemeral=True)
result = await self.get_user_profile_cached(user)
if isinstance(result, discord.Embed):
await ctx.send(content=new_user_txt, embed=result, ephemeral=True)
else: # File
await ctx.send(content=new_user_txt, file=result, ephemeral=True)
except Exception as e:
log.error("Error generating profile", exc_info=e)
if "Payload Too Large" in str(e):
txt = _("Your profile image is too large to send!")
else:
txt = _("An error occurred while generating your profile!\n{}").format(str(e))
await ctx.send(txt)
@commands.hybrid_command(name="prestige")
@commands.guild_only()
@commands.bot_has_permissions(manage_roles=True, embed_links=True)
async def prestige(self, ctx: commands.Context):
"""
Prestige your rank!
Once you have reached this servers prestige level requirement, you can
reset your level and experience to gain a prestige level and any perks associated with it
If you are over level and xp when you prestige, your xp and levels will carry over
"""
conf = self.db.get_conf(ctx.guild)
if ctx.author.id not in conf.users:
return await ctx.send(_("You have no level data yet!"))
if not conf.prestigelevel or not conf.prestigedata:
return await ctx.send(_("Prestige has not been configured for this server!"))
profile = conf.get_profile(ctx.author)
if profile.level < conf.prestigelevel:
return await ctx.send(_("You need to be at least level {} to prestige!").format(conf.prestigelevel))
next_prestige = profile.prestige + 1
if next_prestige not in conf.prestigedata:
return await ctx.send(_("You have reached the maximum prestige level!"))
pdata = conf.prestigedata[next_prestige]
role = ctx.guild.get_role(pdata.role)
if not role:
return await ctx.send(_("The prestige role for this level no longer exists, please contact an admin!"))
current_xp = int(profile.xp)
xp_at_prestige = conf.algorithm.get_xp(conf.prestigelevel)
leftover_xp = current_xp - xp_at_prestige if current_xp > xp_at_prestige else 0
newlevel = conf.algorithm.get_level(leftover_xp)
profile.level = newlevel
profile.xp = leftover_xp
profile.prestige = next_prestige
self.save()
txt = _("You have reached Prestige {}!\n").format(f"**{next_prestige}**")
added, removed = await self.ensure_roles(ctx.author, conf, _("Reached prestige {}").format(next_prestige))
embed = discord.Embed(description=txt, color=await self.bot.get_embed_color(ctx))
if added:
added_roles = humanize_list([r.mention for r in added])
embed.add_field(name=_("Roles Added"), value=added_roles)
if removed:
removed_roles = humanize_list([r.mention for r in removed])
embed.add_field(name=_("Roles Removed"), value=removed_roles)
await ctx.send(embed=embed)
@commands.hybrid_group(name="setprofile", aliases=["myprofile", "mypf", "pfset"])
@commands.guild_only()
async def set_profile(self, ctx: commands.Context):
"""Customize your profile"""
@set_profile.command(name="view")
@commands.bot_has_permissions(embed_links=True)
async def view_profile_settings(self, ctx: commands.Context):
"""View your profile settings"""
conf = self.db.get_conf(ctx.guild)
profile = conf.get_profile(ctx.author)
if not conf.use_embeds:
desc = _(
"`Profile Style: `{}\n"
"`Show Nickname: `{}\n"
"`Blur: `{}\n"
"`Font: `{}\n"
"`Background: `{}\n"
).format(
profile.style.title(),
profile.show_displayname,
_("Enabled") if profile.blur else _("Disabled"),
str(profile.font).title(),
profile.background,
)
else:
desc = _("`Show Nickname: `{}\n").format(profile.show_displayname)
color = ctx.author.color
if color == discord.Color.default():
color = await self.bot.get_embed_color(ctx)
embed = discord.Embed(
title=_("Your Profile Settings"),
description=desc,
color=color,
)
bg = profile.background
file = None
if not conf.use_embeds:
if bg.startswith("http"):
embed.set_image(url=bg)
elif bg not in ("default", "random"):
available = list(self.backgrounds.iterdir()) + list(self.custom_backgrounds.iterdir())
for path in available:
if bg.lower() in path.name.lower():
embed.set_image(url=f"attachment://{path.name}")
file = discord.File(str(path), filename=path.name)
break
if self.db.cache_seconds:
embed.add_field(
name=_("Cache Time"),
value=_("Profiles are cached for {} seconds, this is configured by the bot owner.").format(
self.db.cache_seconds
),
)
embeds = [embed]
if not conf.use_embeds:
embeds += [
discord.Embed(
description=_("Name color: {}").format(f"**{str(profile.namecolor).title()}**"),
color=utils.string_to_rgb(profile.namecolor, as_discord_color=True),
),
discord.Embed(
description=_("Stat color: {}").format(f"**{str(profile.statcolor).title()}**"),
color=utils.string_to_rgb(profile.statcolor, as_discord_color=True),
),
discord.Embed(
description=_("Level bar color: {}").format(f"**{str(profile.barcolor).title()}**"),
color=utils.string_to_rgb(profile.barcolor, as_discord_color=True),
),
]
if ctx.channel.permissions_for(ctx.me).attach_files:
return await ctx.send(embeds=embeds, file=file, ephemeral=True)
await ctx.send(embeds=embeds, ephemeral=True)
@set_profile.command(name="bgpath")
@commands.is_owner()
async def get_bg_path(self, ctx: commands.Context):
"""Get the folder paths for this cog's backgrounds"""
txt = ""
txt += _("- Defaults: `{}`\n").format(self.backgrounds)
txt += _("- Custom: `{}`\n").format(self.custom_backgrounds)
await ctx.send(txt)
@set_profile.command(name="addbackground")
@commands.is_owner()
async def add_background(self, ctx: commands.Context, preferred_filename: str = None):
"""
Add a custom background to the cog from discord
**Arguments**
`preferred_filename` - If a name is given, it will be saved as this name instead of the filename
**DISCLAIMER**
- Do not replace any existing file names with custom images
- If you add broken or corrupt images it can break the cog
- Do not include the file extension in the preferred name, it will be added automatically
"""
content = utils.get_attachments(ctx)
if not content:
return await ctx.send(_("No images found in the message!"))
valid = [".png", ".jpg", ".jpeg", ".gif", ".webp"]
filename = content[0].filename
if not filename.endswith(tuple(valid)):
return await ctx.send(
_("That is not a valid format, must be on of the following extensions: ") + humanize_list(valid)
)
for ext in valid:
if ext in filename.lower():
break
else:
ext = ".png"
filebytes = await content[0].read()
if preferred_filename:
if Path(filename).suffix.lower() == Path(preferred_filename).suffix.lower():
# User already included the extension
filename = preferred_filename
else:
filename = preferred_filename + ext
path = self.custom_backgrounds / filename
path.write_bytes(filebytes)
await ctx.send(_("Your custom background has been saved as {}").format(f"`{filename}`"))
@set_profile.command(name="rembackground")
@commands.is_owner()
async def remove_background(self, ctx: commands.Context, *, filename: str):
"""Remove a default background from the cog's backgrounds folder"""
for path in self.custom_backgrounds.iterdir():
if filename.lower() in path.name.lower():
break
else:
return await ctx.send(_("No background found with that name!"))
msg = await ctx.send(_("Are you sure you want to delete {}?").format(f"`{path}`"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Cancelled"))
path.unlink()
await msg.edit(content=_("The background {} has been deleted").format(f"`{path}`"))
@set_profile.command(name="fontpath")
@commands.is_owner()
async def get_font_path(self, ctx: commands.Context):
"""Get folder paths for this cog's fonts"""
txt = ""
txt += _("- Defaults: {}\n").format(self.fonts)
txt += _("- Custom: {}\n").format(self.custom_fonts)
await ctx.send(txt)
@set_profile.command(name="addfont")
@commands.is_owner()
async def add_font(self, ctx: commands.Context, preferred_filename: str = None):
"""
Add a custom font to the cog from discord
**Arguments**
`preferred_filename` - If a name is given, it will be saved as this name instead of the filename
**Note:** do not include the file extension in the preferred name, it will be added automatically
"""
content = utils.get_attachments(ctx)
if not content:
return await ctx.send(_("No fonts found in the message!"))
valid = [".ttf", ".otf"]
filename = content[0].filename
if not filename.endswith(tuple(valid)):
return await ctx.send(
_("That is not a valid format, must be on of the following extensions: ") + humanize_list(valid)
)
for ext in valid:
if ext in filename.lower():
break
else:
ext = ".ttf"
filebytes = await content[0].read()
if preferred_filename:
if Path(filename).suffix.lower() == Path(preferred_filename).suffix.lower():
# User already included the extension
filename = preferred_filename
else:
filename = preferred_filename + ext
path = self.custom_fonts / filename
path.write_bytes(filebytes)
await ctx.send(_("Your custom font has been saved as {}").format(f"`{filename}`"))
@set_profile.command(name="remfont")
@commands.is_owner()
async def remove_font(self, ctx: commands.Context, *, filename: str):
"""Remove a default font from the cog's fonts folder"""
for path in self.custom_fonts.iterdir():
if filename.lower() in path.name.lower():
break
else:
return await ctx.send(_("No font found with that name!"))
msg = await ctx.send(_("Are you sure you want to delete {}?").format(f"`{path}`"))
yes = await utils.confirm_msg(ctx)
if not yes:
return await msg.edit(content=_("Cancelled"))
path.unlink()
await msg.edit(content=_("The font {} has been deleted").format(f"`{path}`"))
@set_profile.command(name="backgrounds")
@commands.cooldown(1, 5, commands.BucketType.user)
@commands.bot_has_permissions(attach_files=True)
async def view_all_backgrounds(self, ctx: commands.Context):
"""View the all available backgrounds"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
paths = list(self.backgrounds.iterdir()) + list(self.custom_backgrounds.iterdir())
paths = [str(x) for x in paths]
def _run() -> discord.File:
img = imgtools.format_backgrounds(paths)
buffer = BytesIO()
img.save(buffer, format="WEBP")
buffer.seek(0)
return discord.File(buffer, filename="backgrounds.webp")
async with ctx.typing():
file = await asyncio.to_thread(_run)
txt = _("Here are all the available backgrounds!\nYou can use {} to set your background").format(
f"`{ctx.clean_prefix}setprofile background <image name>`"
)
await ctx.send(txt, file=file)
@set_profile.command(name="fonts")
@commands.cooldown(1, 5, commands.BucketType.user)
@commands.bot_has_permissions(attach_files=True)
async def view_fonts(self, ctx: commands.Context):
"""View the available fonts you can use"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
paths = list(self.fonts.iterdir()) + list(self.custom_fonts.iterdir())
paths = [str(x) for x in paths]
def _run() -> discord.File:
img = imgtools.format_fonts(paths)
buffer = BytesIO()
img.save(buffer, format="WEBP")
buffer.seek(0)
return discord.File(buffer, filename="fonts.webp")
async with ctx.typing():
file = await asyncio.to_thread(_run)
txt = _("Here are all the available fonts!\nYou can use {} to set your font").format(
f"`{ctx.clean_prefix}setprofile font <font name>`"
)
await ctx.send(txt, file=file)
@set_profile.command(name="style")
async def toggle_profile_style(self, ctx: commands.Context, style: t.Literal["default", "runescape"]):
"""
Set your profile image style
- `default` is the default profile style, very customizable
- `runescape` is a runescape style profile, less customizable but more nostalgic
- (WIP) - more to come
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
if style not in const.PROFILE_TYPES:
txt = _("That is not a valid profile style, please choose from the following: {}").format(
humanize_list(const.PROFILE_TYPES)
)
return await ctx.send(txt)
profile = conf.get_profile(ctx.author)
profile.style = style
self.save()
await ctx.send(_("Your profile type has been set to {}").format(style.capitalize()))
@set_profile.command(name="shownick")
async def toggle_show_nickname(self, ctx: commands.Context):
"""Toggle whether your nickname or username is shown in your profile"""
conf = self.db.get_conf(ctx.guild)
profile = conf.get_profile(ctx.author)
profile.show_displayname = not profile.show_displayname
self.save()
txt = (
_("Your nickname will now be shown in your profile!")
if profile.show_displayname
else _("Your username will now be shown in your profile!")
)
await ctx.send(txt)
@set_profile.command(name="namecolor", aliases=["name"])
@commands.bot_has_permissions(embed_links=True, attach_files=True)
@app_commands.describe(color="Name of color, hex or integer value")
async def set_name_color(self, ctx: commands.Context, *, color: str):
"""
Set a color for your username
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**
Set to `default` to randomize the color each time your profile is generated
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
profile = conf.get_profile(ctx.author)
if profile.style in const.STATIC_FONT_STYLES:
return await ctx.send(_("You cannot change your name color with the current profile style!"))
if color == "default":
profile.namecolor = None
self.save()
return await ctx.send(_("Your name color has been set to random!"))
try:
rgb = utils.string_to_rgb(color)
except ValueError:
file = discord.File(imgtools.COLORTABLE)
return await ctx.send(
_("That is an invalid color, please use a valid name, integer, or hex color."), file=file
)
embed = discord.Embed(
description=_("Name color has been updated to {}!").format(f"`{color}`"),
color=discord.Color.from_rgb(*rgb),
)
profile.namecolor = color
self.save()
await ctx.send(embed=embed)
@set_profile.command(name="statcolor", aliases=["stat"])
@commands.bot_has_permissions(embed_links=True, attach_files=True)
async def set_stat_color(self, ctx: commands.Context, *, color: str):
"""
Set a color for your server stats
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**
Set to `default` to randomize the color each time your profile is generated
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
profile = conf.get_profile(ctx.author)
if color == "default":
profile.statcolor = None
self.save()
return await ctx.send(_("Your stat color has been set to random!"))
try:
rgb = utils.string_to_rgb(color)
except ValueError:
file = discord.File(imgtools.COLORTABLE)
return await ctx.send(
_("That is an invalid color, please use a valid name, integer, or hex color."), file=file
)
embed = discord.Embed(
description=_("Stat color has been updated to {}!").format(f"`{color}`"),
color=discord.Color.from_rgb(*rgb),
)
profile.statcolor = color
self.save()
await ctx.send(embed=embed)
@set_profile.command(name="barcolor", aliases=["levelbar", "lvlbar", "bar"])
@commands.bot_has_permissions(embed_links=True, attach_files=True)
async def set_levelbar_color(self, ctx: commands.Context, *, color: str):
"""
Set a color for your level bar
For a specific color, try **[Google's hex color picker](https://htmlcolorcodes.com/)**
Set to `default` to randomize the color each time your profile is generated
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
return await ctx.send(_("Image profiles are disabled here, this setting has no effect!"))
profile = conf.get_profile(ctx.author)
if profile.style in const.STATIC_FONT_STYLES:
return await ctx.send(_("You cannot change your name color with the current profile style!"))
if color == "default":
profile.barcolor = None
self.save()
return await ctx.send(_("Your level bar color has been set to random!"))
try:
rgb = utils.string_to_rgb(color)
except ValueError:
file = discord.File(imgtools.COLORTABLE)
return await ctx.send(
_("That is an invalid color, please use a valid name, integer, or hex color."), file=file
)
embed = discord.Embed(
description=_("Level bar color has been updated to {}!").format(f"`{color}`"),
color=discord.Color.from_rgb(*rgb),
)
profile.barcolor = color
self.save()
await ctx.send(embed=embed)
@set_levelbar_color.autocomplete("color")
@set_stat_color.autocomplete("color")
@set_name_color.autocomplete("color")
async def set_name_color_autocomplete(self, interaction: discord.Interaction, current: str) -> t.List[Choice]:
choices = await self.get_color_choices_cached(current)
return choices
@cached(ttl=120)
async def get_color_choices_cached(self, current: str) -> t.List[Choice]:
current = current.lower()
choices: t.List[Choice] = []
for color in const.COLORS:
if current in color.lower() or not current:
choices.append(Choice(name=color, value=color))
if len(choices) >= 25:
break
return choices
@set_profile.command(name="background", aliases=["bg"])
@commands.bot_has_permissions(embed_links=True)
async def set_user_background(
self,
ctx: commands.Context,
url: t.Optional[str] = None,
):
"""
Set a background for your profile
This will override your profile banner as the background
**WARNING**
The default profile style is wide (1050 by 450 pixels) with an aspect ratio of 21:9.
Portrait images will be cropped.
Tip: Googling "dual monitor backgrounds" gives good results for the right images
Here are some good places to look.
[dualmonitorbackgrounds](https://www.dualmonitorbackgrounds.com/)
[setaswall](https://www.setaswall.com/dual-monitor-wallpapers/)
[pexels](https://www.pexels.com/photo/panoramic-photography-of-trees-and-lake-358482/)
[teahub](https://www.teahub.io/searchw/dual-monitor/)
**Additional Options**
- Leave `url` blank or specify `default` to reset back to using your profile banner (or random if you don't have one)
- `random` will randomly select from a pool of default backgrounds each time
- `filename` run `[p]mypf backgrounds` to view default options you can use by including their filename
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
txt = _("Image profiles are disabled here, this setting has no effect!")
return await ctx.send(txt)
profile = conf.get_profile(ctx.author)
if profile.style in const.STATIC_FONT_STYLES:
return await ctx.send(_("You cannot change your name color with the current profile style!"))
cached_txt = _("\n\nProfiles are cached for {} seconds so you may not see the change immediately").format(
self.db.cache_seconds
)
if url and url == "random":
profile.background = "random"
self.save()
txt = _("Your profile background has been set to random!")
if self.db.cache_seconds:
txt += cached_txt
return await ctx.send(txt)
if url and url == "default":
profile.background = "default"
self.save()
txt = _("Your profile background has been set to default!")
if self.db.cache_seconds:
txt += cached_txt
return await ctx.send(txt)
attachments = utils.get_attachments(ctx)
# If image url is given, run some checks
if not url and not attachments:
if profile.background == "default":
return await ctx.send(_("You must provide a url, filename, or attach a file"))
else:
profile.background = "default"
self.save()
txt = _("Your background has been reset to default!")
if self.db.cache_seconds:
txt += cached_txt
return await ctx.send(txt)
if url is None:
if attachments[0].size > ctx.guild.filesize_limit:
return await ctx.send(_("That image is too large for this server's upload limit!"))
profile.background = attachments[0].url
try:
file: discord.File = await self.get_user_profile(ctx.author, reraise=True)
if file.__sizeof__() > ctx.guild.filesize_limit:
profile.background = "default"
return await ctx.send(_("That image is too large for this server's upload limit!"))
except Exception as e:
profile.background = "default"
return await ctx.send(_("That image is not a valid profile background!\n{}").format(str(e)))
self.save()
txt = _("Your profile background has been set!")
if self.db.cache_seconds:
txt += cached_txt
return await ctx.send(txt)
if url.startswith("http"):
if self.tenor is None and await self.bot.is_owner(ctx.author):
txt = _(
"Set a Tenor API key to allow setting backgrounds from Discord's GIF links!\n"
"[Click here to get one](https://developers.google.com/tenor/guides/quickstart)\n"
"Then set it with `[p]set api tenor api_key <your_key>`"
)
await ctx.send(txt)
log.debug("Sanitizing link")
url = await sanitize_url(url, ctx)
profile.background = url
try:
file: discord.File = await self.get_user_profile(ctx.author, reraise=True)
if file.__sizeof__() > ctx.guild.filesize_limit:
profile.background = "default"
return await ctx.send(_("That image is too large for this server's upload limit!"))
except Exception as e:
profile.background = "default"
return await ctx.send(_("That image is not a valid profile background!\n{}").format(str(e)))
self.save()
txt = _("Your profile background has been set!")
if self.db.cache_seconds:
txt += cached_txt
return await ctx.send(txt)
# Check if the user provided a filename
backgrounds = list(self.backgrounds.iterdir()) + list(self.custom_backgrounds.iterdir())
for path in backgrounds:
if url.lower() in path.name.lower():
break
else:
return await ctx.send(_("No background found with that name!"))
file = discord.File(path)
profile.background = path.stem
self.save()
txt = _("Your profile background has been set to {}").format(f"`{path.name}`")
if self.db.cache_seconds:
txt += cached_txt
await ctx.send(txt, file=file)
@set_profile.command(name="font")
async def set_user_font(self, ctx: commands.Context, *, font_name: str):
"""
Set a font for your profile
To view available fonts, type `[p]myprofile fonts`
To revert to the default font, use `default` for the `font_name` argument
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
txt = _("Image profiles are disabled here, this setting has no effect!")
return await ctx.send(txt)
profile = conf.get_profile(ctx.author)
if profile.style in const.STATIC_FONT_STYLES:
return await ctx.send(_("You cannot change your name color with the current profile style!"))
if font_name == "default":
profile.font = "default"
self.save()
return await ctx.send(_("Your font has been reset to default!"))
fonts = list(self.fonts.iterdir()) + list(self.custom_fonts.iterdir())
for path in fonts:
if font_name.lower() in path.name.lower():
break
else:
return await ctx.send(_("No font found with that name!"))
profile.font = path.name
self.save()
txt = _("Your font has been set to {}").format(f"`{path.name}`")
await ctx.send(txt)
@set_user_font.autocomplete("font_name")
async def set_user_font_autocomplete(self, interaction: discord.Interaction, current: str) -> t.List[Choice]:
choices = []
current = current.lower()
for path in list(self.fonts.iterdir()) + list(self.custom_fonts.iterdir()):
if current in path.name.lower() or not current:
choices.append(Choice(name=path.stem, value=path.stem))
if len(choices) >= 25:
break
return choices
@set_profile.command(name="blur")
async def set_user_blur(self, ctx: commands.Context):
"""
Toggle a slight blur effect on the background image where the text is displayed.
"""
conf = self.db.get_conf(ctx.guild)
if conf.use_embeds:
txt = _("Image profiles are disabled here, this setting has no effect!")
return await ctx.send(txt)
profile = conf.get_profile(ctx.author)
if profile.style in const.STATIC_FONT_STYLES:
return await ctx.send(_("You cannot change your name color with the current profile style!"))
profile.blur = not profile.blur
self.save()
txt = _("Your profile blur has been set to {}").format(_("Enabled") if profile.blur else _("Disabled"))
await ctx.send(txt)

262
levelup/commands/weekly.py Normal file
View file

@ -0,0 +1,262 @@
import asyncio
import discord
from redbot.core import commands
from redbot.core.i18n import Translator, cog_i18n
from ..abc import MixinMeta
from ..common import formatter, utils
from ..views.dynamic_menu import DynamicMenu
_ = Translator("LevelUp", __file__)
@cog_i18n(_)
class Weekly(MixinMeta):
@commands.command(name="weekly", aliases=["week"])
@commands.guild_only()
async def weekly(
self,
ctx: commands.Context,
stat: str = "exp",
# globalstats: bool = False,
displayname: bool = True,
):
"""View Weekly Leaderboard"""
conf = self.db.get_conf(ctx.guild)
if not conf.weeklysettings.on:
txt = _("Weekly stats are not enabled on this server")
return await ctx.send(txt)
stat = stat.lower()
pages = await asyncio.to_thread(
formatter.get_leaderboard,
bot=self.bot,
guild=ctx.guild,
db=self.db,
stat=stat,
lbtype="weekly",
is_global=False,
member=ctx.author,
use_displayname=displayname,
color=await self.bot.get_embed_color(ctx),
)
if isinstance(pages, str):
return await ctx.send(pages)
await DynamicMenu(ctx, pages).refresh()
@commands.command(name="lastweekly")
@commands.guild_only()
@commands.bot_has_permissions(embed_links=True)
async def lastweekly(self, ctx: commands.Context):
"""View Last Week's Leaderboard"""
conf = self.db.get_conf(ctx.guild)
if not conf.weeklysettings.on:
return await ctx.send(_("Weekly stats are not enabled on this server"))
if not conf.weeklysettings.last_embed:
return await ctx.send(_("There is no recorded weekly embed saved"))
embed = discord.Embed.from_dict(conf.weeklysettings.last_embed)
embed.title = _("Last Weekly Leaderboard")
new_desc = _("{}\n`Last Reset: `{}").format(embed.description, f"<t:{conf.weeklysettings.last_reset}:R>")
embed.description = new_desc
await ctx.send(embed=embed)
@commands.group(name="weeklyset", aliases=["wset"])
@commands.admin_or_permissions(manage_guild=True)
@commands.guild_only()
async def weeklyset(self, ctx: commands.Context):
"""Configure Weekly LevelUp Settings"""
@weeklyset.command(name="view")
@commands.bot_has_permissions(embed_links=True)
async def weeklyset_view(self, ctx: commands.Context):
"""View the current weekly settings"""
conf = self.db.get_conf(ctx.guild)
status = _("Enabled") if conf.weeklysettings.on else _("Disabled")
desc = _("Weekly stat tracking is currently {}").format(status)
embed = discord.Embed(
title=_("LevelUp Weekly Settings"),
description=desc,
color=await self.bot.get_embed_color(ctx),
)
embed.add_field(
name=_("Settings"),
value=_(
"`Winner Count: `{}\n"
"`Channel: `{}\n"
"`Ping Winners: `{}\n"
"`Role: `{}\n"
"`RoleAllWinners: `{}\n"
"`Auto Remove: `{}\n"
"`Bonus Exp: `{}\n"
).format(
conf.weeklysettings.count,
f"<#{conf.weeklysettings.channel}>" if conf.weeklysettings.channel else _("None"),
conf.weeklysettings.ping_winners,
f"<@&{conf.weeklysettings.role}>" if conf.weeklysettings.role else _("None"),
conf.weeklysettings.role_all,
conf.weeklysettings.remove,
conf.weeklysettings.bonus,
),
inline=False,
)
embed.add_field(
name=_("Last Winners"),
value="\n".join(f"{i + 1}. <@{uid}>" for i, uid in enumerate(conf.weeklysettings.last_winners))
if conf.weeklysettings.last_winners
else _("No winners yet"),
inline=False,
)
status = _(" (Enabled)") if conf.weeklysettings.autoreset else _(" (Disabled)")
embed.add_field(
name=_("Auto Reset") + status,
value=_(
"- Stats reset on day {} ({})\n"
"- Reset occurs at hour {}\n"
"- Last reset occured on {}\n"
"- Next reset will happen on {}\n"
).format(
conf.weeklysettings.reset_day,
utils.get_day_name(conf.weeklysettings.reset_day),
conf.weeklysettings.reset_hour,
f"<t:{conf.weeklysettings.last_reset}:F>",
f"<t:{conf.weeklysettings.next_reset}:F>",
),
)
await ctx.send(embed=embed)
@weeklyset.command(name="ping")
async def weeklyset_ping(self, ctx: commands.Context):
"""Toggle whether to ping winners in announcement"""
conf = self.db.get_conf(ctx.guild)
if conf.weeklysettings.ping_winners:
conf.weeklysettings.ping_winners = False
await ctx.send(_("Winners will no longer be pinged in announcements"))
else:
conf.weeklysettings.ping_winners = True
await ctx.send(_("Winners will now be pinged in announcements"))
self.save()
@weeklyset.command(name="autoremove")
async def weeklyset_autoremove(self, ctx: commands.Context):
"""Remove role from previous winner when new one is announced"""
conf = self.db.get_conf(ctx.guild)
if conf.weeklysettings.remove:
conf.weeklysettings.remove = False
await ctx.send(_("Roles will no longer be removed from the previous winners"))
else:
conf.weeklysettings.remove = True
await ctx.send(_("Roles will now be removed from the previous winners"))
self.save()
@weeklyset.command(name="autoreset")
async def weeklyset_autoreset(self, ctx: commands.Context):
"""Toggle auto reset of weekly stats"""
conf = self.db.get_conf(ctx.guild)
if conf.weeklysettings.autoreset:
conf.weeklysettings.autoreset = False
await ctx.send(_("Weekly stats will no longer auto reset"))
else:
conf.weeklysettings.autoreset = True
await ctx.send(_("Weekly stats will now auto reset"))
self.save()
@weeklyset.command(name="bonus")
async def weeklyset_bonus(self, ctx: commands.Context, bonus: int):
"""Set bonus exp for top weekly winners"""
conf = self.db.get_conf(ctx.guild)
conf.weeklysettings.bonus = bonus
await ctx.send(_("Bonus exp for weekly winners set to {}").format(bonus))
self.save()
@weeklyset.command(name="channel")
async def weeklyset_channel(self, ctx: commands.Context, *, channel: discord.TextChannel):
"""Set channel to announce weekly winners"""
conf = self.db.get_conf(ctx.guild)
conf.weeklysettings.channel = channel.id
await ctx.send(_("Weekly winners will now be announced in {}").format(channel.mention))
self.save()
@weeklyset.command(name="day")
async def weeklyset_day(self, ctx: commands.Context, day: int):
"""Set day for weekly stats reset
0 = Monday
1 = Tuesday
2 = Wednesday
3 = Thursday
4 = Friday
5 = Saturday
6 = Sunday
"""
conf = self.db.get_conf(ctx.guild)
if day < 0 or day > 6:
return await ctx.send(_("Day must be between 0 and 6"))
conf.weeklysettings.reset_day = day
await ctx.send(_("Weekly stats will now reset on {}").format(utils.get_day_name(day)))
self.save()
@weeklyset.command(name="hour")
async def weeklyset_hour(self, ctx: commands.Context, hour: int):
"""Set hour for weekly stats reset"""
conf = self.db.get_conf(ctx.guild)
if hour < 0 or hour > 23:
return await ctx.send(_("Hour must be between 0 and 23"))
conf.weeklysettings.reset_hour = hour
txt = _("Hour set to {}, next reset will occur at {}").format(hour, f"<t:{conf.weeklysettings.next_reset}:F>")
await ctx.send(txt)
self.save()
@weeklyset.command(name="reset")
@commands.bot_has_permissions(embed_links=True)
async def reset_weekly_data(self, ctx: commands.Context, yes_or_no: bool):
"""Reset the weekly leaderboard manually and announce winners"""
if not yes_or_no:
return await ctx.send(_("Not resetting weekly stats"))
async with ctx.typing():
await self.reset_weekly(ctx.guild, ctx)
await ctx.tick()
@weeklyset.command(name="role")
async def weeklyset_role(self, ctx: commands.Context, *, role: discord.Role):
"""Set role to award top weekly winners"""
conf = self.db.get_conf(ctx.guild)
conf.weeklysettings.role = role.id
await ctx.send(_("Role set to {}").format(role.mention))
self.save()
@weeklyset.command(name="roleall")
async def weeklyset_roleall(self, ctx: commands.Context):
"""Toggle whether all winners get the role"""
conf = self.db.get_conf(ctx.guild)
if conf.weeklysettings.role_all:
conf.weeklysettings.role_all = False
await ctx.send(_("Only the top winner will get the role"))
else:
conf.weeklysettings.role_all = True
await ctx.send(_("All winners will get the role"))
self.save()
@weeklyset.command(name="winners")
async def weeklyset_winners(self, ctx: commands.Context, count: int):
"""
Set number of winners to display
Due to Discord limitations with max embed field count, the maximum number of winners is 25
"""
conf = self.db.get_conf(ctx.guild)
if count < 1 or count > 25:
return await ctx.send(_("Number of winners must be between 1 and 25"))
conf.weeklysettings.count = count
await ctx.send(_("Number of winners to display set to {}").format(count))
self.save()
@weeklyset.command(name="toggle")
async def weeklyset_toggle(self, ctx: commands.Context):
"""Toggle weekly stat tracking"""
conf = self.db.get_conf(ctx.guild)
if conf.weeklysettings.on:
conf.weeklysettings.on = False
await ctx.send(_("Weekly stat tracking disabled"))
else:
conf.weeklysettings.on = True
await ctx.send(_("Weekly stat tracking enabled"))
self.save()

View file

@ -0,0 +1 @@
# The main database models.py, and Non-class functions and variables that are used across the project

1055
levelup/common/const.py Normal file

File diff suppressed because it is too large Load diff

318
levelup/common/formatter.py Normal file
View file

@ -0,0 +1,318 @@
import math
import typing as t
from datetime import datetime
from io import StringIO
import discord
from redbot.core.bot import Red
from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import humanize_number
from ..common import utils
from ..common.models import DB, GuildSettings, Profile, ProfileWeekly, WeeklySettings
_ = Translator("LevelUp", __file__)
def get_user_position(
guild: discord.Guild,
conf: GuildSettings,
lbtype: t.Literal["lb", "weekly"],
target_user: int,
key: str,
) -> dict:
"""Get the position of a user in the leaderboard
Args:
lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard
target_user (int): The user's ID
key (str): The key to sort by
Returns:
int: The user's position
"""
if lbtype == "weekly":
lb: t.Dict[int, ProfileWeekly] = conf.users_weekly
elif not conf.prestigedata:
lb: t.Dict[int, Profile] = conf.users
else:
lb: t.Dict[int, Profile] = {}
for user_id in list(conf.users.keys()):
profile = (
conf.users[user_id].model_copy()
if hasattr(conf.users[user_id], "model_copy")
else conf.users[user_id].copy()
)
if profile.prestige and conf.prestigelevel:
profile.xp += profile.prestige * conf.algorithm.get_xp(conf.prestigelevel)
profile.level += profile.prestige * conf.prestigelevel
lb[user_id] = profile
valid_users = {k: v for k, v in lb.items() if guild.get_member(k)}
sorted_users = sorted(valid_users.items(), key=lambda x: getattr(x[1], key), reverse=True)
for idx, (uid, _) in enumerate(sorted_users):
if uid == target_user:
position = idx + 1
break
else:
position = -1
total = sum([getattr(x[1], key) for x in sorted_users])
percent = getattr(lb[target_user], key) / total * 100 if total else 0
return {"position": position, "total": total, "percent": percent}
def get_role_leaderboard(rolegroups: t.Dict[int, float], color: discord.Color) -> t.List[discord.Embed]:
"""Format and return the role leaderboard
Args:
rolegroups (t.Dict[int, float]): The role leaderboard
Returns:
t.List[discord.Embed]: A list of embeds
"""
sorted_roles = sorted(rolegroups.items(), key=lambda x: x[1], reverse=True)
filtered_roles = [x for x in sorted_roles if x[1] > 0]
embeds = []
count = len(filtered_roles)
pages = math.ceil(count / 10)
start = 0
stop = 10
for idx in range(pages):
stop = min(count, stop)
buffer = StringIO()
for i, (role_id, xp) in enumerate(filtered_roles[start:stop], start=start):
buffer.write(f"**{i + 1}.** <@&{role_id}> `{humanize_number(int(xp))}`xp\n")
embed = discord.Embed(
title=_("Role Leaderboard"),
description=buffer.getvalue(),
color=color,
).set_footer(text=_("Page {}").format(f"{idx + 1}/{pages}"))
embeds.append(embed)
start += 10
stop += 10
return embeds
def get_leaderboard(
bot: Red,
guild: discord.Guild,
db: DB,
stat: str,
lbtype: str,
is_global: bool,
member: discord.Member = None,
use_displayname: bool = True,
dashboard: bool = False,
color: discord.Color = discord.Color.random(),
query: str = None,
) -> t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]:
"""Format and return the leaderboard
Args:
bot (Red)
guild (discord.Guild)
db (DB)
stat (str): The stat to display (xp, messages, voice, stars)
lbtype (str): The type of leaderboard (weekly, lb)
is_global (bool): Whether to display global stats
member (discord.Member, optional): Person running the command. Defaults to None.
use_displayname (bool, optional): If false, uses username. Defaults to True.
dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.
color (discord.Color, optional): Defaults to discord.Color.random().
Returns:
t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string
"""
stat = stat.lower()
color = member.color if member else color
conf = db.get_conf(guild)
lb: t.Dict[int, t.Union[Profile, ProfileWeekly]]
weekly: WeeklySettings = None
if lbtype == "weekly":
title = _("Weekly ")
lb = db.get_conf(guild).users_weekly
weekly = db.get_conf(guild).weeklysettings
elif lbtype == "lb" and is_global:
title = _("Global LevelUp ")
# Add up all the guilds
lb: t.Dict[int, Profile] = {}
for guild_id in db.configs.keys():
guild_conf: GuildSettings = db.configs[guild_id]
for user_id in guild_conf.users.keys():
profile: Profile = guild_conf.users[user_id]
if user_id not in lb:
lb[user_id] = profile.model_copy() if hasattr(profile, "model_copy") else profile.copy()
else:
lb[user_id].xp += profile.xp
lb[user_id].messages += profile.messages
lb[user_id].voice += profile.voice
lb[user_id].stars += profile.stars
if "xp" in stat and profile.prestige and guild_conf.prestigelevel:
lb[user_id].xp += profile.prestige * guild_conf.algorithm.get_xp(guild_conf.prestigelevel)
lb[user_id].level += profile.prestige * guild_conf.prestigelevel
elif "xp" in stat and conf.prestigelevel and conf.prestigedata:
title = _("LevelUp ")
lb = {}
for user_id in conf.users.keys():
profile: Profile = conf.users[user_id]
lb[user_id] = profile.model_copy() if hasattr(profile, "model_copy") else profile.copy()
if profile.prestige:
lb[user_id].xp += profile.prestige * conf.algorithm.get_xp(conf.prestigelevel)
lb[user_id].level += profile.prestige * conf.prestigelevel
else:
title = _("LevelUp ")
lb = db.get_conf(guild).users.copy()
if "v" in stat:
title += _("Voice Leaderboard")
key = "voice"
emoji = conf.emojis.get("mic", bot)
statname = _("Voicetime")
elif "m" in stat:
title += _("Message Leaderboard")
key = "messages"
emoji = conf.emojis.get("chat", bot)
statname = _("Messages")
elif "s" in stat:
title += _("Star Leaderboard")
key = "stars"
emoji = conf.emojis.get("star", bot)
statname = _("Stars")
else:
title += _("Exp Leaderboard")
key = "xp"
emoji = conf.emojis.get("bulb", bot)
statname = _("Experience")
if is_global:
valid_users: t.Dict[int, t.Union[Profile, ProfileWeekly]] = {k: v for k, v in lb.items() if bot.get_user(k)}
else:
valid_users: t.Dict[int, t.Union[Profile, ProfileWeekly]] = {k: v for k, v in lb.items() if guild.get_member(k)}
filtered_users: t.Dict[int, t.Union[Profile, ProfileWeekly]] = {
k: v for k, v in valid_users.items() if getattr(v, key) > 0
}
if not filtered_users and not dashboard:
txt = _("There is no data for the {} leaderboard yet").format(
_("weekly {}").format(statname) if lbtype == "weekly" else statname
)
return txt
sorted_users = sorted(filtered_users.items(), key=lambda x: getattr(x[1], key), reverse=True)
usercount = len(sorted_users)
func = utils.humanize_delta if "v" in stat else humanize_number
total: str = func(round(sum([getattr(x, key) for x in filtered_users.values()])))
for idx, (user_id, stats) in enumerate(sorted_users):
if member and user_id == member.id:
you = _(" | You: {}").format(f"{idx + 1}/{len(sorted_users)}")
break
else:
you = ""
if lbtype == "weekly":
if dashboard:
desc = _("➣ Total {}: {}\n").format(statname, f"`{total}`{emoji}")
else:
desc = _("➣ **Total {}:** {}\n").format(statname, f"`{total}`{emoji}")
if dashboard:
if weekly.last_reset:
ts = datetime.fromtimestamp(weekly.last_reset).strftime("%m/%d/%Y @ %I:%M:%S %p")
desc += _("➣ Last Reset: {}\n").format(ts)
if weekly.autoreset:
ts = datetime.fromtimestamp(weekly.next_reset).strftime("%m/%d/%Y @ %I:%M:%S %p")
delta = utils.humanize_delta(weekly.next_reset - int(datetime.now().timestamp()))
desc += _("➣ Next Reset: {} ({})\n").format(ts, delta)
else:
if weekly.last_reset:
ts = weekly.last_reset
desc += _("➣ **Last Reset:** {}\n").format(f"<t:{ts}:d> (<t:{ts}:R>)")
if weekly.autoreset:
ts = weekly.next_reset
desc += _("➣ **Next Reset:** {}\n").format(f"<t:{ts}:d> (<t:{ts}:R>)")
desc += "\n"
else:
if dashboard:
desc = _("Total {}: {}\n").format(statname, f"`{total}`{emoji}")
else:
desc = _("**Total {}:** {}\n\n").format(statname, f"`{total}`{emoji}")
if dashboard:
# Format for when dashboard integration calls this function
payload = {
"title": title,
"description": desc.strip(),
"stat": statname,
"total": total,
"type": lbtype,
"user_position": you,
"stats": [],
}
for idx, (user_id, stats) in enumerate(sorted_users):
user_obj = bot.get_user(user_id) if is_global else guild.get_member(user_id)
user = (user_obj.display_name if use_displayname else user_obj.name) if user_obj else user_id
if query:
if query.startswith("#"):
# User search by position
position_query = query[1:]
if idx + 1 != int(position_query):
continue
elif query.isdigit():
# User search by ID or position
if user_id != int(query) and idx + 1 != int(query):
continue
else:
# User search by name
if query.lower() not in str(user).lower():
continue
place = idx + 1
if key == "voice":
stat = utils.humanize_delta(round(getattr(stats, key)))
else:
stat = utils.abbreviate_number(round(getattr(stats, key)))
if key == "xp" and lbtype != "weekly" and not is_global:
stat += f" 🎖{stats.level}"
entry = {"position": place, "name": user, "id": user_id, "stat": stat}
payload["stats"].append(entry)
return payload
embeds = []
pages = math.ceil(len(sorted_users) / 10)
start = 0
stop = 10
for idx in range(pages):
stop = min(usercount, stop)
buffer = StringIO()
for i in range(start, stop):
user_id, stats = sorted_users[i]
user_obj = bot.get_user(user_id) if is_global else guild.get_member(user_id)
name = (user_obj.display_name if use_displayname else user_obj.name) if user_obj else user_id
place = i + 1
if key == "voice":
stat = utils.humanize_delta(round(getattr(stats, key)))
else:
stat = utils.abbreviate_number(round(getattr(stats, key)))
if key == "xp" and lbtype != "weekly" and not is_global:
stat += f" 🎖{stats.level}"
buffer.write(f"**{place}**. {name} (`{stat}`)\n")
embed = discord.Embed(
title=title,
description=desc + buffer.getvalue(),
color=color,
).set_footer(text=_("Page {}").format(f"{idx + 1}/{pages}{you}"), icon_url=guild.icon)
embeds.append(embed)
start += 10
stop += 10
return embeds

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: German\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: de_DE\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,301 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:57\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: es_ES\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr "Obtener la posición de un usuario en la tabla de líderes\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): La tabla de líderes\n"
" target_user (int): La ID del usuario\n"
" key (str): La clave para ordenar\n\n"
" Returns:\n"
" int: La posición del usuario\n"
" "
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr "Formatear y devolver la tabla de líderes de roles\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): La tabla de líderes de roles\n\n"
" Returns:\n"
" t.List[discord.Embed]: Una lista de embeds\n"
" "
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr "Clasificación de Roles"
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr "Página {}"
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr "Formatear y devolver la tabla de líderes\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): La estadística a mostrar (xp, mensajes, voz, estrellas)\n"
" lbtype (str): El tipo de tabla de líderes (semanal, lb)\n"
" is_global (bool): Si se deben mostrar estadísticas globales\n"
" member (discord.Member, optional): La persona que ejecuta el comando. Por defecto es None.\n"
" use_displayname (bool, optional): Si es falso, usa el nombre de usuario. Por defecto es True.\n"
" dashboard (bool, optional): Verdadero cuando es llamado por la integración del dashboard. Por defecto es falso.\n"
" color (discord.Color, optional): Por defecto es discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: Si se llama desde el dashboard devuelve un diccionario, de lo contrario devuelve una lista de embeds o una cadena\n"
" "
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr "Semanal "
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr "Global LevelUp "
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr "LevelUp "
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr "Clasificación por voz"
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr "Tiempo de voz"
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr "Clasificación de Mensajes"
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr "Mensajes"
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr "Clasificación por estrellas"
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr "Estrellas"
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr "Clasificación Exp"
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr "Experiencia"
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr "No hay datos para la tabla de líderes de {} aún"
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr "semanal {}"
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr " | Tú: {}"
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr "➣ Total {}: {}\n"
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr "➣ **Total {}:** {}\n"
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr "➣ Último reinicio: {}\n"
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr "➣ Próximo reinicio: {} ({})\n"
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr "➣ **Último reinicio:** {}\n"
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr "➣ **Próximo reinicio:** {}\n"
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr "Total {}: {}\n"
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr "**Total {}:** {}\n\n"
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr "Modelo base personalizado con métodos adicionales para cargar y guardar configuraciones de manera segura"
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr "Calcular el nivel que corresponde a la cantidad de XP dada"
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr "Calcular el XP requerido para alcanzar el nivel especificado"
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr "Sanear datos de configuración antiguos para ser validados por el nuevo esquema"
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr "Formatear el tiempo en segundos en una cadena legible por humanos"
#: levelup\common\utils.py:91
msgid "None"
msgstr "Ninguno"
#: levelup\common\utils.py:94
msgid " second"
msgstr " segundo"
#: levelup\common\utils.py:96
msgid " seconds"
msgstr " segundos"
#: levelup\common\utils.py:99
msgid " minute"
msgstr " minuto"
#: levelup\common\utils.py:101
msgid " minutes"
msgstr " minutos"
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr "Obtener la url de los emojis unicode desde Twemoji CDN"
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr "Obtener todos los archivos adjuntos del contexto"
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr "Encuentra recursivamente el tamaño de un objeto en memoria"
#: levelup\common\utils.py:191
msgid "Monday"
msgstr "Lunes"
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr "Martes"
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr "Miércoles"
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr "Jueves"
#: levelup\common\utils.py:195
msgid "Friday"
msgstr "Viernes"
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr "Sábado"
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr "Domingo"
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr "Esperar a que el usuario responda sí o no"
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr "Obtener un nivel que se lograría con la cantidad de XP"
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr "Obtener cuánta XP se necesita para alcanzar un nivel"
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr "• lvl {}, {} xp, {}\n"
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr "Curva XP"
#: levelup\common\utils.py:332
msgid "Level"
msgstr "Nivel"
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr "Experiencia necesaria"

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:57\n"
"Last-Translator: \n"
"Language-Team: French\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: fr_FR\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: hr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: hr_HR\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: ko\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: ko_KR\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,281 @@
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
#: levelup\common\formatter.py:18
#, docstring
msgid ""
"Get the position of a user in the leaderboard\n"
"\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n"
"\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid ""
"Format and return the role leaderboard\n"
"\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n"
"\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid ""
"Format and return the leaderboard\n"
"\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n"
"\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid ""
"**Total {}:** {}\n"
"\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid ""
"Custom BaseModel with additional methods for loading and saving settings "
"safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: pt-PT\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: pt_PT\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: ru_RU\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

View file

@ -0,0 +1,275 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: tr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/common/locales/messages.pot\n"
"X-Crowdin-File-ID: 162\n"
"Language: tr_TR\n"
#: levelup\common\formatter.py:18
#, docstring
msgid "Get the position of a user in the leaderboard\n\n"
" Args:\n"
" lb (t.Dict[int, t.Union[Profile, ProfileWeekly]]): The leaderboard\n"
" target_user (int): The user's ID\n"
" key (str): The key to sort by\n\n"
" Returns:\n"
" int: The user's position\n"
" "
msgstr ""
#: levelup\common\formatter.py:58
#, docstring
msgid "Format and return the role leaderboard\n\n"
" Args:\n"
" rolegroups (t.Dict[int, float]): The role leaderboard\n\n"
" Returns:\n"
" t.List[discord.Embed]: A list of embeds\n"
" "
msgstr ""
#: levelup\common\formatter.py:81
msgid "Role Leaderboard"
msgstr ""
#: levelup\common\formatter.py:84 levelup\common\formatter.py:305
msgid "Page {}"
msgstr ""
#: levelup\common\formatter.py:106
#, docstring
msgid "Format and return the leaderboard\n\n"
" Args:\n"
" bot (Red)\n"
" guild (discord.Guild)\n"
" db (DB)\n"
" stat (str): The stat to display (xp, messages, voice, stars)\n"
" lbtype (str): The type of leaderboard (weekly, lb)\n"
" is_global (bool): Whether to display global stats\n"
" member (discord.Member, optional): Person running the command. Defaults to None.\n"
" use_displayname (bool, optional): If false, uses username. Defaults to True.\n"
" dashboard (bool, optional): True when called by the dashboard integration. Defaults to False.\n"
" color (discord.Color, optional): Defaults to discord.Color.random().\n\n"
" Returns:\n"
" t.Union[t.List[discord.Embed], t.Dict[str, t.Any], str]: If called from dashboard returns a dict, else returns a list of embeds or a string\n"
" "
msgstr ""
#: levelup\common\formatter.py:129
msgid "Weekly "
msgstr ""
#: levelup\common\formatter.py:133
msgid "Global LevelUp "
msgstr ""
#: levelup\common\formatter.py:152 levelup\common\formatter.py:161
msgid "LevelUp "
msgstr ""
#: levelup\common\formatter.py:165
msgid "Voice Leaderboard"
msgstr ""
#: levelup\common\formatter.py:168
msgid "Voicetime"
msgstr ""
#: levelup\common\formatter.py:170
msgid "Message Leaderboard"
msgstr ""
#: levelup\common\formatter.py:173
msgid "Messages"
msgstr ""
#: levelup\common\formatter.py:175
msgid "Star Leaderboard"
msgstr ""
#: levelup\common\formatter.py:178
msgid "Stars"
msgstr ""
#: levelup\common\formatter.py:180
msgid "Exp Leaderboard"
msgstr ""
#: levelup\common\formatter.py:183
msgid "Experience"
msgstr ""
#: levelup\common\formatter.py:194
msgid "There is no data for the {} leaderboard yet"
msgstr ""
#: levelup\common\formatter.py:195
msgid "weekly {}"
msgstr ""
#: levelup\common\formatter.py:206
msgid " | You: {}"
msgstr ""
#: levelup\common\formatter.py:213
msgid "➣ Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:215
msgid "➣ **Total {}:** {}\n"
msgstr ""
#: levelup\common\formatter.py:219
msgid "➣ Last Reset: {}\n"
msgstr ""
#: levelup\common\formatter.py:223
msgid "➣ Next Reset: {} ({})\n"
msgstr ""
#: levelup\common\formatter.py:227
msgid "➣ **Last Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:230
msgid "➣ **Next Reset:** {}\n"
msgstr ""
#: levelup\common\formatter.py:235
msgid "Total {}: {}\n"
msgstr ""
#: levelup\common\formatter.py:237
msgid "**Total {}:** {}\n\n"
msgstr ""
#: levelup\common\models.py:23
#, docstring
msgid "Custom BaseModel with additional methods for loading and saving settings safely"
msgstr ""
#: levelup\common\models.py:250
#, docstring
msgid "Calculate the level that corresponds to the given XP amount"
msgstr ""
#: levelup\common\models.py:254
#, docstring
msgid "Calculate XP required to reach specified level"
msgstr ""
#: levelup\common\models.py:367
#, docstring
msgid "Sanitize old config data to be validated by the new schema"
msgstr ""
#: levelup\common\utils.py:83
#, docstring
msgid "Format time in seconds into a human readable string"
msgstr ""
#: levelup\common\utils.py:91
msgid "None"
msgstr ""
#: levelup\common\utils.py:94
msgid " second"
msgstr ""
#: levelup\common\utils.py:96
msgid " seconds"
msgstr ""
#: levelup\common\utils.py:99
msgid " minute"
msgstr ""
#: levelup\common\utils.py:101
msgid " minutes"
msgstr ""
#: levelup\common\utils.py:112
#, docstring
msgid "Fetch the url of unicode emojis from Twemoji CDN"
msgstr ""
#: levelup\common\utils.py:130
#, docstring
msgid "Get all attachments from context"
msgstr ""
#: levelup\common\utils.py:145
#, docstring
msgid "Recursively finds the size of an object in memory"
msgstr ""
#: levelup\common\utils.py:191
msgid "Monday"
msgstr ""
#: levelup\common\utils.py:192
msgid "Tuesday"
msgstr ""
#: levelup\common\utils.py:193
msgid "Wednesday"
msgstr ""
#: levelup\common\utils.py:194
msgid "Thursday"
msgstr ""
#: levelup\common\utils.py:195
msgid "Friday"
msgstr ""
#: levelup\common\utils.py:196
msgid "Saturday"
msgstr ""
#: levelup\common\utils.py:197
msgid "Sunday"
msgstr ""
#: levelup\common\utils.py:213
#, docstring
msgid "Wait for user to respond yes or no"
msgstr ""
#: levelup\common\utils.py:284
#, docstring
msgid "Get a level that would be achieved from the amount of XP"
msgstr ""
#: levelup\common\utils.py:289
#, docstring
msgid "Get how much XP is needed to reach a level"
msgstr ""
#: levelup\common\utils.py:318
msgid "• lvl {}, {} xp, {}\n"
msgstr ""
#: levelup\common\utils.py:326
msgid "XP Curve"
msgstr ""
#: levelup\common\utils.py:332
msgid "Level"
msgstr ""
#: levelup\common\utils.py:333
msgid "Experience Required"
msgstr ""

425
levelup/common/models.py Normal file
View file

@ -0,0 +1,425 @@
from __future__ import annotations
import logging
import math
import os
import typing as t
from contextlib import suppress
from datetime import datetime, timedelta
from pathlib import Path
from uuid import uuid4
import discord
import orjson
from pydantic import VERSION, BaseModel, Field
from redbot.core.bot import Red
from .utils import get_twemoji
log = logging.getLogger("red.vrt.levelup.models")
class Base(BaseModel):
"""Custom BaseModel with additional methods for loading and saving settings safely"""
@classmethod
def load(cls, obj: t.Dict[str, t.Any]) -> Base:
if VERSION >= "2.0.1":
return cls.model_validate(obj)
return cls.parse_obj(obj)
@classmethod
def loadjson(cls, obj: t.Union[str, bytes]) -> Base:
if VERSION >= "2.0.1":
return cls.model_validate_json(obj)
return cls.parse_raw(obj)
def dump(self, exclued_defaults: bool = True) -> t.Dict[str, t.Any]:
if VERSION >= "2.0.1":
return super().model_dump(mode="json", exclude_defaults=exclued_defaults)
return orjson.loads(self.json(exclude_defaults=exclued_defaults))
def dumpjson(self, exclude_defaults: bool = True, pretty: bool = False) -> str:
kwargs = {"exclude_defaults": exclude_defaults}
if pretty:
kwargs["indent"] = 2
if VERSION >= "2.0.1":
return self.model_dump_json(**kwargs)
return self.json(**kwargs)
@classmethod
def from_file(cls, path: Path) -> Base:
if not path.exists():
raise FileNotFoundError(f"File not found: {path}")
if not path.is_file():
raise IsADirectoryError(f"Path is not a file: {path}")
if VERSION >= "2.0.1":
text = path.read_text()
try:
return cls.model_validate_json(text)
except UnicodeDecodeError as e:
log.warning(f"Failed to load {path}, attempting to load via json5")
try:
import json5
data = json5.loads(text)
return cls.model_validate(data)
except ImportError:
log.error("Failed to load via json5")
raise e
try:
return cls.parse_file(path)
except UnicodeDecodeError as e:
log.warning(f"Failed to load {path}, attempting to load via json5")
try:
import json5
data = json5.loads(path.read_text())
return cls.parse_obj(data)
except ImportError:
log.error("Failed to load via json5")
raise e
def to_file(self, path: Path, pretty: bool = False) -> None:
dump = self.dumpjson(exclude_defaults=True, pretty=pretty)
# We want to write the file as safely as possible
# https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/core/_drivers/json.py#L224
tmp_file = f"{path.stem}-{uuid4().fields[0]}.tmp"
tmp_path = path.parent / tmp_file
with tmp_path.open(encoding="utf-8", mode="w") as fs:
fs.write(dump)
fs.flush() # This does get closed on context exit, ...
os.fsync(fs.fileno()) # but that needs to happen prior to this line
# Replace the original file with the new content
tmp_path.replace(path)
# Ensure directory fsync for better durability
if hasattr(os, "O_DIRECTORY"):
fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
class VoiceTracking(Base):
"""Non-config model for tracking voice activity"""
joined: float # Time when user joined VC
not_gaining_xp: bool # If the user currently shouldnt gain xp (if solo or deafened is ignored ect..)
not_gaining_xp_time: float # Total time user wasnt gaining xp
stopped_gaining_xp_at: t.Union[float, None] # Time when user last stopped gaining xp
class Profile(Base):
xp: float = 0 # Experience points
voice: float = 0 # Voice time in seconds
messages: int = 0 # Message count
level: int = 0 # Level
prestige: int = 0 # Prestige level
stars: int = 0
last_active: datetime = Field(
default_factory=datetime.now,
description="Last time the user was active in the guild (message sent or voice activity)",
)
show_tutorial: bool = True # Init with True, show tutorial on first command usage
# Profile customization
style: str = "default" # Can be default, runescape... (WIP)
background: str = "default" # Can be default, random, filename, or URL
namecolor: t.Union[str, None] = None # Hex color
statcolor: t.Union[str, None] = None # Hex color
barcolor: t.Union[str, None] = None # Hex color
font: t.Union[str, None] = None # Font name (must match font file)
blur: bool = True # Blur background of stat area
show_displayname: bool = False # Show display name instead of username on profile
def add_message(self) -> Profile:
self.messages += 1
self.last_active = datetime.now()
return self
def all_default(self) -> bool:
# Check if all settings under the Profile customization section are default
checks = [
self.style == "default",
self.background == "default",
self.namecolor is None,
self.statcolor is None,
self.barcolor is None,
self.font is None,
self.blur,
not self.show_displayname,
]
return all(checks)
class ProfileWeekly(Base):
xp: float = 0
voice: float = 0
messages: int = 0
stars: int = 0
def add_message(self) -> ProfileWeekly:
self.messages += 1
return self
class WeeklySettings(Base):
on: bool = False # Weekly stats are being tracked for this guild or not
autoreset: bool = False # Whether to auto reset once a week or require manual reset
reset_hour: int = 0 # 0 - 23 hour (Server's system time)
reset_day: int = 0 # 0 = mon, 1 = tues, 2 = wed, 3 = thur, 4 = fri, 5 = sat, 6 = sun
last_reset: int = 0 # Timestamp of when weekly was last reset
count: int = 3 # How many users to show in weekly winners
channel: int = 0 # Announce the weekly winners (top 3 by default)
role: int = 0 # Role awarded to top member(s) for that week
role_all: bool = False # If True, all winners get the role instead of only 1st place
last_winners: t.List[int] = [] # IDs of last members that won if role_all is enabled
remove: bool = True # Whether to remove the role from the previous winner when a new one is announced
bonus: int = 0 # Bonus exp to award the top X winners
last_embed: t.Dict[str, t.Any] = {} # Dict repr of last winner embed
ping_winners: bool = False # Mention the winners in the announcement
@property
def next_reset(self) -> int:
now = datetime.now()
current_weekday = now.weekday()
# Calculate how many days until the next reset day
days_until_reset: int = (self.reset_day - current_weekday + 7) % 7
if days_until_reset == 0 and now.hour >= self.reset_hour:
days_until_reset = 7
next_reset_time = (now + timedelta(days=days_until_reset)).replace(
hour=self.reset_hour, minute=0, second=0, microsecond=0
)
return int(next_reset_time.timestamp())
def refresh(self):
self.last_reset = int(datetime.now().timestamp())
class Prestige(Base):
role: int
emoji_string: str
emoji_url: t.Union[str, None] = None
class RoleBonus(Base):
msg: t.Dict[int, t.List[int]] = {} # Role_ID: [Min, Max]
voice: t.Dict[int, t.List[int]] = {} # Role_ID: [Min, Max]
class ChannelBonus(Base):
msg: t.Dict[int, t.List[int]] = {} # Channel_ID: [Min, Max]
voice: t.Dict[int, t.List[int]] = {} # Channel_ID: [Min, Max]
class Algorithm(Base):
base: int = 100 # Base denominator for level algorithm, higher takes longer to level
exp: float = 2.0 # Exponent for level algorithm, higher is a more exponential/steeper curve
def get_level(self, xp: t.Union[int, float]) -> int:
"""Calculate the level that corresponds to the given XP amount"""
return int((xp / self.base) ** (1 / self.exp))
def get_xp(self, level: int) -> int:
"""Calculate XP required to reach specified level"""
return math.ceil(self.base * (level**self.exp))
class Emojis(Base):
level: t.Union[str, int] = "\N{SPORTS MEDAL}"
trophy: t.Union[str, int] = "\N{TROPHY}"
star: t.Union[str, int] = "\N{WHITE MEDIUM STAR}"
chat: t.Union[str, int] = "\N{SPEECH BALLOON}"
mic: t.Union[str, int] = "\N{STUDIO MICROPHONE}\N{VARIATION SELECTOR-16}"
bulb: t.Union[str, int] = "\N{ELECTRIC LIGHT BULB}"
money: t.Union[str, int] = "\N{MONEY BAG}"
def get(self, name: str, bot: Red) -> t.Union[str, discord.Emoji, discord.PartialEmoji]:
if not hasattr(self, name):
raise AttributeError(f"Emoji {name} not found")
emoji = getattr(self, name)
if isinstance(emoji, str) and emoji.isdigit():
emoji_obj = bot.get_emoji(int(emoji))
elif isinstance(emoji, int):
emoji_obj = bot.get_emoji(emoji)
else:
emoji_obj = emoji
final = emoji_obj or Emojis().dump(False)[name]
if isinstance(final, str) and len(final) > 50:
log.error(f"Something is wrong with the emoji {name}: {final}")
final = Emojis().dump(False)[name]
setattr(self, name, final if isinstance(final, str) else final.id)
return final
class GuildSettings(Base):
users: t.Dict[int, Profile] = {} # User_ID: Profile
users_weekly: t.Dict[int, ProfileWeekly] = {} # User_ID: ProfileWeekly
weeklysettings: WeeklySettings = WeeklySettings()
emojis: Emojis = Emojis()
# Leveling
enabled: bool = False # Toggle leveling on/off
algorithm: Algorithm = Algorithm()
levelroles: t.Dict[int, int] = {} # Level: Role_ID
role_groups: t.Dict[int, float] = {} # Role_ID: Exp
use_embeds: bool = True # Use Embeds instead of generated images for leveling
showbal: bool = False # Show economy balance
autoremove: bool = False # Remove previous role on level up
style_override: t.Union[str, None] = None # Override the profile style for this guild
# Messages
xp: t.List[int] = [3, 6] # Min/Max XP per message
command_xp: bool = False # Whether to give XP for using commands
cooldown: int = 60 # Only gives XP every 60 seconds
min_length: int = 0 # Minimum length of message to be considered eligible for XP gain
# Voice
voicexp: int = 2 # XP per minute in voice
ignore_muted: bool = True # Ignore XP while being muted in voice
ignore_solo: bool = True # Ignore XP while in a voice chat alone (Bots dont count)
ignore_deafened: bool = True # Ignore XP while deafened in a voice chat
ignore_invisible: bool = True # Ignore XP while status is invisible in voice chat
# Bonuses
streambonus: t.List[int] = [] # Bonus voice XP for streaming in voice Example: [2, 5]
rolebonus: RoleBonus = RoleBonus()
channelbonus: ChannelBonus = ChannelBonus()
# Allowed
allowedchannels: t.List[int] = [] # Only channels that gain XP if not empty
allowedroles: t.List[int] = [] # Only roles that gain XP if not empty
# Ignored
ignoredchannels: t.List[int] = [] # Channels that dont gain XP
ignoredroles: t.List[int] = [] # Roles that dont gain XP
ignoredusers: t.List[int] = [] # Ignored users won't gain XP
# Prestige
prestigelevel: int = 0 # Level required to prestige, 0 is disabled
prestigedata: t.Dict[int, Prestige] = {} # Level: Prestige
stackprestigeroles: bool = True # Toggle whether to stack prestige roles
keep_level_roles: bool = False # Keep level roles after prestiging
# Alerts
notify: bool = False # Toggle whether to notify member of levelups in the channel they are in
notifylog: int = 0 # Notify member of level up in a set channel
notifydm: bool = False # Notify member of level up in DMs
notifymention: bool = False # Mention the user when sending a level up message
role_awarded_dm: str = "" # Role awarded message in DM
levelup_dm: str = "" # Level up message in DM
role_awarded_msg: t.Optional[str] = "" # Role awarded message in guild
levelup_msg: t.Optional[str] = "" # Level up message in guild
# Stars
starcooldown: int = 3600 # Cooldown in seconds for users to give each other stars
starmention: bool = False # Mention when users add a star
starmentionautodelete: int = 0 # Auto delete star mention reactions (0 to disable)
def get_profile(self, user: t.Union[discord.Member, int]) -> Profile:
uid = user if isinstance(user, int) else user.id
return self.users.setdefault(uid, Profile())
def get_weekly_profile(self, user: t.Union[discord.Member, int]) -> ProfileWeekly:
uid = user if isinstance(user, int) else user.id
return self.users_weekly.setdefault(uid, ProfileWeekly())
class DB(Base):
configs: t.Dict[int, GuildSettings] = {}
ignored_guilds: t.List[int] = []
cache_seconds: int = 0 # How long generated profile images should be cached, 0 to disable
render_gifs: bool = False # Whether to render profiles as gifs
force_embeds: bool = False # Globally force embeds for leveling
internal_api_port: int = 0 # If specified, starts internal api subprocess
external_api_url: str = "" # If specified, overrides internal api
auto_cleanup: bool = False # If True, will clean up configs of old guilds
ignore_bots: bool = True # Ignore bots completely
def get_conf(self, guild: t.Union[discord.Guild, int]) -> GuildSettings:
gid = guild if isinstance(guild, int) else guild.id
return self.configs.setdefault(gid, GuildSettings())
def run_migrations(settings: t.Dict[str, t.Any]) -> DB:
"""Sanitize old config data to be validated by the new schema"""
root: dict = settings["117117117"]
global_settings: dict = root["GLOBAL"]
guild_settings: dict = root["GUILD"]
data = {"configs": guild_settings, **global_settings}
migrated = 0
for conf in data["configs"].values():
if not conf:
continue
# Migrate weekly settings
if weekly := conf.get("weekly"):
conf["users_weekly"] = weekly.get("users", {})
conf["weeklysettings"] = weekly
# Migrate prestige data
if "prestigedata" in conf:
for level, pdata in conf["prestigedata"].items():
emoji = pdata["emoji"]["str"]
emoji_url = pdata["emoji"]["url"]
if isinstance(emoji, str) and emoji_url is None:
with suppress(TypeError, ValueError):
emoji_url = get_twemoji(emoji)
conf["prestigedata"][level] = {
"role": pdata["role"],
"emoji_string": emoji,
"emoji_url": emoji_url,
}
# Migrate profiles
if "users" in conf:
for profile in conf["users"].values():
colors = profile.pop("colors", {})
profile["namecolor"] = colors.get("name")
profile["statcolor"] = colors.get("stat")
profile["barcolor"] = colors.get("bar")
if not profile.get("background"):
profile["background"] = "default"
conf["role_awarded_dm"] = conf.get("lvlup_dm_role", "") or ""
conf["levelup_dm"] = conf.get("lvlup_dm", "") or ""
conf["role_awarded_msg"] = conf.get("lvlup_msg_role", "") or ""
conf["levelup_msg"] = conf.get("lvlup_msg", "") or ""
if conf.get("nofifylog") is None:
conf["notifylog"] = 0
if conf.get("mention") is not None:
conf["notifymention"] = conf["mention"]
if conf.get("usepics") is not None:
conf["use_embeds"] = not conf["usepics"]
if conf.get("prestige") is not None:
conf["prestigelevel"] = conf["prestige"]
if conf.get("rolebonuses") is not None:
conf["rolebonus"] = conf["rolebonuses"]
if conf.get("channelbonuses") is not None:
conf["channelbonus"] = conf["channelbonuses"]
if conf.get("muted") is not None:
conf["ignore_muted"] = conf["muted"]
if conf.get("solo") is not None:
conf["ignore_solo"] = conf["solo"]
if conf.get("deafened") is not None:
conf["ignore_deafened"] = conf["deafened"]
if conf.get("invisible") is not None:
conf["ignore_invisible"] = conf["invisible"]
if conf.get("length") is not None:
conf["min_length"] = conf["length"]
conf["algorithm"] = {
"base": conf.get("base", 100),
"exp": conf.get("exp", 2.0),
}
migrated += 1
log.warning(f"Migrated {migrated} guilds to new schema")
db: DB = DB.load(data)
return db

346
levelup/common/utils.py Normal file
View file

@ -0,0 +1,346 @@
import asyncio
import json
import logging
import math
import random
import re
import sys
import typing as t
from datetime import datetime, timedelta
from io import StringIO
import aiohttp
import discord
import plotly.graph_objects as go
from aiocache import cached
from redbot.core import commands
from redbot.core.i18n import Translator
from redbot.core.utils.predicates import MessagePredicate
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_random_exponential,
)
from .const import COLORS
_ = Translator("LevelUp", __file__)
log = logging.getLogger("red.vrt.levelup.formatter")
IMAGE_LINKS: t.Pattern = re.compile(
r"(https?:\/\/[^\"\'\s]*\.(?P<extension>png|jpg|jpeg|gif)"
r"(?P<extras>\?(?:ex=(?P<expires>\w+)&)(?:is=(?P<issued>\w+)&)(?:hm=(?P<token>\w+)&))?)", # Discord CDN info
flags=re.I,
)
TENOR_REGEX: t.Pattern[str] = re.compile(r"https:\/\/tenor\.com\/view\/(?P<image_slug>[a-zA-Z0-9-]+-(?P<image_id>\d+))")
EMOJI_REGEX: t.Pattern = re.compile(r"(<(?P<animated>a)?:[a-zA-Z0-9\_]+:([0-9]+)>)")
MENTION_REGEX: t.Pattern = re.compile(r"<@!?([0-9]+)>")
ID_REGEX: t.Pattern = re.compile(r"[0-9]{17,}")
VALID_CONTENT_TYPES = ("image/png", "image/jpeg", "image/jpg", "image/gif")
def string_to_rgb(color: str, as_discord_color: bool = False) -> t.Union[t.Tuple[int, int, int], discord.Color]:
if not color:
# Return white
if as_discord_color:
return discord.Color.from_rgb(255, 255, 255)
return 255, 255, 255
if color.isdigit():
color = int(color)
r = color & 255
g = (color >> 8) & 255
b = (color >> 16) & 255
if as_discord_color:
return discord.Color.from_rgb(r, g, b)
return r, g, b
elif color in COLORS:
color = COLORS[color]
color = color.strip("#")
r = int(color[:2], 16)
g = int(color[2:4], 16)
b = int(color[4:], 16)
if as_discord_color:
return discord.Color.from_rgb(r, g, b)
return r, g, b
def get_bar(progress, total, perc=None, width: int = 15) -> str:
fill = ""
space = ""
if perc is not None:
ratio = perc / 100
else:
ratio = progress / total
bar = fill * round(ratio * width) + space * round(width - (ratio * width))
return f"{bar} {round(100 * ratio, 1)}%"
# Format time from total seconds and format into readable string
def humanize_delta(delta: t.Union[int, timedelta]) -> str:
"""Format time in seconds into a human readable string"""
# Some time differences get sent as a float so just handle it the dumb way
time_in_seconds = delta.total_seconds() if isinstance(delta, timedelta) else int(delta)
minutes, seconds = divmod(time_in_seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
years, days = divmod(days, 365)
if not any([seconds, minutes, hours, days, years]):
tstring = _("None")
elif not any([minutes, hours, days, years]):
if seconds == 1:
tstring = str(seconds) + _(" second")
else:
tstring = str(seconds) + _(" seconds")
elif not any([hours, days, years]):
if minutes == 1:
tstring = str(minutes) + _(" minute")
else:
tstring = str(minutes) + _(" minutes")
elif hours and not days and not years:
tstring = f"{hours}h {minutes}m"
elif days and not years:
tstring = f"{days}d {hours}h {minutes}m"
else:
tstring = f"{years}y {days}d {hours}h {minutes}m"
return tstring
def get_twemoji(emoji: str) -> str:
"""Fetch the url of unicode emojis from Twemoji CDN"""
emoji_unicode = []
for char in emoji:
char = hex(ord(char))[2:]
emoji_unicode.append(char)
if "200d" not in emoji_unicode:
emoji_unicode = list(filter(lambda c: c != "fe0f", emoji_unicode))
emoji_unicode = "-".join(emoji_unicode)
return f"https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/{emoji_unicode}.png"
def get_next_reset(weekday: int, hour: int):
now = datetime.now()
reset = now + timedelta((weekday - now.weekday()) % 7)
return int(reset.replace(hour=hour, minute=0, second=0).timestamp())
def get_attachments(ctx: commands.Context) -> t.List[discord.Attachment]:
"""Get all attachments from context"""
content = []
if ctx.message.attachments:
atchmts = [a for a in ctx.message.attachments]
content.extend(atchmts)
if hasattr(ctx.message, "reference"):
try:
atchmts = [a for a in ctx.message.reference.resolved.attachments]
content.extend(atchmts)
except AttributeError:
pass
return content
def deep_getsizeof(obj: t.Any, seen: t.Optional[set] = None) -> int:
"""Recursively finds the size of an object in memory"""
if seen is None:
seen = set()
if id(obj) in seen:
return 0
# Mark object as seen
seen.add(id(obj))
size = sys.getsizeof(obj)
if isinstance(obj, dict):
# If the object is a dictionary, recursively add the size of keys and values
size += sum([deep_getsizeof(k, seen) + deep_getsizeof(v, seen) for k, v in obj.items()])
elif hasattr(obj, "__dict__"):
# If the object has a __dict__, it's likely an object. Find size of its dictionary
size += deep_getsizeof(obj.__dict__, seen)
elif hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes, bytearray)):
# If the object is an iterable (not a string or bytes), iterate through its items
size += sum([deep_getsizeof(i, seen) for i in obj])
elif hasattr(obj, "model_dump"):
# If the object is a pydantic model, get the size of its dictionary
size += deep_getsizeof(obj.model_dump(), seen)
elif hasattr(obj, "dict"):
# If the object is a pydantic model, get the size of its dictionary
size += deep_getsizeof(obj.dict(), seen)
return size
def humanize_size(num: float) -> str:
for unit in ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB"]:
if abs(num) < 1024.0:
return "{0:.1f}{1}".format(num, unit)
num /= 1024.0
return "{0:.1f}{1}".format(num, "YB")
def abbreviate_number(num: int) -> str:
if num < 1000:
return str(num)
for unit in ["", "K", "M", "B", "T", "Q"]:
if abs(num) < 1000.0:
return "{0:.1f}{1}".format(num, unit)
num /= 1000.0
return "{0:.1f}{1}".format(num, "E")
def get_day_name(day: int) -> str:
daymap = {
0: _("Monday"),
1: _("Tuesday"),
2: _("Wednesday"),
3: _("Thursday"),
4: _("Friday"),
5: _("Saturday"),
6: _("Sunday"),
}
return daymap[day]
@cached(ttl=60 * 60 * 24) # 24 hours
async def get_content_from_url(url: str) -> t.Union[bytes, None]:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url) as resp:
if resp.status == 404:
return None
return await resp.content.read()
async def confirm_msg(ctx: t.Union[commands.Context, discord.Interaction]) -> t.Union[bool, None]:
"""Wait for user to respond yes or no"""
if isinstance(ctx, discord.Interaction):
pred = MessagePredicate.yes_or_no(channel=ctx.channel, user=ctx.user)
bot = ctx.client
else:
pred = MessagePredicate.yes_or_no(ctx)
bot = ctx.bot
try:
await bot.wait_for("message", check=pred, timeout=30)
except asyncio.TimeoutError:
return None
else:
return pred.result
@retry(
retry=retry_if_exception_type(json.JSONDecodeError),
wait=wait_random_exponential(min=120, max=600),
stop=stop_after_attempt(6),
reraise=True,
)
async def fetch_amari_payload(guild_id: int, page: int, key: str):
url = f"https://amaribot.com/api/v1/guild/leaderboard/{guild_id}?page={page}&limit=1000"
headers = {"Accept": "application/json", "Authorization": key, "User-Agent": "Mozilla/5.0"}
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as res:
status = res.status
if status == 429:
log.warning("amari import is being rate limited!")
data = await res.json(content_type=None)
return data, status
@retry(
retry=retry_if_exception_type(json.JSONDecodeError),
wait=wait_random_exponential(min=120, max=600),
stop=stop_after_attempt(6),
reraise=True,
)
async def fetch_polaris_payload(guild_id: int, page: int):
url = f"https://gdcolon.com/polaris/api/leaderboard/{guild_id}?page={page}"
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers={"Accept": "application/json", "User-Agent": "Mozilla/5.0"}) as res:
status = res.status
if status == 429:
log.warning("polaris import is being rate limited!")
data = await res.json(content_type=None)
return data, status
@retry(
retry=retry_if_exception_type(json.JSONDecodeError),
wait=wait_random_exponential(min=120, max=600),
stop=stop_after_attempt(6),
reraise=True,
)
async def fetch_mee6_payload(guild_id: int, page: int):
url = f"https://mee6.xyz/api/plugins/levels/leaderboard/{guild_id}?page={page}&limit=1000"
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers={"Accept": "application/json", "User-Agent": "Mozilla/5.0"}) as res:
status = res.status
if status == 429:
log.warning("mee6 import is being rate limited!")
data = await res.json(content_type=None)
return data, status
def get_level(xp: int, base: int, exp: int) -> int:
"""Get a level that would be achieved from the amount of XP"""
return int((xp / base) ** (1 / exp))
def get_xp(level: int, base: int, exp: int) -> int:
"""Get how much XP is needed to reach a level"""
return math.ceil(base * (level**exp))
# Estimate how much time it would take to reach a certain level based on current algorithm
def time_to_level(
xp_needed: int,
xp_range: list,
cooldown: int,
) -> int:
xp_obtained = 0
time_to_reach_level = 0 # Seconds
while xp_obtained < xp_needed:
xp_obtained += random.randint(xp_range[0], xp_range[1] + 1)
mod = (60, 7200) if random.random() < 0.20 else (0, 60)
wait = cooldown + random.randint(*mod)
time_to_reach_level += wait
return time_to_reach_level
def plot_levels(
base: int, exponent: float, cooldown: int, xp_range: t.Tuple[int, int]
) -> t.Tuple[str, t.Optional[bytes]]:
buffer = StringIO()
x, y = [], []
for level in range(1, 21):
xp_required = get_xp(level, base, exponent)
seconds_required = time_to_level(xp_required, xp_range, cooldown)
time = humanize_delta(seconds_required)
buffer.write(_("• lvl {}, {} xp, {}\n").format(level, xp_required, time))
x.append(level)
y.append(xp_required)
try:
fig = go.Figure()
fig.add_trace(go.Scatter(x=x, y=y, mode="lines", name="Total"))
fig.update_layout(
title={
"text": _("XP Curve"),
"x": 0.5, # Set the x position to center
"y": 0.95, # Set the y position to top
"xanchor": "center", # Set the x anchor to center
"yanchor": "top", # Set the y anchor to top
},
xaxis_title=_("Level"),
yaxis_title=_("Experience Required"),
autosize=False,
width=500,
height=500,
margin=dict(l=50, r=50, b=100, t=100, pad=4),
plot_bgcolor="black", # Set the background color to black
paper_bgcolor="black", # Set the paper color to black
font=dict(color="white"), # Set the font color to white
)
img_bytes = fig.to_image(format="PNG")
return buffer.getvalue(), img_bytes
except Exception as e:
log.error("Failed to plot levels", exc_info=e)
return buffer.getvalue(), None

View file

@ -0,0 +1,205 @@
import asyncio
import logging
import typing as t
from pathlib import Path
import discord
from redbot.core import commands
from redbot.core.i18n import Translator
from ..abc import MixinMeta
from ..common import formatter
_ = Translator("LevelUp", __file__)
log = logging.getLogger("red.levelup.dashboard")
root = Path(__file__).parent
static = root / "static"
templates = root / "templates"
def dashboard_page(*args: t.Any, **kwargs: t.Any) -> t.Callable[[t.Any], t.Any]:
def decorator(func: t.Callable) -> t.Callable[[t.Any], t.Any]:
func.__dashboard_decorator_params__ = (args, kwargs)
return func
return decorator
class DashboardIntegration(MixinMeta):
@commands.Cog.listener()
async def on_dashboard_cog_add(self, dashboard_cog: commands.Cog) -> None:
log.info("Dashboard cog added, registering third party.")
dashboard_cog.rpc.third_parties_handler.add_third_party(self)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
async def get_dashboard_leaderboard(
self,
user: discord.User,
guild: discord.Guild,
lbtype: t.Literal["lb", "weekly"],
stat: t.Literal["exp", "messages", "voice", "stars"] = "exp",
**kwargs,
):
"""
kwargs = {
"user_id": int,
"guild_id": int,
"method": str, # GET or POST
"request_url": str,
"cxrf_token": str,
"wtf_csrf_secret_key": bytes,
"extra_kwargs": MultiDict,
"data": {
"form": ImmutableMultiDict,
"json": ImmutableMultiDict,
}
"lang_code": str,
"Form": FlaskForm,
"DpyObjectConverter": DpyObjectConverter,
"get_sorted_channels": Callable,
"get_sorted_roles": Callable,
"Pagination": Pagination,
}
"""
conf = self.db.get_conf(guild)
if lbtype == "weekly":
if not conf.weeklysettings.on:
return {
"status": 1,
"error_title": _("Weekly stats disabled"),
"error_message": _("Weekly stats are disabled for this guild."),
}
if not conf.users_weekly:
return {
"status": 1,
"error_title": _("No Weekly stats available"),
"error_message": _("There is no data for the weekly leaderboard yet, please chat a bit first."),
}
source_path = templates / "leaderboard.html"
js_path = static / "js" / "leaderboard.js"
css_path = static / "css" / "leaderboard.css"
# Inject JS and CSS into the HTML source for full page loads
source = (
f"<style>\n{css_path.read_text()}\n</style>\n\n"
+ source_path.read_text().strip()
+ f"\n\n<script>\n{js_path.read_text()}\n</script>"
)
"""
{
"title": str,
"description": str,
"stat": str,
"stats": [{"position": int, "name": str, "id": int, "stat": str}]
"total": str,
"type": leaderboard type, // lb or weekly
"user_position": int, // Index of the user in the leaderboard
}
"""
res: dict = await asyncio.to_thread(
formatter.get_leaderboard,
bot=self.bot,
guild=guild,
db=self.db,
stat=stat,
lbtype=lbtype,
is_global=False,
member=guild.get_member(user.id),
use_displayname=True,
dashboard=True,
)
data = {
"user_id": user.id,
"users": res["stats"],
"stat": stat,
"total": res["description"].replace("`", ""),
"type": lbtype,
"page": int(kwargs["extra_kwargs"].get("page", 1)),
}
content = {
"status": 0,
"web_content": {
"source": source,
"data": data,
"stat": stat,
"statname": res["stat"],
"expanded": True,
},
}
return content
@dashboard_page(name="leaderboard", description="Display the guild leaderboard.")
async def leaderboard_page(
self, user: discord.User, guild: discord.Guild, stat: str = None, **kwargs
) -> t.Dict[str, t.Any]:
stat = stat if stat is not None and stat in {"exp", "messages", "voice", "stars"} else "exp"
return await self.get_dashboard_leaderboard(user, guild, "lb", stat, **kwargs)
@dashboard_page(name="weekly", description="Display the guild weekly leaderboard.")
async def weekly_page(
self, user: discord.User, guild: discord.Guild, stat: str = None, **kwargs
) -> t.Dict[str, t.Any]:
stat = stat if stat is not None and stat in {"exp", "messages", "voice", "stars"} else "exp"
return await self.get_dashboard_leaderboard(user, guild, "weekly", stat, **kwargs)
# @dashboard_page(name="settings", description="Configure the leveling system.", methods=("GET", "POST"))
async def cog_settings(self, user: discord.User, guild: discord.Guild, **kwargs):
import wtforms # pip install WTForms
from flask_wtf import FlaskForm # pip install Flask-WTF
log.info(f"Getting settings for {guild.name} by {user.name}")
member = guild.get_member(user.id)
if not member:
log.warning(f"Member {user.name} not found in guild {guild.name}")
return {
"status": 1,
"error_title": _("Member not found"),
"error_message": _("You are not a member of this guild."),
}
if not await self.bot.is_admin(member):
log.warning(f"Member {user.name} is not an admin in guild {guild.name}")
return {
"status": 1,
"error_title": _("Insufficient permissions"),
"error_message": _("You need to be an admin to access this page."),
}
conf = self.db.get_conf(guild)
class SettingsForm(kwargs["Form"]):
def __init__(self):
super().__init__(prefix="levelup_settings_form_")
# General settings
enabled = wtforms.BooleanField(_("Enabled:"), default=conf.enabled)
algo_base = wtforms.IntegerField(
_("Algorithm Base:"), default=conf.algorithm.base, validators=[wtforms.validators.InputRequired()]
)
algo_multiplier = wtforms.FloatField(
_("Algorithm Multiplier:"), default=conf.algorithm.exp, validators=[wtforms.validators.InputRequired()]
)
# Submit button
submit = wtforms.SubmitField(_("Save Settings"))
form: FlaskForm = SettingsForm()
# Handle form submission
if form.validate_on_submit() and await form.validate_dpy_converters():
log.info(f"Form validated for {guild.name} by {user.name}")
conf.enabled = form.enabled.data
conf.algorithm.base = form.algo_base.data or 100
conf.algorithm.exp = form.algo_multiplier.data or 2.0
self.save()
return {
"status": 0,
"notifications": [{"message": _("Settings saved"), "category": "success"}],
"redirect_url": kwargs["request_url"],
}
source = (templates / "settings.html").read_text()
return {
"status": 0,
"web_content": {"source": source, "settings_form": form},
}

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: German\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: de_DE\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: es-ES\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: es_ES\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr "Estadísticas semanales desactivadas"
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr "Las estadísticas semanales están desactivadas para este gremio."
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr "No hay estadísticas semanales disponibles"
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr "Todavía no hay datos para la clasificación semanal, por favor, chatea un poco antes."

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: French\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: fr_FR\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Croatian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: hr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: hr_HR\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: ko\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: ko_KR\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,29 @@
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-07-24 17:08-0400\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid ""
"There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: pt-PT\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: pt_PT\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: ru\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: ru_RU\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,35 @@
msgid ""
msgstr ""
"Project-Id-Version: vrt-cogs\n"
"POT-Creation-Date: 2024-06-18 16:29-0400\n"
"PO-Revision-Date: 2024-12-03 14:58\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: redgettext 3.4.2\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: vrt-cogs\n"
"X-Crowdin-Project-ID: 550681\n"
"X-Crowdin-Language: tr\n"
"X-Crowdin-File: /[vertyco.vrt-cogs] main/levelup/dashboard/locales/messages.pot\n"
"X-Crowdin-File-ID: 164\n"
"Language: tr_TR\n"
#: levelup\dashboard\integration.py:42
msgid "Weekly stats disabled"
msgstr ""
#: levelup\dashboard\integration.py:43
msgid "Weekly stats are disabled for this guild."
msgstr ""
#: levelup\dashboard\integration.py:48
msgid "No Weekly stats available"
msgstr ""
#: levelup\dashboard\integration.py:49
msgid "There is no data for the weekly leaderboard yet, please chat a bit first."
msgstr ""

View file

@ -0,0 +1,110 @@
/* Trippy search bar styling */
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes glow-pulse {
0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.7); }
50% { box-shadow: 0 0 20px rgba(0, 255, 255, 0.9); }
100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.7); }
}
@keyframes wobble {
0%, 100% { transform: translateX(0) rotate(0); }
25% { transform: translateX(-5px) rotate(-1deg); }
75% { transform: translateX(5px) rotate(1deg); }
}
/* Default search input styles */
.search-container input[type="text"] {
transition: all 0.3s ease;
}
/* Trippy styles applied conditionally */
.search-container input[type="text"].trippy-search {
background: linear-gradient(45deg, #ff00e1, #00ffff, #ff00a2, #00ff9d, #8400ff, #00e1ff);
background-size: 600% 600%;
animation: gradient-shift 10s ease infinite, glow-pulse 3s infinite;
color: white;
text-shadow: 1px 1px 2px black;
font-weight: bold;
border: 2px solid transparent;
/* border-image: linear-gradient(to right, violet, indigo, blue, green, yellow, orange, red); */
border-image-slice: 1;
}
.search-container input[type="text"].trippy-search:focus {
animation: gradient-shift 3s ease infinite, glow-pulse 1.5s infinite, wobble 2s ease-in-out infinite;
transform: scale(1.02);
outline: none;
}
.search-container input[type="text"].trippy-search::placeholder {
color: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}
.search-container input[type="text"].trippy-search:not(:placeholder-shown) {
font-weight: bold;
letter-spacing: 0.5px;
}
/* Toggle switch styling */
.form-switch .form-check-input {
cursor: pointer;
}
.form-check-label {
font-size: 0.8rem;
cursor: pointer;
}
/* Sleek Toggle Switch */
.trippy-toggle {
position: relative;
display: inline-block;
}
.trippy-toggle .toggle-input {
opacity: 0;
width: 0;
height: 0;
}
.trippy-toggle .toggle-label {
position: relative;
display: inline-block;
width: 30px;
height: 16px;
background-color: #ccc;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.trippy-toggle .toggle-label:before {
position: absolute;
content: '';
height: 12px;
width: 12px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
.trippy-toggle .toggle-input:checked + .toggle-label {
background: linear-gradient(90deg, #ff00e1, #00ffff);
box-shadow: 0 0 5px rgba(0, 255, 255, 0.5);
}
.trippy-toggle .toggle-input:checked + .toggle-label:before {
transform: translateX(14px);
}
.search-input-container {
position: relative;
}

View file

@ -0,0 +1,176 @@
document.addEventListener('alpine:init', () => {
Alpine.data('leaderBoard', (data = {}) => ({
// DATA INITIALIZATION
searchQuery: '',
page: data.page,
perPage: 100,
users: data.users,
sortStat: data.stat, // Can be exp, messages, voice, stars
sortOptions: ['exp', 'messages', 'voice', 'stars'],
sortMenuOpen: false,
currentUserId: data.user_id,
total: data.total,
open: false, // Whether dropdown is open
trippyMode: false,
trippyEnabled: false,
typingTimer: null,
// USER PAGINATION FUNCTIONS
getPageUsers() {
console.log("Fetching users for page", this.page);
console.log(`There are ${this.users.length} users in total`);
if (this.searchQuery === "") {
return this.users.slice((this.page - 1) * this.perPage, this.page * this.perPage);
} else {
// Need to filter users based on search query
const filteredUsers = this.filterUsers();
return filteredUsers.slice((this.page - 1) * this.perPage, this.page * this.perPage);
}
},
nextPage() {
if (this.page * this.perPage < this.users.length) {
this.page++;
this.updateUrlParam('page', this.page);
}
},
prevPage() {
if (this.page > 1) {
this.page--;
this.updateUrlParam('page', this.page);
}
},
setPerPage(value) {
if (this.perPage !== value) {
this.perPage = value;
this.page = 1; // Reset to first page when changing items per page
localStorage.setItem('leaderBoardPerPage', value);
this.updateUrlParam('perPage', value);
this.getPageUsers();
}
},
getPageCount() {
if (this.searchQuery === "") {
return Math.ceil(this.users.length / this.perPage);
} else {
// Need to filter users based on search query
const filteredUsers = this.filterUsers();
return Math.ceil(filteredUsers.length / this.perPage);
}
},
// URL AND BROWSER HISTORY MANAGEMENT
updateUrlParam(param, value) {
// Get current URL and parse its query parameters
const url = new URL(window.location.href);
// Update or add the parameter
url.searchParams.set(param, value);
// Update browser history without refreshing
history.pushState({}, '', url.toString());
},
// SEARCH AND FILTERING
filterUsers() {
// Filter users based on search query
if (this.searchQuery === "") {
return this.users;
}
return this.users.filter(user => user.name.toLowerCase().includes(this.searchQuery.toLowerCase()));
},
// TRIPPY MODE FUNCTIONALITY
toggleTrippy() {
this.trippyEnabled = !this.trippyEnabled;
localStorage.setItem('leaderBoardTrippy', this.trippyEnabled);
// If we're enabling it, immediately show effects
if (this.trippyEnabled) {
this.trippyMode = true;
} else {
this.trippyMode = false;
}
},
handleTyping() {
if (this.trippyEnabled) {
this.trippyMode = true;
// Clear previous timer
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
// Set a timer to disable trippy mode after user stops typing
this.typingTimer = setTimeout(() => {
this.trippyMode = false;
}, 1500);
}
},
// INITIALIZATION AND LOCAL STORAGE
init() {
console.log(`Leaderboard initialized starting on page ${this.page}`);
// Store the URL-provided page (if any)
const urlProvidedPage = this.page;
// Only use localStorage page if no page was specified in the URL (or was specified as 1)
if (!urlProvidedPage || urlProvidedPage === 1) {
const storedPage = localStorage.getItem('leaderBoardPage');
if (storedPage !== null) {
this.page = parseInt(storedPage) || 1;
}
}
// Retrieve stored values if available
const storedSearch = localStorage.getItem('leaderBoardSearch');
if (storedSearch !== null) {
this.searchQuery = storedSearch;
}
// Get stored perPage preference
const storedPerPage = localStorage.getItem('leaderBoardPerPage');
if (storedPerPage !== null) {
this.perPage = parseInt(storedPerPage) || 100;
}
// Load trippy preference
const storedTrippy = localStorage.getItem('leaderBoardTrippy');
if (storedTrippy !== null) {
this.trippyEnabled = storedTrippy === 'true';
// If trippy is enabled at load, show effect briefly
if (this.trippyEnabled) {
this.trippyMode = true;
setTimeout(() => {
if (!this.searchQuery) { // Only turn off if not actively searching
this.trippyMode = false;
}
}, 1500);
}
}
const storedAutoTrippy = localStorage.getItem('leaderBoardAutoTrippy');
if (storedAutoTrippy !== null) {
this.autoTrippy = storedAutoTrippy === 'true';
} else {
// Default to true for auto-trippy
this.autoTrippy = true;
}
// WATCHERS
// Watch for changes to store them
this.$watch('searchQuery', (newquery, oldquery) => {
console.log(`Search query changed from ${oldquery} to ${newquery}`);
localStorage.setItem('leaderBoardSearch', newquery);
this.page = 1;
localStorage.setItem('leaderBoardPage', '1');
this.getPageUsers();
// Call typing handler when search query changes
this.handleTyping();
});
this.$watch('page', (newpage, oldpage) => {
localStorage.setItem('leaderBoardPage', newpage);
});
},
}));
});

View file

@ -0,0 +1,53 @@
document.addEventListener('alpine:init', () => {
Alpine.data('levelUpSettings', (data = {}) => ({
// DATA INITIALIZATION
settings: data.settings || {},
users: data.users || [],
emojis: data.emojis || [],
roles: data.roles || [],
newLevelRole: { level: 1, role: '' },
// METHODS
async saveSettings(event) {
console.log(`Event: ${event}`);
console.log("Saving settings:", this.settings);
try {
// Get CSRF token from span element's data-value attribute
const csrfToken = document.querySelector('#settings-csrf-token').value;
console.log("CSRF token:", csrfToken);
if (!csrfToken) {
console.error("CSRF token not found");
alert("CSRF token not found. Please refresh the page and try again.");
return;
}
// Wrap settings in the expected structure
const requestData = {
save: true,
new_data: this.settings
};
const response = await fetch(window.location.href, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken // Changed from X-CSRF-Token to X-CSRFToken
},
credentials: 'same-origin', // Added to ensure cookies are sent
body: JSON.stringify(requestData)
});
const result = await response.json();
if (result.status === 0) {
alert(result.success_message);
} else {
alert(`Error: ${result.error_message}`);
}
} catch (error) {
console.error("Error saving settings:", error);
alert("An error occurred while saving settings.");
}
}
}));
});

View file

@ -0,0 +1,177 @@
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<div class="leaderboard-container" x-data="leaderBoard({{ data }})">
<div class="d-flex justify-content-between align-items-center">
<h5 class="me-auto stats-info" type="text" x-text="total"></h5>
<div class="dropdown">
<a
class="btn btn-secondary"
role="button"
id="sort_by_dropdown"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
Sort by <i class="ni ni-bold-down"></i>
</a>
<div class="dropdown-menu" aria-labelledby="sort_by_dropdown">
<a class="dropdown-item" href="{{ url_for_query(stat=None) }}"
>{% if stat == "exp" %}<i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i
>{% endif %}Exp</a
>
<a class="dropdown-item" href="{{ url_for_query(stat='messages') }}"
>{% if stat == "messages" %}<i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i
>{% endif %}Messages</a
>
<a class="dropdown-item" href="{{ url_for_query(stat='voice') }}"
>{% if stat == "voice" %}<i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i
>{% endif %}Voice</a
>
<a class="dropdown-item" href="{{ url_for_query(stat='stars') }}"
>{% if stat == "stars" %}<i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i
>{% endif %}Stars</a
>
</div>
</div>
{% if position_url %}
<a
href="{{ position_url }}"
class="btn bg-gradient-{{ variables['meta']['color'] }} position-btn"
>
Go to my position
</a>
{% endif %}
</div>
<div class="search-container my-3">
<div class="search-input-container d-flex align-items-center mb-2">
<input
type="text"
x-text="searchQuery"
x-model="searchQuery"
class="form-control w-100"
x-bind:class="{ 'trippy-search': trippyMode }"
placeholder="{{ _('Search for a user...') }}"
/>
<div class="trippy-toggle ms-2">
<input
class="toggle-input"
type="checkbox"
id="trippyToggle"
x-model="trippyEnabled"
@click="toggleTrippy()"
/>
<label class="toggle-label" for="trippyToggle"></label>
</div>
</div>
<div
class="pagination-controls my-3 d-flex justify-content-between align-items-center"
>
<button
class="btn btn-primary"
x-on:click="prevPage()"
x-bind:disabled="page === 1"
>
Previous
</button>
<div class="d-flex flex-column align-items-center">
<span x-text="'Page ' + page + ' of ' + getPageCount()"></span>
<div class="dropdown">
<button
class="btn btn-sm btn-secondary dropdown-toggle mt-1"
type="button"
id="per_page_dropdown"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<span x-text="perPage"></span> per page
</button>
<div class="dropdown-menu" aria-labelledby="per_page_dropdown">
<a class="dropdown-item" x-on:click="setPerPage(25)" href="#"
><span x-show="perPage === 25"
><i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i></span
>25</a
>
<a class="dropdown-item" x-on:click="setPerPage(50)" href="#"
><span x-show="perPage === 50"
><i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i></span
>50</a
>
<a class="dropdown-item" x-on:click="setPerPage(100)" href="#"
><span x-show="perPage === 100"
><i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i></span
>100</a
>
<a class="dropdown-item" x-on:click="setPerPage(1000)" href="#"
><span x-show="perPage === 1000"
><i
class="ni ni-check-bold me-2"
style="vertical-align: -1.5px"
></i></span
>All</a
>
</div>
</div>
</div>
<button
class="btn btn-primary"
x-on:click="nextPage()"
x-bind:disabled="page * perPage >= filterUsers().length"
>
Next
</button>
</div>
<div id="users-container" class="table-container">
<table class="table align-items-center mb-0">
<thead>
<tr>
<td class="medium"><b>#</b></td>
<td class="large"><b>Name:</b></td>
<td class="medium"><b>{{ statname }}:</b></td>
</tr>
</thead>
<tbody>
<template x-for="user in getPageUsers()" :key="user.id">
<!-- If user is current user, highlight row in blue -->
<tr
x-bind:class="{ 'bg-gradient-primary': user.id === currentUserId }"
>
<td class="medium" x-text="user.position"></td>
<td class="large" x-text="user.name" x-bind:title="user.id"></td>
<td class="medium" x-text="user.stat"></td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- End of table-container -->
</div>
</div>

View file

@ -0,0 +1,9 @@
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<div class="container-fluid">
<h3 class="text-dark mb-4">This page is a work in progress!</h3>
<div class="card-body">{{ settings_form|safe }}</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

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