Upload 2 Cogs & Update README
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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())
|
6
appeals/commands/__init__.py
Normal 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
|
@ -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)
|
0
appeals/common/__init__.py
Normal file
23
appeals/common/checks.py
Normal 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
|
@ -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 ###
|
729
appeals/db/migrations/appeals_2024_12_05t19_16_56_208214.py
Normal 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
|
36
appeals/db/migrations/appeals_2024_12_07t15_29_49_006538.py
Normal 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
|
36
appeals/db/migrations/appeals_2025_02_14t13_21_03_690552.py
Normal 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
|
@ -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"),
|
||||
)
|
8
appeals/db/piccolo_conf.py
Normal 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
|
@ -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
|
@ -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)
|
||||
)
|
11
appeals/engine/__init__.py
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
|||
class UNCPathError(Exception):
|
||||
message: str
|
||||
|
||||
|
||||
class DirectoryError(Exception):
|
||||
message: str
|
16
appeals/info.json
Normal 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"
|
||||
}
|
10
appeals/listeners/__init__.py
Normal 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.
|
||||
"""
|
82
appeals/listeners/messages.py
Normal 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
|
@ -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
|
0
appeals/views/__init__.py
Normal file
119
appeals/views/appeal.py
Normal 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)
|
287
appeals/views/dynamic_menu.py
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
19
levelup/commands/__init__.py
Normal 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
759
levelup/commands/data.py
Normal 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()
|
2594
levelup/commands/locales/de-DE.po
Normal file
2891
levelup/commands/locales/es-ES.po
Normal file
2594
levelup/commands/locales/fr-FR.po
Normal file
2594
levelup/commands/locales/hr-HR.po
Normal file
2594
levelup/commands/locales/ko-KR.po
Normal file
2762
levelup/commands/locales/messages.pot
Normal file
2594
levelup/commands/locales/pt-PT.po
Normal file
2594
levelup/commands/locales/ru-RU.po
Normal file
2594
levelup/commands/locales/tr-TR.po
Normal file
299
levelup/commands/owner.py
Normal 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
|
@ -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
|
@ -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
|
@ -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()
|
1
levelup/common/__init__.py
Normal 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
318
levelup/common/formatter.py
Normal 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
|
275
levelup/common/locales/de-DE.po
Normal 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 ""
|
||||
|
301
levelup/common/locales/es-ES.po
Normal 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"
|
||||
|
275
levelup/common/locales/fr-FR.po
Normal 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 ""
|
||||
|
275
levelup/common/locales/hr-HR.po
Normal 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 ""
|
||||
|
275
levelup/common/locales/ko-KR.po
Normal 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 ""
|
||||
|
281
levelup/common/locales/messages.pot
Normal 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 ""
|
275
levelup/common/locales/pt-PT.po
Normal 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 ""
|
||||
|
275
levelup/common/locales/ru-RU.po
Normal 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 ""
|
||||
|
275
levelup/common/locales/tr-TR.po
Normal 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
|
@ -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
|
@ -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
|
205
levelup/dashboard/integration.py
Normal 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},
|
||||
}
|
35
levelup/dashboard/locales/de-DE.po
Normal 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 ""
|
||||
|
35
levelup/dashboard/locales/es-ES.po
Normal 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."
|
||||
|
35
levelup/dashboard/locales/fr-FR.po
Normal 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 ""
|
||||
|
35
levelup/dashboard/locales/hr-HR.po
Normal 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 ""
|
||||
|
35
levelup/dashboard/locales/ko-KR.po
Normal 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 ""
|
||||
|
29
levelup/dashboard/locales/messages.pot
Normal 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 ""
|
35
levelup/dashboard/locales/pt-PT.po
Normal 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 ""
|
||||
|
35
levelup/dashboard/locales/ru-RU.po
Normal 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 ""
|
||||
|
35
levelup/dashboard/locales/tr-TR.po
Normal 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 ""
|
||||
|
110
levelup/dashboard/static/css/leaderboard.css
Normal 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;
|
||||
}
|
0
levelup/dashboard/static/css/settings.css
Normal file
176
levelup/dashboard/static/js/leaderboard.js
Normal 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);
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
53
levelup/dashboard/static/js/settings.js
Normal 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.");
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
177
levelup/dashboard/templates/leaderboard.html
Normal 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>
|
9
levelup/dashboard/templates/settings.html
Normal 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>
|
BIN
levelup/data/backgrounds/card01.webp
Normal file
After Width: | Height: | Size: 207 KiB |
BIN
levelup/data/backgrounds/card02.webp
Normal file
After Width: | Height: | Size: 93 KiB |
BIN
levelup/data/backgrounds/card03.webp
Normal file
After Width: | Height: | Size: 400 KiB |
BIN
levelup/data/backgrounds/card04.webp
Normal file
After Width: | Height: | Size: 165 KiB |
BIN
levelup/data/backgrounds/card05.webp
Normal file
After Width: | Height: | Size: 201 KiB |
BIN
levelup/data/backgrounds/card06.webp
Normal file
After Width: | Height: | Size: 364 KiB |
BIN
levelup/data/backgrounds/card07.webp
Normal file
After Width: | Height: | Size: 236 KiB |
BIN
levelup/data/backgrounds/card08.webp
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
levelup/data/backgrounds/card09.webp
Normal file
After Width: | Height: | Size: 130 KiB |
BIN
levelup/data/backgrounds/card10.webp
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
levelup/data/backgrounds/card11.webp
Normal file
After Width: | Height: | Size: 283 KiB |
BIN
levelup/data/backgrounds/card12.webp
Normal file
After Width: | Height: | Size: 208 KiB |
BIN
levelup/data/backgrounds/card13.webp
Normal file
After Width: | Height: | Size: 171 KiB |
BIN
levelup/data/backgrounds/card14.webp
Normal file
After Width: | Height: | Size: 232 KiB |
BIN
levelup/data/backgrounds/card15.webp
Normal file
After Width: | Height: | Size: 210 KiB |
BIN
levelup/data/backgrounds/card16.webp
Normal file
After Width: | Height: | Size: 100 KiB |
BIN
levelup/data/backgrounds/card17.webp
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
levelup/data/backgrounds/card18.webp
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
levelup/data/backgrounds/card19.webp
Normal file
After Width: | Height: | Size: 75 KiB |
BIN
levelup/data/backgrounds/card20.webp
Normal file
After Width: | Height: | Size: 143 KiB |