forked from blizzthewolf/SeaCogs
Compare commits
77 commits
aurora-hyb
...
main
Author | SHA1 | Date | |
---|---|---|---|
e94f640712 | |||
0e2e8e4216 | |||
863bed33f5 | |||
8d8fd0c04f | |||
d0474a3707 | |||
d17a7e645f | |||
aaf9ac1b4e | |||
dbe6fc2390 | |||
b67b692201 | |||
15715dff3c | |||
73b0e73ff0 | |||
d9c123d441 | |||
4f38fc1f7d | |||
602d759e67 | |||
3da2d74a48 | |||
4344d26096 | |||
285257eed5 | |||
2b7f320d2a | |||
df970717c2 | |||
97b54b507b | |||
06e011f670 | |||
0955282325 | |||
19fc6adaad | |||
7ca836759f | |||
833d6954cc | |||
b9e06d6288 | |||
f28a8aad0e | |||
a9c2f18b5d | |||
f0bc915da8 | |||
46b7123fdd | |||
04c3b0e83c | |||
33ce8a147c | |||
01b249fbb3 | |||
f572a0d535 | |||
9f7244cd65 | |||
a4f2d21fa1 | |||
ba25078f3f | |||
3066848853 | |||
0ed7ab6727 | |||
f713780d49 | |||
5adaca755d | |||
09d7f634f2 | |||
069621eee8 | |||
76da85365c | |||
1edb08a127 | |||
43464db6a7 | |||
50d1d7900b | |||
6ec79c9f92 | |||
9f6e960a25 | |||
46b534ebf8 | |||
74f58162de | |||
39808f1766 | |||
014025f547 | |||
9b0a11a7bc | |||
6ab593390c | |||
7c16ec8df8 | |||
cbd9f28f38 | |||
c546fa597b | |||
7b859e07e9 | |||
5f4cb88ea8 | |||
4135cd4f98 | |||
d6bccf20e9 | |||
7a75266b01 | |||
25b26322d2 | |||
|
40b846123f | ||
4c603eea46 | |||
92e8ee2dc2 | |||
99dddf2fa7 | |||
ba7a5f9208 | |||
ddb9f30d6f | |||
3b5932bac9 | |||
f4efcb8ea5 | |||
6035aea5c6 | |||
03c14a0311 | |||
56522e51ad | |||
9eff010b35 | |||
c0195f44f6 |
31 changed files with 1670 additions and 1433 deletions
|
@ -10,6 +10,14 @@ Backup allows you to export a JSON list of all of your installed repositories an
|
||||||
[p]cog load backup
|
[p]cog load backup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Version Compatibility
|
||||||
|
|
||||||
|
As of commit [1edb08a](https://www.coastalcommits.com/SeaswimmerTheFsh/SeaCogs/commit/1edb08a1271f12098ca0bed11a735f7162cedd14), the Backup cog no longer supports Red versions older than 3.5.6. If you want to use the cog on an earlier version (3.5.0 - 3.5.5), install the cog pinned to this commit: `43464db6a7c51bc69282b1ae3dc507a4aae851de`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[p]cog installversion sea-cogs 43464db6a7c51bc69282b1ae3dc507a4aae851de backup
|
||||||
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### backup export
|
### backup export
|
||||||
|
|
|
@ -39,7 +39,9 @@ Default value:
|
||||||
tellraw @a ["",{"text":".$N ","color":".$C","insertion":"<@.$I>","hoverEvent":{"action":"show_text","contents":"Shift click to mention this user inside Discord"}},{"text":"(DISCORD):","color":"blue","clickEvent":{"action":"open_url","value":".$V"},"hoverEvent":{"action":"show_text","contents":"Click to join the Discord Server"}},{"text":" .$M","color":"white"}]
|
tellraw @a ["",{"text":".$N ","color":".$C","insertion":"<@.$I>","hoverEvent":{"action":"show_text","contents":"Shift click to mention this user inside Discord"}},{"text":"(DISCORD):","color":"blue","clickEvent":{"action":"open_url","value":".$V"},"hoverEvent":{"action":"show_text","contents":"Click to join the Discord Server"}},{"text":" .$M","color":"white"}]
|
||||||
```
|
```
|
||||||
|
|
||||||
## `consolechannel`
|
## `console`
|
||||||
|
|
||||||
|
### `channel`
|
||||||
|
|
||||||
/// admonition | Only give access to the console channel to people you trust!
|
/// admonition | Only give access to the console channel to people you trust!
|
||||||
type: danger
|
type: danger
|
||||||
|
@ -63,6 +65,21 @@ This is to prevent the console channel from flooding and getting backed up by Di
|
||||||
|
|
||||||
Default value: `None`
|
Default value: `None`
|
||||||
|
|
||||||
|
### `commands`
|
||||||
|
|
||||||
|
/// admonition | This has no effect on the `[p]pterodactyl command` text command, or the matching slash command.
|
||||||
|
type: danger
|
||||||
|
If you want to disable the ability to execute commands on the server through Discord, use the following commands:
|
||||||
|
`[p]pterodactyl config console commands False` - this command
|
||||||
|
`[p]command disable pterodactyl command` - disables the text command that lets you execute commands on the server
|
||||||
|
`[p]slash disable pterodactyl` - due to how slash commands are laid out, this is the only way to disable the ability to execute commands on the server
|
||||||
|
`[p]slash sync` - apply above slash command change
|
||||||
|
///
|
||||||
|
|
||||||
|
This option determines if commands sent to the console channel will be sent to the Pterodactyl console.
|
||||||
|
|
||||||
|
Default value: `False`
|
||||||
|
|
||||||
## `invite`
|
## `invite`
|
||||||
|
|
||||||
This option determines what url the chat command will substitute in for the Discord invite placeholder.
|
This option determines what url the chat command will substitute in for the Discord invite placeholder.
|
||||||
|
@ -136,6 +153,41 @@ This option determines which server's websocket to connect to. See [Getting Star
|
||||||
|
|
||||||
Default value: `None`
|
Default value: `None`
|
||||||
|
|
||||||
|
## `topic`
|
||||||
|
|
||||||
|
### `host`
|
||||||
|
|
||||||
|
This option determines the hostname of your server that will be used to retrieve server information.
|
||||||
|
|
||||||
|
### `port`
|
||||||
|
|
||||||
|
This option determines the port of your server that will be used to retrieve server information.
|
||||||
|
|
||||||
|
Default value: `25565`
|
||||||
|
|
||||||
|
### `text`
|
||||||
|
|
||||||
|
This option determines what the channel topic will be set to.
|
||||||
|
|
||||||
|
Available placeholders:
|
||||||
|
|
||||||
|
- `.$H` - replaced with the server's hostname
|
||||||
|
- `.$O` - replaced with the server's port
|
||||||
|
|
||||||
|
Available with a Minecraft server:
|
||||||
|
|
||||||
|
- `.$I` - replaced with the server's ip address
|
||||||
|
- `.$M` - replaced with maximum player count
|
||||||
|
- `.$P` - replaced with current online player count
|
||||||
|
- `.$V` - replaced with the server's current version
|
||||||
|
- `.$D` - replaced with the server's description / message of the day
|
||||||
|
|
||||||
|
Default value:
|
||||||
|
|
||||||
|
```
|
||||||
|
Server IP: .$H\nServer Players: .$P/.$M
|
||||||
|
```
|
||||||
|
|
||||||
## `url`
|
## `url`
|
||||||
|
|
||||||
This option determines what panel the cog will send requests to. See [Getting Started](getting-started.md#getting-server-information) for more information on this.
|
This option determines what panel the cog will send requests to. See [Getting Started](getting-started.md#getting-server-information) for more information on this.
|
||||||
|
|
|
@ -17,4 +17,5 @@
|
||||||
import-outside-toplevel,
|
import-outside-toplevel,
|
||||||
import-self,
|
import-self,
|
||||||
relative-beyond-top-level,
|
relative-beyond-top-level,
|
||||||
too-many-instance-attributes
|
too-many-instance-attributes,
|
||||||
|
duplicate-code
|
||||||
|
|
|
@ -17,11 +17,11 @@ jobs:
|
||||||
run: poetry install --with dev --no-root
|
run: poetry install --with dev --no-root
|
||||||
|
|
||||||
- name: Analysing code with Ruff
|
- name: Analysing code with Ruff
|
||||||
run: ruff check $(git ls-files '*.py')
|
run: ./.venv/bin/ruff check $(git ls-files '*.py')
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Analysing code with Pylint
|
- name: Analysing code with Pylint
|
||||||
run: pylint --rcfile=.forgejo/workflows/config/.pylintrc $(git ls-files '*.py')
|
run: ./.venv/bin/pylint --rcfile=.forgejo/workflows/config/.pylintrc $(git ls-files '*.py')
|
||||||
|
|
||||||
Build Documentation (MkDocs):
|
Build Documentation (MkDocs):
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
@ -42,7 +42,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
export SITE_URL="https://$CI_ACTION_REF_NAME_SLUG.seacogs.coastalcommits.com"
|
export SITE_URL="https://$CI_ACTION_REF_NAME_SLUG.seacogs.coastalcommits.com"
|
||||||
export EDIT_URI="src/branch/$CI_ACTION_REF_NAME/.docs"
|
export EDIT_URI="src/branch/$CI_ACTION_REF_NAME/.docs"
|
||||||
mkdocs build -v
|
./.venv/bin/mkdocs build -v
|
||||||
|
|
||||||
- name: Deploy documentation
|
- name: Deploy documentation
|
||||||
run: |
|
run: |
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
||||||
.cache
|
.cache
|
||||||
.vscode
|
.vscode
|
||||||
site
|
site
|
||||||
|
.venv
|
||||||
|
|
5
antipolls/__init__.py
Normal file
5
antipolls/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from .antipolls import AntiPolls
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
await bot.add_cog(AntiPolls(bot))
|
180
antipolls/antipolls.py
Normal file
180
antipolls/antipolls.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
# _____ _
|
||||||
|
# / ____| (_)
|
||||||
|
# | (___ ___ __ _ _____ ___ _ __ ___ _ __ ___ ___ _ __
|
||||||
|
# \___ \ / _ \/ _` / __\ \ /\ / / | '_ ` _ \| '_ ` _ \ / _ \ '__|
|
||||||
|
# ____) | __/ (_| \__ \\ V V /| | | | | | | | | | | | __/ |
|
||||||
|
# |_____/ \___|\__,_|___/ \_/\_/ |_|_| |_| |_|_| |_| |_|\___|_|
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from red_commons.logging import getLogger
|
||||||
|
from redbot.core import commands
|
||||||
|
from redbot.core.bot import Config, Red
|
||||||
|
from redbot.core.utils.chat_formatting import humanize_list
|
||||||
|
|
||||||
|
|
||||||
|
class AntiPolls(commands.Cog):
|
||||||
|
"""AntiPolls deletes messages that contain polls, with a configurable per-guild role and channel whitelist and support for default Discord permissions (Manage Messages)."""
|
||||||
|
|
||||||
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/antipolls/"
|
||||||
|
|
||||||
|
def __init__(self, bot: Red):
|
||||||
|
super().__init__()
|
||||||
|
self.bot = bot
|
||||||
|
self.logger = getLogger("red.SeaCogs.AntiPolls")
|
||||||
|
self.config = Config.get_conf(self, identifier=23517395243, force_registration=True)
|
||||||
|
self.config.register_guild(
|
||||||
|
role_whitelist=[],
|
||||||
|
channel_whitelist=[],
|
||||||
|
manage_messages=True,
|
||||||
|
)
|
||||||
|
if not self.bot.intents.message_content:
|
||||||
|
self.logger.error("Message Content intent is not enabled, cog will not load.")
|
||||||
|
raise RuntimeError("This cog requires the Message Content intent to function. To prevent potentially destructive behavior, the cog will not load without the intent enabled.")
|
||||||
|
|
||||||
|
def format_help_for_context(self, ctx: commands.Context) -> str:
|
||||||
|
pre_processed = super().format_help_for_context(ctx) or ""
|
||||||
|
n = "\n" if "\n\n" not in pre_processed else ""
|
||||||
|
text = [
|
||||||
|
f"{pre_processed}{n}",
|
||||||
|
f"Cog Version: **{self.__version__}**",
|
||||||
|
f"Author: {humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}",
|
||||||
|
]
|
||||||
|
return "\n".join(text)
|
||||||
|
|
||||||
|
async def red_delete_data_for_user(self, **kwargs): # pylint: disable=unused-argument
|
||||||
|
"""Nothing to delete."""
|
||||||
|
return
|
||||||
|
|
||||||
|
@commands.Cog.listener('on_message')
|
||||||
|
async def polls_listener(self, message: discord.Message) -> None:
|
||||||
|
if message.guild is None:
|
||||||
|
return self.logger.verbose("Message in direct messages ignored")
|
||||||
|
|
||||||
|
if message.author.bot:
|
||||||
|
return self.logger.verbose("Message from bot ignored")
|
||||||
|
|
||||||
|
if await self.bot.cog_disabled_in_guild(self, message.guild):
|
||||||
|
return self.logger.verbose("Message ignored, cog is disabled in guild %s", message.guild.id)
|
||||||
|
|
||||||
|
guild_config = await self.config.guild(message.guild).all()
|
||||||
|
|
||||||
|
if guild_config['manage_messages'] is True and message.author.guild_permissions.manage_messages:
|
||||||
|
return self.logger.verbose("Message from user with Manage Messages permission ignored")
|
||||||
|
|
||||||
|
if message.channel.id in guild_config['channel_whitelist']:
|
||||||
|
return self.logger.verbose("Message in whitelisted channel %s ignored", message.channel.id)
|
||||||
|
|
||||||
|
if any(role.id in guild_config['role_whitelist'] for role in message.author.roles):
|
||||||
|
return self.logger.verbose("Message from whitelisted role %s ignored", message.author.roles)
|
||||||
|
|
||||||
|
if not message.content and not message.embeds and not message.attachments and not message.stickers:
|
||||||
|
self.logger.trace("Message %s is a poll, attempting to delete", message.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
return self.logger.error("Failed to delete message: %s", e)
|
||||||
|
|
||||||
|
return self.logger.trace("Deleted poll message %s", message.id)
|
||||||
|
self.logger.verbose("Message %s is not a poll, ignoring", message.id)
|
||||||
|
|
||||||
|
@commands.group(name="antipolls", aliases=["ap"])
|
||||||
|
@commands.guild_only()
|
||||||
|
@commands.admin_or_permissions(manage_guild=True)
|
||||||
|
async def antipolls(self, ctx: commands.Context) -> None:
|
||||||
|
"""Manage AntiPolls settings."""
|
||||||
|
|
||||||
|
@antipolls.group(name="roles")
|
||||||
|
async def antipolls_roles(self, ctx: commands.Context) -> None:
|
||||||
|
"""Manage role whitelist."""
|
||||||
|
|
||||||
|
@antipolls_roles.command(name="add")
|
||||||
|
async def antipolls_roles_add(self, ctx: commands.Context, *roles: discord.Role) -> None:
|
||||||
|
"""Add roles to the whitelist."""
|
||||||
|
async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist:
|
||||||
|
role_whitelist: list
|
||||||
|
failed: list[discord.Role] = []
|
||||||
|
for role in roles:
|
||||||
|
if role.id in role_whitelist:
|
||||||
|
failed.append(role)
|
||||||
|
continue
|
||||||
|
role_whitelist.append(role.id)
|
||||||
|
await ctx.tick()
|
||||||
|
if failed:
|
||||||
|
await ctx.send(f"The following roles were already in the whitelist: {humanize_list([role.mention for role in failed])}", delete_after=10)
|
||||||
|
|
||||||
|
@antipolls_roles.command(name="remove")
|
||||||
|
async def antipolls_roles_remove(self, ctx: commands.Context, *roles: discord.Role) -> None:
|
||||||
|
"""Remove roles from the whitelist."""
|
||||||
|
async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist:
|
||||||
|
role_whitelist: list
|
||||||
|
failed: list[discord.Role] = []
|
||||||
|
for role in roles:
|
||||||
|
if role.id not in role_whitelist:
|
||||||
|
failed.append(role)
|
||||||
|
continue
|
||||||
|
role_whitelist.remove(role.id)
|
||||||
|
await ctx.tick()
|
||||||
|
if failed:
|
||||||
|
await ctx.send(f"The following roles were not in the whitelist: {humanize_list([role.mention for role in failed])}", delete_after=10)
|
||||||
|
|
||||||
|
@antipolls_roles.command(name="list")
|
||||||
|
async def antipolls_roles_list(self, ctx: commands.Context) -> None:
|
||||||
|
"""List roles in the whitelist."""
|
||||||
|
role_whitelist = await self.config.guild(ctx.guild).role_whitelist()
|
||||||
|
if not role_whitelist:
|
||||||
|
return await ctx.send("No roles in the whitelist.")
|
||||||
|
roles = [ctx.guild.get_role(role) for role in role_whitelist]
|
||||||
|
await ctx.send(humanize_list([role.mention for role in roles]))
|
||||||
|
|
||||||
|
@antipolls.group(name="channels")
|
||||||
|
async def antipolls_channels(self, ctx: commands.Context) -> None:
|
||||||
|
"""Manage channel whitelist."""
|
||||||
|
|
||||||
|
@antipolls_channels.command(name="add")
|
||||||
|
async def antipolls_channels_add(self, ctx: commands.Context, *channels: discord.TextChannel) -> None:
|
||||||
|
"""Add channels to the whitelist."""
|
||||||
|
async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist:
|
||||||
|
channel_whitelist: list
|
||||||
|
failed: list[discord.TextChannel] = []
|
||||||
|
for channel in channels:
|
||||||
|
if channel.id in channel_whitelist:
|
||||||
|
failed.append(channel)
|
||||||
|
continue
|
||||||
|
channel_whitelist.append(channel.id)
|
||||||
|
await ctx.tick()
|
||||||
|
if failed:
|
||||||
|
await ctx.send(f"The following channels were already in the whitelist: {humanize_list([channel.mention for channel in failed])}", delete_after=10)
|
||||||
|
|
||||||
|
@antipolls_channels.command(name="remove")
|
||||||
|
async def antipolls_channels_remove(self, ctx: commands.Context, *channels: discord.TextChannel) -> None:
|
||||||
|
"""Remove channels from the whitelist."""
|
||||||
|
async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist:
|
||||||
|
channel_whitelist: list
|
||||||
|
failed: list[discord.TextChannel] = []
|
||||||
|
for channel in channels:
|
||||||
|
if channel.id not in channel_whitelist:
|
||||||
|
failed.append(channel)
|
||||||
|
continue
|
||||||
|
channel_whitelist.remove(channel.id)
|
||||||
|
await ctx.tick()
|
||||||
|
if failed:
|
||||||
|
await ctx.send(f"The following channels were not in the whitelist: {humanize_list([channel.mention for channel in failed])}", delete_after=10)
|
||||||
|
|
||||||
|
@antipolls_channels.command(name="list")
|
||||||
|
async def antipolls_channels_list(self, ctx: commands.Context) -> None:
|
||||||
|
"""List channels in the whitelist."""
|
||||||
|
channel_whitelist = await self.config.guild(ctx.guild).channel_whitelist()
|
||||||
|
if not channel_whitelist:
|
||||||
|
return await ctx.send("No channels in the whitelist.")
|
||||||
|
channels = [ctx.guild.get_channel(channel) for channel in channel_whitelist]
|
||||||
|
await ctx.send(humanize_list([channel.mention for channel in channels]))
|
||||||
|
|
||||||
|
@antipolls.command(name="managemessages")
|
||||||
|
async def antipolls_managemessages(self, ctx: commands.Context, enabled: bool) -> None:
|
||||||
|
"""Toggle Manage Messages permission check."""
|
||||||
|
await self.config.guild(ctx.guild).manage_messages.set(enabled)
|
||||||
|
await ctx.tick()
|
17
antipolls/info.json
Normal file
17
antipolls/info.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"author" : ["SeaswimmerTheFsh (seasw.)"],
|
||||||
|
"install_msg" : "Thank you for installing AntiPolls!\nYou can find the source code of this cog [here](https://coastalcommits.com/SeaswimmerTheFsh/SeaCogs).",
|
||||||
|
"name" : "AntiPolls",
|
||||||
|
"short" : "AntiPolls deletes messages that contain polls.",
|
||||||
|
"description" : "AntiPolls deletes messages that contain polls, with a configurable per-guild role and channel whitelist and support for default Discord permissions (Manage Messages).",
|
||||||
|
"end_user_data_statement" : "This cog does not store any user data.",
|
||||||
|
"hidden": false,
|
||||||
|
"disabled": false,
|
||||||
|
"min_bot_version": "3.5.0",
|
||||||
|
"min_python_version": [3, 10, 0],
|
||||||
|
"tags": [
|
||||||
|
"automod",
|
||||||
|
"automoderation",
|
||||||
|
"polls"
|
||||||
|
]
|
||||||
|
}
|
111
aurora/aurora.py
111
aurora/aurora.py
|
@ -13,14 +13,12 @@ from datetime import datetime, timedelta, timezone
|
||||||
from math import ceil
|
from math import ceil
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import humanize
|
|
||||||
from discord.ext import tasks
|
from discord.ext import tasks
|
||||||
from pytimeparse2 import disable_dateutil, parse
|
|
||||||
from redbot.core import app_commands, commands, data_manager
|
from redbot.core import app_commands, commands, data_manager
|
||||||
from redbot.core.app_commands import Choice
|
from redbot.core.app_commands import Choice
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.utils.chat_formatting import (box, error, humanize_list,
|
from redbot.core.commands.converter import parse_relativedelta, parse_timedelta
|
||||||
warning)
|
from redbot.core.utils.chat_formatting import box, error, humanize_list, humanize_timedelta, warning
|
||||||
|
|
||||||
from aurora.importers.aurora import ImportAuroraView
|
from aurora.importers.aurora import ImportAuroraView
|
||||||
from aurora.importers.galacticbot import ImportGalacticBotView
|
from aurora.importers.galacticbot import ImportGalacticBotView
|
||||||
|
@ -29,17 +27,10 @@ from aurora.menus.guild import Guild
|
||||||
from aurora.menus.immune import Immune
|
from aurora.menus.immune import Immune
|
||||||
from aurora.menus.overrides import Overrides
|
from aurora.menus.overrides import Overrides
|
||||||
from aurora.utilities.config import config, register_config
|
from aurora.utilities.config import config, register_config
|
||||||
from aurora.utilities.database import (connect, create_guild_table, fetch_case,
|
from aurora.utilities.database import connect, create_guild_table, fetch_case, mysql_log
|
||||||
mysql_log)
|
from aurora.utilities.factory import addrole_embed, case_factory, changes_factory, evidenceformat_factory, guild_embed, immune_embed, message_factory, overrides_embed
|
||||||
from aurora.utilities.factory import (addrole_embed, case_factory,
|
|
||||||
changes_factory, evidenceformat_factory,
|
|
||||||
guild_embed, immune_embed,
|
|
||||||
message_factory, overrides_embed)
|
|
||||||
from aurora.utilities.logger import logger
|
from aurora.utilities.logger import logger
|
||||||
from aurora.utilities.utils import (check_moddable, check_permissions,
|
from aurora.utilities.utils import check_moddable, check_permissions, convert_timedelta_to_str, fetch_channel_dict, fetch_user_dict, generate_dict, log, send_evidenceformat, timedelta_from_relativedelta
|
||||||
convert_timedelta_to_str,
|
|
||||||
fetch_channel_dict, fetch_user_dict,
|
|
||||||
generate_dict, log, send_evidenceformat)
|
|
||||||
|
|
||||||
|
|
||||||
class Aurora(commands.Cog):
|
class Aurora(commands.Cog):
|
||||||
|
@ -48,7 +39,8 @@ class Aurora(commands.Cog):
|
||||||
This cog stores all of its data in an SQLite database."""
|
This cog stores all of its data in an SQLite database."""
|
||||||
|
|
||||||
__author__ = ["SeaswimmerTheFsh"]
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
__version__ = "2.0.6"
|
__version__ = "2.1.2"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/aurora/"
|
||||||
|
|
||||||
async def red_delete_data_for_user(self, *, requester, user_id: int):
|
async def red_delete_data_for_user(self, *, requester, user_id: int):
|
||||||
if requester == "discord_deleted_user":
|
if requester == "discord_deleted_user":
|
||||||
|
@ -84,7 +76,6 @@ class Aurora(commands.Cog):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
register_config(config)
|
register_config(config)
|
||||||
disable_dateutil()
|
|
||||||
self.handle_expiry.start()
|
self.handle_expiry.start()
|
||||||
|
|
||||||
def format_help_for_context(self, ctx: commands.Context) -> str:
|
def format_help_for_context(self, ctx: commands.Context) -> str:
|
||||||
|
@ -94,6 +85,7 @@ class Aurora(commands.Cog):
|
||||||
f"{pre_processed}{n}",
|
f"{pre_processed}{n}",
|
||||||
f"Cog Version: **{self.__version__}**",
|
f"Cog Version: **{self.__version__}**",
|
||||||
f"Author: {humanize_list(self.__author__)}",
|
f"Author: {humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}",
|
||||||
]
|
]
|
||||||
return "\n".join(text)
|
return "\n".join(text)
|
||||||
|
|
||||||
|
@ -332,13 +324,10 @@ class Aurora(commands.Cog):
|
||||||
return
|
return
|
||||||
|
|
||||||
if duration is not None:
|
if duration is not None:
|
||||||
try:
|
parsed_time = parse_timedelta(duration)
|
||||||
parsed_time = parse(
|
if parsed_time is None:
|
||||||
sval=duration, as_timedelta=True, raise_exception=True
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
error("Please provide a valid duration!"), ephemeral=True
|
content=error("Please provide a valid duration!"), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
|
@ -383,10 +372,10 @@ class Aurora(commands.Cog):
|
||||||
|
|
||||||
await target.add_roles(
|
await target.add_roles(
|
||||||
role,
|
role,
|
||||||
reason=f"Role added by {interaction.user.id}{(' for ' + {humanize.precisedelta(parsed_time)} if parsed_time != 'NULL' else '')} for: {reason}",
|
reason=f"Role added by {interaction.user.id}{(' for ' + {humanize_timedelta(timedelta=parsed_time)} if parsed_time != 'NULL' else '')} for: {reason}",
|
||||||
)
|
)
|
||||||
response: discord.WebhookMessage = await interaction.followup.send(
|
response: discord.WebhookMessage = await interaction.followup.send(
|
||||||
content=f"{target.mention} has been given the {role.mention} role{(' for ' + {humanize.precisedelta(parsed_time)} if parsed_time != 'NULL' else '')}!\n**Reason** - `{reason}`"
|
content=f"{target.mention} has been given the {role.mention} role{(' for ' + {humanize_timedelta(timedelta=parsed_time)} if parsed_time != 'NULL' else '')}!\n**Reason** - `{reason}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
moderation_id = await mysql_log(
|
moderation_id = await mysql_log(
|
||||||
|
@ -400,7 +389,7 @@ class Aurora(commands.Cog):
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
await response.edit(
|
await response.edit(
|
||||||
content=f"{target.mention} has been given the {role.mention} role{(' for ' + {humanize.precisedelta(parsed_time)} if parsed_time != 'NULL' else '')}! (Case `#{moderation_id:,}`)\n**Reason** - `{reason}`",
|
content=f"{target.mention} has been given the {role.mention} role{(' for ' + {humanize_timedelta(timedelta=parsed_time)} if parsed_time != 'NULL' else '')}! (Case `#{moderation_id:,}`)\n**Reason** - `{reason}`",
|
||||||
)
|
)
|
||||||
await log(interaction, moderation_id)
|
await log(interaction, moderation_id)
|
||||||
|
|
||||||
|
@ -440,16 +429,15 @@ class Aurora(commands.Cog):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_time = parse(sval=duration, as_timedelta=True, raise_exception=True)
|
parsed_time = parse_timedelta(duration, maximum=timedelta(days=28))
|
||||||
except ValueError:
|
if parsed_time is None:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
error("Please provide a valid duration!"), ephemeral=True
|
error("Please provide a valid duration!"), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
except commands.BadArgument:
|
||||||
if parsed_time.total_seconds() / 1000 > 2419200000:
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
error("Please provide a duration that is less than 28 days.")
|
error("Please provide a duration that is less than 28 days."), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -458,7 +446,7 @@ class Aurora(commands.Cog):
|
||||||
)
|
)
|
||||||
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
content=f"{target.mention} has been muted for {humanize.precisedelta(parsed_time)}!\n**Reason** - `{reason}`"
|
content=f"{target.mention} has been muted for {humanize_timedelta(timedelta=parsed_time)}!\n**Reason** - `{reason}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
if silent is None:
|
if silent is None:
|
||||||
|
@ -489,7 +477,7 @@ class Aurora(commands.Cog):
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f"{target.mention} has been muted for {humanize.precisedelta(parsed_time)}! (Case `#{moderation_id:,}`)\n**Reason** - `{reason}`"
|
content=f"{target.mention} has been muted for {humanize_timedelta(timedelta=parsed_time)}! (Case `#{moderation_id:,}`)\n**Reason** - `{reason}`"
|
||||||
)
|
)
|
||||||
await log(interaction, moderation_id)
|
await log(interaction, moderation_id)
|
||||||
|
|
||||||
|
@ -684,18 +672,22 @@ class Aurora(commands.Cog):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if duration:
|
if duration:
|
||||||
try:
|
parsed_time = parse_relativedelta(duration)
|
||||||
parsed_time = parse(
|
if parsed_time is None:
|
||||||
sval=duration, as_timedelta=True, raise_exception=True
|
await interaction.response.send_message(
|
||||||
|
content=error("Please provide a valid duration!"), ephemeral=True
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
parsed_time = timedelta_from_relativedelta(parsed_time)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
error("Please provide a valid duration!"), ephemeral=True
|
content=error("Please provide a valid duration!"), ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
content=f"{target.mention} has been banned for {humanize.precisedelta(parsed_time)}!\n**Reason** - `{reason}`"
|
content=f"{target.mention} has been banned for {humanize_timedelta(timedelta=parsed_time)}!\n**Reason** - `{reason}`"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -729,7 +721,7 @@ class Aurora(commands.Cog):
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f"{target.mention} has been banned for {humanize.precisedelta(parsed_time)}! (Case `#{moderation_id}`)\n**Reason** - `{reason}`"
|
content=f"{target.mention} has been banned for {humanize_timedelta(timedelta=parsed_time)}! (Case `#{moderation_id}`)\n**Reason** - `{reason}`"
|
||||||
)
|
)
|
||||||
await log(interaction, moderation_id)
|
await log(interaction, moderation_id)
|
||||||
|
|
||||||
|
@ -1069,9 +1061,9 @@ class Aurora(commands.Cog):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
duration_embed = (
|
duration_embed = (
|
||||||
f"{humanize.precisedelta(td)} | <t:{case['end_timestamp']}:R>"
|
f"{humanize_timedelta(timedelta=td)} | <t:{case['end_timestamp']}:R>"
|
||||||
if bool(case["expired"]) is False
|
if bool(case["expired"]) is False
|
||||||
else f"{humanize.precisedelta(td)} | Expired"
|
else f"{humanize_timedelta(timedelta=td)} | Expired"
|
||||||
)
|
)
|
||||||
field_value += f"\n**Duration:** {duration_embed}"
|
field_value += f"\n**Duration:** {duration_embed}"
|
||||||
|
|
||||||
|
@ -1380,11 +1372,8 @@ class Aurora(commands.Cog):
|
||||||
case_dict = await fetch_case(case, interaction.guild.id)
|
case_dict = await fetch_case(case, interaction.guild.id)
|
||||||
if case_dict:
|
if case_dict:
|
||||||
if duration:
|
if duration:
|
||||||
try:
|
parsed_time = parse_timedelta(duration)
|
||||||
parsed_time = parse(
|
if parsed_time is None:
|
||||||
sval=duration, as_timedelta=True, raise_exception=True
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
error("Please provide a valid duration!"), ephemeral=True
|
error("Please provide a valid duration!"), ephemeral=True
|
||||||
)
|
)
|
||||||
|
@ -1497,6 +1486,7 @@ class Aurora(commands.Cog):
|
||||||
|
|
||||||
@tasks.loop(minutes=1)
|
@tasks.loop(minutes=1)
|
||||||
async def handle_expiry(self):
|
async def handle_expiry(self):
|
||||||
|
await self.bot.wait_until_red_ready()
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
database = connect()
|
database = connect()
|
||||||
cursor = database.cursor()
|
cursor = database.cursor()
|
||||||
|
@ -1700,15 +1690,34 @@ class Aurora(commands.Cog):
|
||||||
)
|
)
|
||||||
|
|
||||||
@aurora.command(aliases=["tdc", "td", "timedeltaconvert"])
|
@aurora.command(aliases=["tdc", "td", "timedeltaconvert"])
|
||||||
async def timedelta(self, ctx: commands.Context, *, duration: str):
|
async def timedelta(self, ctx: commands.Context, *, duration: str) -> None:
|
||||||
"""This command converts a duration to a [`timedelta`](https://docs.python.org/3/library/datetime.html#datetime.timedelta) Python object.
|
"""Convert a string to a timedelta.
|
||||||
|
|
||||||
|
This command converts a duration to a [`timedelta`](https://docs.python.org/3/library/datetime.html#datetime.timedelta) Python object.
|
||||||
|
You cannot convert years or months as they are not fixed units. Use `[p]aurora relativedelta` for that.
|
||||||
|
|
||||||
**Example usage**
|
**Example usage**
|
||||||
`[p]aurora timedelta 1 day 15hr 82 minutes 52s`
|
`[p]aurora timedelta 1 day 15hr 82 minutes 52s`
|
||||||
**Output**
|
**Output**
|
||||||
`1 day, 16:22:52`"""
|
`1 day, 16:22:52`"""
|
||||||
try:
|
parsed_time = parse_timedelta(duration)
|
||||||
parsed_time = parse(duration, as_timedelta=True, raise_exception=True)
|
if parsed_time is None:
|
||||||
await ctx.send(f"`{str(parsed_time)}`")
|
|
||||||
except ValueError:
|
|
||||||
await ctx.send(error("Please provide a convertible value!"))
|
await ctx.send(error("Please provide a convertible value!"))
|
||||||
|
return
|
||||||
|
await ctx.send(f"`{parsed_time}`")
|
||||||
|
|
||||||
|
@aurora.command(aliases=["rdc", "rd", "relativedeltaconvert"])
|
||||||
|
async def relativedelta(self, ctx: commands.Context, *, duration: str) -> None:
|
||||||
|
"""Convert a string to a relativedelta.
|
||||||
|
|
||||||
|
This command converts a duration to a [`relativedelta`](https://dateutil.readthedocs.io/en/stable/relativedelta.html) Python object.
|
||||||
|
|
||||||
|
**Example usage**
|
||||||
|
`[p]aurora relativedelta 3 years 1 day 15hr 82 minutes 52s`
|
||||||
|
**Output**
|
||||||
|
`relativedelta(years=+3, days=+1, hours=+15, minutes=+82, seconds=+52)`"""
|
||||||
|
parsed_time = parse_relativedelta(duration)
|
||||||
|
if parsed_time is None:
|
||||||
|
await ctx.send(error("Please provide a convertible value!"))
|
||||||
|
return
|
||||||
|
await ctx.send(f"`{parsed_time}`")
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
"short" : "A full replacement for Red's core Mod cogs.",
|
"short" : "A full replacement for Red's core Mod cogs.",
|
||||||
"description" : "Aurora is a fully-featured moderation system. It is heavily inspired by GalacticBot, and is designed to be a more user-friendly alternative to Red's core Mod cogs. This cog stores all of its data in an SQLite database.",
|
"description" : "Aurora is a fully-featured moderation system. It is heavily inspired by GalacticBot, and is designed to be a more user-friendly alternative to Red's core Mod cogs. This cog stores all of its data in an SQLite database.",
|
||||||
"end_user_data_statement" : "This cog stores the following information:\n- User IDs of accounts who moderate users or are moderated\n- Guild IDs of guilds with the cog enabled\n- Timestamps of moderations\n- Other information relating to moderations",
|
"end_user_data_statement" : "This cog stores the following information:\n- User IDs of accounts who moderate users or are moderated\n- Guild IDs of guilds with the cog enabled\n- Timestamps of moderations\n- Other information relating to moderations",
|
||||||
"requirements": ["humanize", "pytimeparse2"],
|
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"min_bot_version": "3.5.0",
|
"min_bot_version": "3.5.0",
|
||||||
|
|
|
@ -17,12 +17,13 @@ class Addrole(ui.View):
|
||||||
await interaction.response.send_message(error("You must have the manage guild permission to add roles to the addrole whitelist."), ephemeral=True)
|
await interaction.response.send_message(error("You must have the manage guild permission to add roles to the addrole whitelist."), ephemeral=True)
|
||||||
return
|
return
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
addrole_whitelist: list = await config.guild(self.ctx.guild).addrole_whitelist()
|
async with config.guild(self.ctx.guild).addrole_whitelist() as addrole_whitelist:
|
||||||
if select.values[0].id in addrole_whitelist:
|
addrole_whitelist: list # type hint
|
||||||
addrole_whitelist.remove(select.values[0].id)
|
for value in select.values:
|
||||||
|
if value.id in addrole_whitelist:
|
||||||
|
addrole_whitelist.remove(value.id)
|
||||||
else:
|
else:
|
||||||
addrole_whitelist.append(select.values[0].id)
|
addrole_whitelist.append(value.id)
|
||||||
await config.guild(self.ctx.guild).addrole_whitelist.set(addrole_whitelist)
|
|
||||||
await interaction.message.edit(embed=await addrole_embed(self.ctx))
|
await interaction.message.edit(embed=await addrole_embed(self.ctx))
|
||||||
|
|
||||||
@ui.button(label="Clear", style=ButtonStyle.red, row=1)
|
@ui.button(label="Clear", style=ButtonStyle.red, row=1)
|
||||||
|
|
|
@ -31,6 +31,16 @@ class Guild(ui.View):
|
||||||
await config.guild(interaction.guild).use_discord_permissions.set(not current_setting)
|
await config.guild(interaction.guild).use_discord_permissions.set(not current_setting)
|
||||||
await interaction.message.edit(embed=await guild_embed(self.ctx))
|
await interaction.message.edit(embed=await guild_embed(self.ctx))
|
||||||
|
|
||||||
|
@ui.button(label="Respect Hierarchy", style=ButtonStyle.green, row=0)
|
||||||
|
async def respect_heirarchy(self, interaction: Interaction, button: ui.Button): # pylint: disable=unused-argument
|
||||||
|
if not interaction.user.guild_permissions.manage_guild and not interaction.user.guild_permissions.administrator:
|
||||||
|
await interaction.response.send_message("You must have the manage guild permission to change this setting.", ephemeral=True)
|
||||||
|
return
|
||||||
|
await interaction.response.defer()
|
||||||
|
current_setting = await config.guild(interaction.guild).respect_hierarchy()
|
||||||
|
await config.guild(interaction.guild).respect_hierarchy.set(not current_setting)
|
||||||
|
await interaction.message.edit(embed=await guild_embed(self.ctx))
|
||||||
|
|
||||||
@ui.button(label="Ignore Modlog", style=ButtonStyle.green, row=0)
|
@ui.button(label="Ignore Modlog", style=ButtonStyle.green, row=0)
|
||||||
async def ignore_modlog(self, interaction: Interaction, button: ui.Button): # pylint: disable=unused-argument
|
async def ignore_modlog(self, interaction: Interaction, button: ui.Button): # pylint: disable=unused-argument
|
||||||
if not interaction.user.guild_permissions.manage_guild and not interaction.user.guild_permissions.administrator:
|
if not interaction.user.guild_permissions.manage_guild and not interaction.user.guild_permissions.administrator:
|
||||||
|
|
|
@ -17,13 +17,13 @@ class Immune(ui.View):
|
||||||
await interaction.response.send_message(error("You must have the manage guild permission to add immune roles."), ephemeral=True)
|
await interaction.response.send_message(error("You must have the manage guild permission to add immune roles."), ephemeral=True)
|
||||||
return
|
return
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
immune_roles: list = await config.guild(self.ctx.guild).immune_roles()
|
async with config.guild(self.ctx.guild).immune_roles() as immune_roles:
|
||||||
for role in select.values:
|
immune_roles: list # type hint
|
||||||
if role.id in immune_roles:
|
for value in select.values:
|
||||||
immune_roles.remove(role.id)
|
if value.id in immune_roles:
|
||||||
|
immune_roles.remove(value.id)
|
||||||
else:
|
else:
|
||||||
immune_roles.append(role.id)
|
immune_roles.append(value.id)
|
||||||
await config.guild(self.ctx.guild).immune_roles.set(immune_roles)
|
|
||||||
await interaction.message.edit(embed=await immune_embed(self.ctx))
|
await interaction.message.edit(embed=await immune_embed(self.ctx))
|
||||||
|
|
||||||
@ui.button(label="Clear", style=ButtonStyle.red, row=1)
|
@ui.button(label="Clear", style=ButtonStyle.red, row=1)
|
||||||
|
|
|
@ -7,6 +7,7 @@ def register_config(config_obj: Config):
|
||||||
config_obj.register_guild(
|
config_obj.register_guild(
|
||||||
show_moderator=True,
|
show_moderator=True,
|
||||||
use_discord_permissions=True,
|
use_discord_permissions=True,
|
||||||
|
respect_hierarchy=True,
|
||||||
ignore_modlog=True,
|
ignore_modlog=True,
|
||||||
ignore_other_bots=True,
|
ignore_other_bots=True,
|
||||||
dm_users=True,
|
dm_users=True,
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
import humanize
|
|
||||||
from discord import (Color, Embed, Guild, Interaction, InteractionMessage,
|
from discord import (Color, Embed, Guild, Interaction, InteractionMessage,
|
||||||
Member, Role, User)
|
Member, Role, User)
|
||||||
from redbot.core import commands
|
from redbot.core import commands
|
||||||
from redbot.core.utils.chat_formatting import bold, box, error, warning
|
from redbot.core.utils.chat_formatting import (bold, box, error,
|
||||||
|
humanize_timedelta, warning)
|
||||||
|
|
||||||
from aurora.utilities.config import config
|
from aurora.utilities.config import config
|
||||||
from aurora.utilities.utils import (fetch_channel_dict, fetch_user_dict,
|
from aurora.utilities.utils import (fetch_channel_dict, fetch_user_dict,
|
||||||
|
@ -51,7 +51,7 @@ async def message_factory(
|
||||||
guild_name = guild.name
|
guild_name = guild.name
|
||||||
|
|
||||||
if moderation_type in ["tempbanned", "muted"] and duration:
|
if moderation_type in ["tempbanned", "muted"] and duration:
|
||||||
embed_duration = f" for {humanize.precisedelta(duration)}"
|
embed_duration = f" for {humanize_timedelta(timedelta=duration)}"
|
||||||
else:
|
else:
|
||||||
embed_duration = ""
|
embed_duration = ""
|
||||||
|
|
||||||
|
@ -141,9 +141,9 @@ async def log_factory(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
duration_embed = (
|
duration_embed = (
|
||||||
f"{humanize.precisedelta(td)} | <t:{case_dict['end_timestamp']}:R>"
|
f"{humanize_timedelta(timedelta=td)} | <t:{case_dict['end_timestamp']}:R>"
|
||||||
if case_dict["expired"] == "0"
|
if case_dict["expired"] == "0"
|
||||||
else str(humanize.precisedelta(td))
|
else str(humanize_timedelta(timedelta=td))
|
||||||
)
|
)
|
||||||
embed.description = (
|
embed.description = (
|
||||||
embed.description
|
embed.description
|
||||||
|
@ -204,7 +204,7 @@ async def log_factory(
|
||||||
)
|
)
|
||||||
embed.description = (
|
embed.description = (
|
||||||
embed.description
|
embed.description
|
||||||
+ f"\n**Duration:** {humanize.precisedelta(td)} | <t:{case_dict['end_timestamp']}:R>"
|
+ f"\n**Duration:** {humanize_timedelta(timedelta=td)} | <t:{case_dict['end_timestamp']}:R>"
|
||||||
)
|
)
|
||||||
|
|
||||||
embed.add_field(name="Reason", value=box(case_dict["reason"]), inline=False)
|
embed.add_field(name="Reason", value=box(case_dict["reason"]), inline=False)
|
||||||
|
@ -255,9 +255,9 @@ async def case_factory(interaction: Interaction, case_dict: dict) -> Embed:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
duration_embed = (
|
duration_embed = (
|
||||||
f"{humanize.precisedelta(td)} | <t:{case_dict['end_timestamp']}:R>"
|
f"{humanize_timedelta(timedelta=td)} | <t:{case_dict['end_timestamp']}:R>"
|
||||||
if bool(case_dict["expired"]) is False
|
if bool(case_dict["expired"]) is False
|
||||||
else str(humanize.precisedelta(td))
|
else str(humanize_timedelta(timedelta=td))
|
||||||
)
|
)
|
||||||
embed.description += f"\n**Duration:** {duration_embed}\n**Expired:** {bool(case_dict['expired'])}"
|
embed.description += f"\n**Duration:** {duration_embed}\n**Expired:** {bool(case_dict['expired'])}"
|
||||||
|
|
||||||
|
@ -379,7 +379,7 @@ async def evidenceformat_factory(interaction: Interaction, case_dict: dict) -> s
|
||||||
if case_dict["duration"] != "NULL":
|
if case_dict["duration"] != "NULL":
|
||||||
hours, minutes, seconds = map(int, case_dict["duration"].split(":"))
|
hours, minutes, seconds = map(int, case_dict["duration"].split(":"))
|
||||||
td = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
td = timedelta(hours=hours, minutes=minutes, seconds=seconds)
|
||||||
content += f"\nDuration: {humanize.precisedelta(td)}"
|
content += f"\nDuration: {humanize_timedelta(timedelta=td)}"
|
||||||
|
|
||||||
content += f"\nReason: {case_dict['reason']}"
|
content += f"\nReason: {case_dict['reason']}"
|
||||||
|
|
||||||
|
@ -455,6 +455,7 @@ async def guild_embed(ctx: commands.Context) -> Embed:
|
||||||
ctx.guild
|
ctx.guild
|
||||||
).history_inline_pagesize(),
|
).history_inline_pagesize(),
|
||||||
"auto_evidenceformat": await config.guild(ctx.guild).auto_evidenceformat(),
|
"auto_evidenceformat": await config.guild(ctx.guild).auto_evidenceformat(),
|
||||||
|
"respect_hierarchy": await config.guild(ctx.guild).respect_hierarchy(),
|
||||||
}
|
}
|
||||||
|
|
||||||
channel = ctx.guild.get_channel(guild_settings["log_channel"])
|
channel = ctx.guild.get_channel(guild_settings["log_channel"])
|
||||||
|
@ -471,6 +472,9 @@ async def guild_embed(ctx: commands.Context) -> Embed:
|
||||||
+ bold("Use Discord Permissions: ")
|
+ bold("Use Discord Permissions: ")
|
||||||
+ get_bool_emoji(guild_settings["use_discord_permissions"]),
|
+ get_bool_emoji(guild_settings["use_discord_permissions"]),
|
||||||
"- "
|
"- "
|
||||||
|
+ bold("Respect Hierarchy: ")
|
||||||
|
+ get_bool_emoji(guild_settings["respect_hierarchy"]),
|
||||||
|
"- "
|
||||||
+ bold("Ignore Modlog: ")
|
+ bold("Ignore Modlog: ")
|
||||||
+ get_bool_emoji(guild_settings["ignore_modlog"]),
|
+ get_bool_emoji(guild_settings["ignore_modlog"]),
|
||||||
"- "
|
"- "
|
||||||
|
@ -510,15 +514,29 @@ async def guild_embed(ctx: commands.Context) -> Embed:
|
||||||
async def addrole_embed(ctx: commands.Context) -> Embed:
|
async def addrole_embed(ctx: commands.Context) -> Embed:
|
||||||
"""Generates a configuration menu field value for a guild's addrole whitelist."""
|
"""Generates a configuration menu field value for a guild's addrole whitelist."""
|
||||||
|
|
||||||
whitelist = await config.guild(ctx.guild).addrole_whitelist()
|
roles = []
|
||||||
if whitelist:
|
async with config.guild(ctx.guild).addrole_whitelist() as whitelist:
|
||||||
whitelist = [
|
for role in whitelist:
|
||||||
ctx.guild.get_role(role).mention or error(f"`{role}` (Not Found)")
|
evalulated_role = ctx.guild.get_role(role) or error(f"`{role}` (Not Found)")
|
||||||
for role in whitelist
|
if isinstance(evalulated_role, Role):
|
||||||
]
|
roles.append({
|
||||||
whitelist = "\n".join(whitelist)
|
"id": evalulated_role.id,
|
||||||
|
"mention": evalulated_role.mention,
|
||||||
|
"position": evalulated_role.position
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
whitelist = warning("No roles are on the addrole whitelist!")
|
roles.append({
|
||||||
|
"id": role,
|
||||||
|
"mention": error(f"`{role}` (Not Found)"),
|
||||||
|
"position": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if roles:
|
||||||
|
roles = sorted(roles, key=lambda x: x["position"], reverse=True)
|
||||||
|
roles = [role["mention"] for role in roles]
|
||||||
|
whitelist_str = "\n".join(roles)
|
||||||
|
else:
|
||||||
|
whitelist_str = warning("No roles are on the addrole whitelist!")
|
||||||
|
|
||||||
e = await _config(ctx)
|
e = await _config(ctx)
|
||||||
e.title += ": Addrole Whitelist"
|
e.title += ": Addrole Whitelist"
|
||||||
|
@ -526,8 +544,8 @@ async def addrole_embed(ctx: commands.Context) -> Embed:
|
||||||
"Use the select menu below to manage this guild's addrole whitelist."
|
"Use the select menu below to manage this guild's addrole whitelist."
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(whitelist) > 4000 and len(whitelist) < 5000:
|
if len(whitelist_str) > 4000 and len(whitelist_str) < 5000:
|
||||||
lines = whitelist.split("\n")
|
lines = whitelist_str.split("\n")
|
||||||
chunks = []
|
chunks = []
|
||||||
chunk = ""
|
chunk = ""
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
@ -541,21 +559,35 @@ async def addrole_embed(ctx: commands.Context) -> Embed:
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
e.add_field(name="", value=chunk)
|
e.add_field(name="", value=chunk)
|
||||||
else:
|
else:
|
||||||
e.description += "\n\n" + whitelist
|
e.description += "\n\n" + whitelist_str
|
||||||
|
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
async def immune_embed(ctx: commands.Context) -> Embed:
|
async def immune_embed(ctx: commands.Context) -> Embed:
|
||||||
"""Generates a configuration menu field value for a guild's immune roles."""
|
"""Generates a configuration menu embed for a guild's immune roles."""
|
||||||
|
|
||||||
immune_roles = await config.guild(ctx.guild).immune_roles()
|
roles = []
|
||||||
if immune_roles:
|
async with config.guild(ctx.guild).immune_roles() as immune_roles:
|
||||||
immune_str = [
|
for role in immune_roles:
|
||||||
ctx.guild.get_role(role).mention or error(f"`{role}` (Not Found)")
|
evalulated_role = ctx.guild.get_role(role) or error(f"`{role}` (Not Found)")
|
||||||
for role in immune_roles
|
if isinstance(evalulated_role, Role):
|
||||||
]
|
roles.append({
|
||||||
immune_str = "\n".join(immune_str)
|
"id": evalulated_role.id,
|
||||||
|
"mention": evalulated_role.mention,
|
||||||
|
"position": evalulated_role.position
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
roles.append({
|
||||||
|
"id": role,
|
||||||
|
"mention": error(f"`{role}` (Not Found)"),
|
||||||
|
"position": 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if roles:
|
||||||
|
roles = sorted(roles, key=lambda x: x["position"], reverse=True)
|
||||||
|
roles = [role["mention"] for role in roles]
|
||||||
|
immune_str = "\n".join(roles)
|
||||||
else:
|
else:
|
||||||
immune_str = warning("No roles are set as immune roles!")
|
immune_str = warning("No roles are set as immune roles!")
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
from red_commons.logging import getLogger
|
from red_commons.logging import getLogger
|
||||||
|
|
||||||
logger = getLogger("red.seacogs.aurora")
|
logger = getLogger("red.SeaCogs.Aurora")
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
# pylint: disable=cyclic-import
|
# pylint: disable=cyclic-import
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from datetime import timedelta as td
|
from datetime import timedelta as td
|
||||||
from typing import Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta as rd
|
||||||
from discord import Guild, Interaction, Member, SelectOption, User
|
from discord import Guild, Interaction, Member, SelectOption, User
|
||||||
from discord.errors import Forbidden, NotFound
|
from discord.errors import Forbidden, NotFound
|
||||||
from redbot.core import commands
|
from redbot.core import commands
|
||||||
|
@ -75,7 +77,7 @@ async def check_moddable(
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(target, Member):
|
if isinstance(target, Member):
|
||||||
if interaction.user.top_role <= target.top_role:
|
if interaction.user.top_role <= target.top_role and await config.guild(interaction.guild).respect_hierarchy() is True:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
content=error(
|
content=error(
|
||||||
"You cannot moderate members with a higher role than you!"
|
"You cannot moderate members with a higher role than you!"
|
||||||
|
@ -248,7 +250,7 @@ def convert_timedelta_to_str(timedelta: td) -> str:
|
||||||
return f"{hours}:{minutes}:{seconds}"
|
return f"{hours}:{minutes}:{seconds}"
|
||||||
|
|
||||||
|
|
||||||
def get_bool_emoji(value: bool) -> str:
|
def get_bool_emoji(value: Optional[bool]) -> str:
|
||||||
"""Returns a unicode emoji based on a boolean value."""
|
"""Returns a unicode emoji based on a boolean value."""
|
||||||
if value is True:
|
if value is True:
|
||||||
return "\N{WHITE HEAVY CHECK MARK}"
|
return "\N{WHITE HEAVY CHECK MARK}"
|
||||||
|
@ -283,3 +285,9 @@ def create_pagesize_options() -> list[SelectOption]:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
def timedelta_from_relativedelta(relativedelta: rd) -> td:
|
||||||
|
"""Converts a relativedelta object to a timedelta object."""
|
||||||
|
now = datetime.now()
|
||||||
|
then = now - relativedelta
|
||||||
|
return now - then
|
||||||
|
|
231
backup/backup.py
231
backup/backup.py
|
@ -14,8 +14,7 @@ from redbot.cogs.downloader import errors
|
||||||
from redbot.cogs.downloader.converters import InstalledCog
|
from redbot.cogs.downloader.converters import InstalledCog
|
||||||
from redbot.core import commands
|
from redbot.core import commands
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.utils.chat_formatting import (error, humanize_list,
|
from redbot.core.utils.chat_formatting import error, humanize_list, text_to_file
|
||||||
text_to_file)
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
|
@ -23,12 +22,13 @@ class Backup(commands.Cog):
|
||||||
"""A utility to make reinstalling repositories and cogs after migrating the bot far easier."""
|
"""A utility to make reinstalling repositories and cogs after migrating the bot far easier."""
|
||||||
|
|
||||||
__author__ = ["SeaswimmerTheFsh"]
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.1.0"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/backup/"
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
def __init__(self, bot: Red):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.logger = getLogger("red.seacogs.backup")
|
self.logger = getLogger("red.SeaCogs.Backup")
|
||||||
|
|
||||||
def format_help_for_context(self, ctx: commands.Context) -> str:
|
def format_help_for_context(self, ctx: commands.Context) -> str:
|
||||||
pre_processed = super().format_help_for_context(ctx) or ""
|
pre_processed = super().format_help_for_context(ctx) or ""
|
||||||
|
@ -37,6 +37,7 @@ class Backup(commands.Cog):
|
||||||
f"{pre_processed}{n}",
|
f"{pre_processed}{n}",
|
||||||
f"Cog Version: **{self.__version__}**",
|
f"Cog Version: **{self.__version__}**",
|
||||||
f"Author: {humanize_list(self.__author__)}",
|
f"Author: {humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}",
|
||||||
]
|
]
|
||||||
return "\n".join(text)
|
return "\n".join(text)
|
||||||
|
|
||||||
|
@ -94,12 +95,19 @@ class Backup(commands.Cog):
|
||||||
@commands.is_owner()
|
@commands.is_owner()
|
||||||
async def backup_import(self, ctx: commands.Context):
|
async def backup_import(self, ctx: commands.Context):
|
||||||
"""Import your installed repositories and cogs from an export file."""
|
"""Import your installed repositories and cogs from an export file."""
|
||||||
|
export = None
|
||||||
|
if ctx.message.attachments:
|
||||||
try:
|
try:
|
||||||
export = json.loads(await ctx.message.attachments[0].read())
|
export = json.loads(await ctx.message.attachments[0].read())
|
||||||
except (json.JSONDecodeError, IndexError):
|
except json.JSONDecodeError:
|
||||||
|
await ctx.send(error("Invalid JSON in message attachments."))
|
||||||
|
elif ctx.message.reference and hasattr(ctx.message.reference, 'resolved'):
|
||||||
|
if ctx.message.reference.resolved.attachments:
|
||||||
try:
|
try:
|
||||||
export = json.loads(await ctx.message.reference.resolved.attachments[0].read())
|
export = json.loads(await ctx.message.reference.resolved.attachments[0].read())
|
||||||
except (json.JSONDecodeError, IndexError):
|
except json.JSONDecodeError:
|
||||||
|
await ctx.send(error("Invalid JSON in referenced message attachments."))
|
||||||
|
if export is None:
|
||||||
await ctx.send(error("Please provide a valid JSON export file."))
|
await ctx.send(error("Please provide a valid JSON export file."))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -112,210 +120,23 @@ class Backup(commands.Cog):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
repo_s = []
|
all_repos = list(downloader._repo_manager.repos)
|
||||||
uninstall_s = []
|
|
||||||
install_s = []
|
|
||||||
repo_e = []
|
|
||||||
uninstall_e = []
|
|
||||||
install_e = []
|
|
||||||
|
|
||||||
async with ctx.typing():
|
|
||||||
for repo in export:
|
for repo in export:
|
||||||
# Most of this code is from the Downloader cog.
|
if repo["name"] not in [r.name for r in all_repos]:
|
||||||
name = repo["name"]
|
|
||||||
branch = repo["branch"]
|
|
||||||
url = repo["url"]
|
|
||||||
cogs = repo["cogs"]
|
|
||||||
|
|
||||||
if "PyLav/Red-Cogs" in url:
|
|
||||||
repo_e.append("PyLav cogs are not supported.")
|
|
||||||
continue
|
|
||||||
if name.startswith(".") or name.endswith("."):
|
|
||||||
repo_e.append(
|
|
||||||
f"Invalid repository name: {name}\nRepository names cannot start or end with a dot."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if re.match(r"^[a-zA-Z0-9_\-\.]+$", name) is None:
|
|
||||||
repo_e.append(
|
|
||||||
f"Invalid repository name: {name}\nRepository names may only contain letters, numbers, underscores, hyphens, and dots."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
repository = await downloader._repo_manager.add_repo(
|
await downloader._repo_manager.add_repo(
|
||||||
url, name, branch
|
repo["url"], repo["name"], repo["branch"]
|
||||||
)
|
)
|
||||||
repo_s.append(
|
|
||||||
f"Added repository {name} from {url} on branch {branch}."
|
|
||||||
)
|
|
||||||
self.logger.debug(
|
|
||||||
"Added repository %s from %s on branch %s", name, url, branch
|
|
||||||
)
|
|
||||||
|
|
||||||
except errors.ExistingGitRepo:
|
except errors.ExistingGitRepo:
|
||||||
repo_e.append(f"Repository {name} already exists.")
|
pass
|
||||||
repository = downloader._repo_manager.get_repo(
|
|
||||||
name
|
|
||||||
)
|
|
||||||
self.logger.debug("Repository %s already exists", name)
|
|
||||||
|
|
||||||
# This is commented out because errors.AuthenticationError is not yet implemented in Red 3.5.5's Downloader cog.
|
for cog in repo["cogs"]:
|
||||||
# Rather, it is only in the development version and will be added in version 3.5.6 (or whatever the next version is).
|
|
||||||
# except errors.AuthenticationError as err:
|
|
||||||
# repo_e.append(f"Authentication error while adding repository {name}. See logs for more information.")
|
|
||||||
# self.logger.exception(
|
|
||||||
# "Something went wrong whilst cloning %s (to revision %s)",
|
|
||||||
# url,
|
|
||||||
# branch,
|
|
||||||
# exc_info=err,
|
|
||||||
# )
|
|
||||||
# continue
|
|
||||||
|
|
||||||
except errors.CloningError as err:
|
|
||||||
repo_e.append(
|
|
||||||
f"Cloning error while adding repository {name}. See logs for more information."
|
|
||||||
)
|
|
||||||
self.logger.exception(
|
|
||||||
"Something went wrong whilst cloning %s (to revision %s)",
|
|
||||||
url,
|
|
||||||
branch,
|
|
||||||
exc_info=err,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
except OSError:
|
|
||||||
repo_e.append(
|
|
||||||
f"OS error while adding repository {name}. See logs for more information."
|
|
||||||
)
|
|
||||||
self.logger.exception(
|
|
||||||
"Something went wrong trying to add repo %s under name %s",
|
|
||||||
url,
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
cog_modules = []
|
|
||||||
for cog in cogs:
|
|
||||||
# If you're forking this cog, make sure to change these strings!
|
|
||||||
if cog["name"] == "backup" and "SeaswimmerTheFsh/SeaCogs" in url:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
cog_module = await InstalledCog.convert(ctx, cog["name"])
|
await downloader._cog_install_interface.install_cog(
|
||||||
except commands.BadArgument:
|
cog["name"], cog["commit"]
|
||||||
uninstall_e.append(f"Failed to uninstall {cog['name']}")
|
|
||||||
continue
|
|
||||||
cog_modules.append(cog_module)
|
|
||||||
|
|
||||||
for cog in set(cog.name for cog in cog_modules):
|
|
||||||
poss_installed_path = (await downloader.cog_install_path()) / cog
|
|
||||||
if poss_installed_path.exists():
|
|
||||||
with contextlib.suppress(commands.ExtensionNotLoaded):
|
|
||||||
await ctx.bot.unload_extension(cog)
|
|
||||||
await ctx.bot.remove_loaded_package(cog)
|
|
||||||
await downloader._delete_cog(
|
|
||||||
poss_installed_path
|
|
||||||
)
|
|
||||||
uninstall_s.append(f"Uninstalled {cog}")
|
|
||||||
self.logger.debug("Uninstalled %s", cog)
|
|
||||||
else:
|
|
||||||
uninstall_e.append(f"Failed to uninstall {cog}")
|
|
||||||
self.logger.warning("Failed to uninstall %s", cog)
|
|
||||||
await downloader._remove_from_installed(
|
|
||||||
cog_modules
|
|
||||||
)
|
|
||||||
|
|
||||||
for cog in cogs:
|
|
||||||
cog_name = cog["name"]
|
|
||||||
cog_pinned = cog["pinned"]
|
|
||||||
if cog_pinned:
|
|
||||||
commit = cog["commit"]
|
|
||||||
else:
|
|
||||||
commit = None
|
|
||||||
|
|
||||||
# If you're forking this cog, make sure to change these strings!
|
|
||||||
if cog_name == "backup" and "SeaswimmerTheFsh/SeaCogs" in url:
|
|
||||||
continue
|
|
||||||
|
|
||||||
async with repository.checkout(
|
|
||||||
commit, exit_to_rev=repository.branch
|
|
||||||
):
|
|
||||||
cogs_c, message = (
|
|
||||||
await downloader._filter_incorrect_cogs_by_names(
|
|
||||||
repository, [cog_name]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not cogs_c:
|
|
||||||
install_e.append(message)
|
|
||||||
self.logger.error(message)
|
|
||||||
continue
|
|
||||||
failed_reqs = await downloader._install_requirements(
|
|
||||||
cogs_c
|
|
||||||
)
|
|
||||||
if failed_reqs:
|
|
||||||
install_e.append(
|
|
||||||
f"Failed to install {cog_name} due to missing requirements: {failed_reqs}"
|
|
||||||
)
|
|
||||||
self.logger.error(
|
|
||||||
"Failed to install %s due to missing requirements: %s",
|
|
||||||
cog_name,
|
|
||||||
failed_reqs,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
installed_cogs, failed_cogs = await downloader._install_cogs(
|
|
||||||
cogs_c
|
|
||||||
)
|
|
||||||
|
|
||||||
if repository.available_libraries:
|
|
||||||
installed_libs, failed_libs = (
|
|
||||||
await repository.install_libraries(
|
|
||||||
target_dir=downloader.SHAREDLIB_PATH,
|
|
||||||
req_target_dir=downloader.LIB_PATH,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
installed_libs = None
|
|
||||||
failed_libs = None
|
|
||||||
|
|
||||||
if cog_pinned:
|
|
||||||
for cog in installed_cogs:
|
|
||||||
cog.pinned = True
|
|
||||||
|
|
||||||
await downloader._save_to_installed(
|
|
||||||
installed_cogs + installed_libs
|
|
||||||
if installed_libs
|
|
||||||
else installed_cogs
|
|
||||||
)
|
|
||||||
if installed_cogs:
|
|
||||||
installed_cog_name = installed_cogs[0].name
|
|
||||||
install_s.append(f"Installed {installed_cog_name}")
|
|
||||||
self.logger.debug("Installed %s", installed_cog_name)
|
|
||||||
if installed_libs:
|
|
||||||
for lib in installed_libs:
|
|
||||||
install_s.append(
|
|
||||||
f"Installed {lib.name} required for {cog_name}"
|
|
||||||
)
|
|
||||||
self.logger.debug(
|
|
||||||
"Installed %s required for %s", lib.name, cog_name
|
|
||||||
)
|
|
||||||
if failed_cogs:
|
|
||||||
failed_cog_name = failed_cogs[0].name
|
|
||||||
install_e.append(f"Failed to install {failed_cog_name}")
|
|
||||||
self.logger.error("Failed to install %s", failed_cog_name)
|
|
||||||
if failed_libs:
|
|
||||||
for lib in failed_libs:
|
|
||||||
install_e.append(
|
|
||||||
f"Failed to install {lib.name} required for {cog_name}"
|
|
||||||
)
|
|
||||||
self.logger.error(
|
|
||||||
"Failed to install %s required for %s",
|
|
||||||
lib.name,
|
|
||||||
cog_name,
|
|
||||||
)
|
|
||||||
await ctx.send(
|
|
||||||
"Import complete!",
|
|
||||||
file=text_to_file(
|
|
||||||
f"Repositories:\n{repo_s}\n\nRepository Errors:\n{repo_e}\n\nUninstalled Cogs:\n{uninstall_s}\n\nUninstalled Cogs Errors:\n{uninstall_e}\n\nInstalled Cogs:\n{install_s}\n\nInstalled Cogs Errors:\n{install_e}",
|
|
||||||
"backup.log",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
except errors.CogNotFoundError:
|
||||||
|
pass
|
||||||
|
except errors.DownloaderError:
|
||||||
|
pass
|
|
@ -7,8 +7,8 @@
|
||||||
"end_user_data_statement" : "This cog does not store end user data.",
|
"end_user_data_statement" : "This cog does not store end user data.",
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"min_bot_version": "3.5.0",
|
"min_bot_version": "3.5.6",
|
||||||
"max_bot_version": "3.5.5",
|
"max_bot_version": "3.5.9",
|
||||||
"min_python_version": [3, 9, 0],
|
"min_python_version": [3, 9, 0],
|
||||||
"tags": [
|
"tags": [
|
||||||
"utility",
|
"utility",
|
||||||
|
|
|
@ -6,11 +6,14 @@
|
||||||
# |_____/ \___|\__,_|___/ \_/\_/ |_|_| |_| |_|_| |_| |_|\___|_|
|
# |_____/ \___|\__,_|___/ \_/\_/ |_|_| |_| |_|_| |_| |_|\___|_|
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from discord import Embed
|
import numpy as np
|
||||||
|
from discord import Colour, Embed, File
|
||||||
|
from PIL import Image
|
||||||
from red_commons.logging import getLogger
|
from red_commons.logging import getLogger
|
||||||
from redbot.core import Config, commands
|
from redbot.core import Config, commands, data_manager
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.utils.chat_formatting import error, humanize_list
|
from redbot.core.utils.chat_formatting import error, humanize_list
|
||||||
|
|
||||||
|
@ -22,7 +25,8 @@ class Bible(commands.Cog):
|
||||||
"""Retrieve Bible verses from the API.bible API."""
|
"""Retrieve Bible verses from the API.bible API."""
|
||||||
|
|
||||||
__author__ = ["SeaswimmerTheFsh"]
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
__version__ = "1.0.1"
|
__version__ = "1.1.0"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/bible/"
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
def __init__(self, bot: Red):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@ -31,7 +35,7 @@ class Bible(commands.Cog):
|
||||||
self.config = Config.get_conf(
|
self.config = Config.get_conf(
|
||||||
self, identifier=481923957134912, force_registration=True
|
self, identifier=481923957134912, force_registration=True
|
||||||
)
|
)
|
||||||
self.logger = getLogger("red.seacogs.bible")
|
self.logger = getLogger("red.SeaCogs.Bible")
|
||||||
self.config.register_global(bible="de4e12af7f28f599-02")
|
self.config.register_global(bible="de4e12af7f28f599-02")
|
||||||
self.config.register_user(bible=None)
|
self.config.register_user(bible=None)
|
||||||
|
|
||||||
|
@ -42,9 +46,26 @@ class Bible(commands.Cog):
|
||||||
f"{pre_processed}{n}",
|
f"{pre_processed}{n}",
|
||||||
f"Cog Version: **{self.__version__}**",
|
f"Cog Version: **{self.__version__}**",
|
||||||
f"Author: {humanize_list(self.__author__)}",
|
f"Author: {humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}",
|
||||||
]
|
]
|
||||||
return "\n".join(text)
|
return "\n".join(text)
|
||||||
|
|
||||||
|
def get_icon(self, color: Colour) -> File:
|
||||||
|
"""Get the docs.api.bible favicon with a given color."""
|
||||||
|
image_path = data_manager.bundled_data_path(self) / "api.bible-logo.png"
|
||||||
|
image = Image.open(image_path)
|
||||||
|
image = image.convert("RGBA")
|
||||||
|
data = np.array(image)
|
||||||
|
red, green, blue, alpha = data.T # pylint: disable=unused-variable
|
||||||
|
white_areas = (red == 255) & (blue == 255) & (green == 255)
|
||||||
|
data[..., :-1][white_areas.T] = color.to_rgb()
|
||||||
|
image = Image.fromarray(data)
|
||||||
|
|
||||||
|
with BytesIO() as image_binary:
|
||||||
|
image.save(image_binary, "PNG")
|
||||||
|
image_binary.seek(0)
|
||||||
|
return File(image_binary, filename="icon.png", description="API.Bible Icon")
|
||||||
|
|
||||||
async def translate_book_name(self, bible_id: str, book_name: str) -> str:
|
async def translate_book_name(self, bible_id: str, book_name: str) -> str:
|
||||||
"""Translate a book name to a book ID."""
|
"""Translate a book name to a book ID."""
|
||||||
book_name_list = [
|
book_name_list = [
|
||||||
|
@ -246,15 +267,17 @@ class Bible(commands.Cog):
|
||||||
return
|
return
|
||||||
|
|
||||||
if await ctx.embed_requested():
|
if await ctx.embed_requested():
|
||||||
|
icon = self.get_icon(await ctx.embed_color())
|
||||||
embed = Embed(
|
embed = Embed(
|
||||||
title=f"{passage['reference']}",
|
title=f"{passage['reference']}",
|
||||||
description=passage["content"].replace("¶ ", ""),
|
description=passage["content"].replace("¶ ", ""),
|
||||||
color=await self.bot.get_embed_color(ctx.channel),
|
color=await ctx.embed_color(),
|
||||||
)
|
)
|
||||||
embed.set_footer(
|
embed.set_footer(
|
||||||
text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})"
|
text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})",
|
||||||
|
icon_url="attachment://icon.png"
|
||||||
)
|
)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed, file=icon)
|
||||||
else:
|
else:
|
||||||
await ctx.send(f"## {passage['reference']}\n{passage['content']}")
|
await ctx.send(f"## {passage['reference']}\n{passage['content']}")
|
||||||
|
|
||||||
|
@ -286,14 +309,16 @@ class Bible(commands.Cog):
|
||||||
return
|
return
|
||||||
|
|
||||||
if await ctx.embed_requested():
|
if await ctx.embed_requested():
|
||||||
|
icon = self.get_icon(await ctx.embed_color())
|
||||||
embed = Embed(
|
embed = Embed(
|
||||||
title=f"{passage['reference']}",
|
title=f"{passage['reference']}",
|
||||||
description=passage["content"].replace("¶ ", ""),
|
description=passage["content"].replace("¶ ", ""),
|
||||||
color=await self.bot.get_embed_color(ctx.channel),
|
color=await ctx.embed_color(),
|
||||||
)
|
)
|
||||||
embed.set_footer(
|
embed.set_footer(
|
||||||
text=f"{ctx.prefix}bible random - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})"
|
text=f"{ctx.prefix}bible random - Powered by API.Bible - {version.abbreviationLocal} ({version.languageLocal}, {version.descriptionLocal})",
|
||||||
|
icon_url="attachment://icon.png"
|
||||||
)
|
)
|
||||||
await ctx.send(embed=embed)
|
await ctx.send(embed=embed, file=icon)
|
||||||
else:
|
else:
|
||||||
await ctx.send(f"## {passage['reference']}\n{passage['content']}")
|
await ctx.send(f"## {passage['reference']}\n{passage['content']}")
|
||||||
|
|
BIN
bible/data/api.bible-logo.png
Normal file
BIN
bible/data/api.bible-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
|
@ -9,6 +9,7 @@
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"min_bot_version": "3.5.0",
|
"min_bot_version": "3.5.0",
|
||||||
"min_python_version": [3, 10, 0],
|
"min_python_version": [3, 10, 0],
|
||||||
|
"requirements": ["numpy", "pillow"],
|
||||||
"tags": [
|
"tags": [
|
||||||
"fun",
|
"fun",
|
||||||
"utility",
|
"utility",
|
||||||
|
|
|
@ -29,7 +29,7 @@ nav:
|
||||||
plugins:
|
plugins:
|
||||||
- git-authors
|
- git-authors
|
||||||
- search
|
- search
|
||||||
- social
|
#- social
|
||||||
- git-revision-date-localized:
|
- git-revision-date-localized:
|
||||||
enable_creation_date: true
|
enable_creation_date: true
|
||||||
type: timeago
|
type: timeago
|
||||||
|
|
|
@ -18,7 +18,8 @@ class Nerdify(commands.Cog):
|
||||||
"""Nerdify your text."""
|
"""Nerdify your text."""
|
||||||
|
|
||||||
__author__ = ["SeaswimmerTheFsh"]
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
__version__ = "1.3.3"
|
__version__ = "1.3.4"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/nerdify/"
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
@ -30,6 +31,7 @@ class Nerdify(commands.Cog):
|
||||||
f"{pre_processed}{n}",
|
f"{pre_processed}{n}",
|
||||||
f"Cog Version: **{self.__version__}**",
|
f"Cog Version: **{self.__version__}**",
|
||||||
f"Author: {chat_formatting.humanize_list(self.__author__)}",
|
f"Author: {chat_formatting.humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}"
|
||||||
]
|
]
|
||||||
return "\n".join(text)
|
return "\n".join(text)
|
||||||
|
|
||||||
|
|
2020
poetry.lock
generated
2020
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -7,6 +7,7 @@ def register_config(config_obj: Config) -> None:
|
||||||
base_url=None,
|
base_url=None,
|
||||||
server_id=None,
|
server_id=None,
|
||||||
console_channel=None,
|
console_channel=None,
|
||||||
|
console_commands_enabled=False,
|
||||||
current_status='',
|
current_status='',
|
||||||
chat_regex=r"^\[\d{2}:\d{2}:\d{2}\sINFO\]: (?!\[(?:Server|Rcon)\])(?:<|\[)(\w+)(?:>|\]) (.*)",
|
chat_regex=r"^\[\d{2}:\d{2}:\d{2}\sINFO\]: (?!\[(?:Server|Rcon)\])(?:<|\[)(\w+)(?:>|\]) (.*)",
|
||||||
server_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]:(?: \[Not Secure\])? \[(?:Server|Rcon)\] (.*)",
|
server_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]:(?: \[Not Secure\])? \[(?:Server|Rcon)\] (.*)",
|
||||||
|
@ -14,6 +15,9 @@ def register_config(config_obj: Config) -> None:
|
||||||
leave_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]: ([^<\n]+) left the game$",
|
leave_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]: ([^<\n]+) left the game$",
|
||||||
achievement_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]: (.*) has (made the advancement|completed the challenge) \[(.*)\]$",
|
achievement_regex=r"^\[\d{2}:\d{2}:\d{2} INFO\]: (.*) has (made the advancement|completed the challenge) \[(.*)\]$",
|
||||||
chat_command='tellraw @a ["",{"text":".$N ","color":".$C","insertion":"<@.$I>","hoverEvent":{"action":"show_text","contents":"Shift click to mention this user inside Discord"}},{"text":"(DISCORD):","color":"blue","clickEvent":{"action":"open_url","value":".$V"},"hoverEvent":{"action":"show_text","contents":"Click to join the Discord Server"}},{"text":" .$M","color":"white"}]', # noqa: E501
|
chat_command='tellraw @a ["",{"text":".$N ","color":".$C","insertion":"<@.$I>","hoverEvent":{"action":"show_text","contents":"Shift click to mention this user inside Discord"}},{"text":"(DISCORD):","color":"blue","clickEvent":{"action":"open_url","value":".$V"},"hoverEvent":{"action":"show_text","contents":"Click to join the Discord Server"}},{"text":" .$M","color":"white"}]', # noqa: E501
|
||||||
|
topic='Server IP: .$H\nServer Players: .$P/.$M',
|
||||||
|
topic_hostname=None,
|
||||||
|
topic_port=25565,
|
||||||
api_endpoint="minecraft",
|
api_endpoint="minecraft",
|
||||||
chat_channel=None,
|
chat_channel=None,
|
||||||
startup_msg='Server started!',
|
startup_msg='Server started!',
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
|
from red_commons import logging
|
||||||
from red_commons.logging import getLogger
|
from red_commons.logging import getLogger
|
||||||
|
|
||||||
logger = getLogger('red.seacogs.pterodactyl')
|
logger = getLogger('red.SeaCogs.Pterodactyl')
|
||||||
websocket_logger = getLogger('red.seacogs.pterodactyl.websocket')
|
websocket_logger = getLogger('red.SeaCogs.Pterodactyl.websocket')
|
||||||
|
if logger.level >= logging.VERBOSE:
|
||||||
|
websocket_logger.setLevel(logging.logging.INFO)
|
||||||
|
elif logger.level < logging.VERBOSE:
|
||||||
|
websocket_logger.setLevel(logging.logging.DEBUG)
|
||||||
|
|
10
pterodactyl/mcsrvstatus.py
Normal file
10
pterodactyl/mcsrvstatus.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
async def get_status(host, port = 25565) -> tuple[bool, dict]:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(f'https://api.mcsrvstat.us/2/{host}:{port}') as response:
|
||||||
|
response = await response.json()
|
||||||
|
if response['online']:
|
||||||
|
return (True, response)
|
||||||
|
return (False, response)
|
|
@ -1,16 +1,18 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Mapping, Optional, Union
|
from typing import Mapping, Optional, Tuple, Union
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import websockets
|
import websockets
|
||||||
|
from discord.ext import tasks
|
||||||
from pydactyl import PterodactylClient
|
from pydactyl import PterodactylClient
|
||||||
from redbot.core import app_commands, commands
|
from redbot.core import app_commands, commands
|
||||||
from redbot.core.app_commands import Choice
|
from redbot.core.app_commands import Choice
|
||||||
from redbot.core.bot import Red
|
from redbot.core.bot import Red
|
||||||
from redbot.core.utils.chat_formatting import box, error
|
from redbot.core.utils.chat_formatting import box, error, humanize_list
|
||||||
from redbot.core.utils.views import ConfirmView
|
from redbot.core.utils.views import ConfirmView
|
||||||
|
|
||||||
|
from pterodactyl import mcsrvstatus
|
||||||
from pterodactyl.config import config, register_config
|
from pterodactyl.config import config, register_config
|
||||||
from pterodactyl.logger import logger
|
from pterodactyl.logger import logger
|
||||||
|
|
||||||
|
@ -18,6 +20,10 @@ from pterodactyl.logger import logger
|
||||||
class Pterodactyl(commands.Cog):
|
class Pterodactyl(commands.Cog):
|
||||||
"""Pterodactyl allows you to manage your Pterodactyl Panel from Discord."""
|
"""Pterodactyl allows you to manage your Pterodactyl Panel from Discord."""
|
||||||
|
|
||||||
|
__author__ = ["SeaswimmerTheFsh"]
|
||||||
|
__version__ = "2.0.0"
|
||||||
|
__documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/"
|
||||||
|
|
||||||
def __init__(self, bot: Red):
|
def __init__(self, bot: Red):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.client: Optional[PterodactylClient] = None
|
self.client: Optional[PterodactylClient] = None
|
||||||
|
@ -25,12 +31,22 @@ class Pterodactyl(commands.Cog):
|
||||||
self.websocket: Optional[websockets.WebSocketClientProtocol] = None
|
self.websocket: Optional[websockets.WebSocketClientProtocol] = None
|
||||||
self.retry_counter: int = 0
|
self.retry_counter: int = 0
|
||||||
register_config(config)
|
register_config(config)
|
||||||
|
|
||||||
async def cog_load(self) -> None:
|
|
||||||
self.retry_counter = 0
|
|
||||||
self.task = self.get_task()
|
self.task = self.get_task()
|
||||||
|
self.update_topic.start()
|
||||||
|
|
||||||
|
def format_help_for_context(self, ctx: commands.Context) -> str:
|
||||||
|
pre_processed = super().format_help_for_context(ctx) or ""
|
||||||
|
n = "\n" if "\n\n" not in pre_processed else ""
|
||||||
|
text = [
|
||||||
|
f"{pre_processed}{n}",
|
||||||
|
f"Cog Version: **{self.__version__}**",
|
||||||
|
f"Author: {humanize_list(self.__author__)}",
|
||||||
|
f"Documentation: {self.__documentation__}",
|
||||||
|
]
|
||||||
|
return "\n".join(text)
|
||||||
|
|
||||||
async def cog_unload(self) -> None:
|
async def cog_unload(self) -> None:
|
||||||
|
self.update_topic.cancel()
|
||||||
self.task.cancel()
|
self.task.cancel()
|
||||||
self.retry_counter = 0
|
self.retry_counter = 0
|
||||||
await self.client._session.close() # pylint: disable=protected-access
|
await self.client._session.close() # pylint: disable=protected-access
|
||||||
|
@ -56,11 +72,26 @@ class Pterodactyl(commands.Cog):
|
||||||
else:
|
else:
|
||||||
logger.info("Retry limit reached. Stopping task.")
|
logger.info("Retry limit reached. Stopping task.")
|
||||||
|
|
||||||
|
@tasks.loop(minutes=6)
|
||||||
|
async def update_topic(self):
|
||||||
|
await self.bot.wait_until_red_ready()
|
||||||
|
topic = await self.get_topic()
|
||||||
|
console = self.bot.get_channel(await config.console_channel())
|
||||||
|
chat = self.bot.get_channel(await config.chat_channel())
|
||||||
|
if console:
|
||||||
|
await console.edit(topic=topic)
|
||||||
|
if chat:
|
||||||
|
await chat.edit(topic=topic)
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_message_without_command(self, message: discord.Message) -> None:
|
async def on_message_without_command(self, message: discord.Message) -> None:
|
||||||
if message.channel.id == await config.console_channel() and message.author.bot is False:
|
if message.channel.id == await config.console_channel() and message.author.bot is False:
|
||||||
|
if await config.console_commands_enabled() is False:
|
||||||
|
await message.channel.send("Console commands are disabled.")
|
||||||
|
logger.debug("Received console command from %s, but console commands are disabled: %s", message.author.id, message.content)
|
||||||
|
return
|
||||||
logger.debug("Received console command from %s: %s", message.author.id, message.content)
|
logger.debug("Received console command from %s: %s", message.author.id, message.content)
|
||||||
await message.channel.send(f"Received console command from {message.author.id}: {message.content[:1900]}")
|
await message.channel.send(f"Received console command from {message.author.id}: {message.content[:1900]}", allowed_mentions=discord.AllowedMentions.none())
|
||||||
try:
|
try:
|
||||||
await self.websocket.send(json.dumps({"event": "send command", "args": [message.content]}))
|
await self.websocket.send(json.dumps({"event": "send command", "args": [message.content]}))
|
||||||
except websockets.exceptions.ConnectionClosed as e:
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
@ -72,7 +103,7 @@ class Pterodactyl(commands.Cog):
|
||||||
logger.debug("Received chat message from %s: %s", message.author.id, message.content)
|
logger.debug("Received chat message from %s: %s", message.author.id, message.content)
|
||||||
channel = self.bot.get_channel(await config.console_channel())
|
channel = self.bot.get_channel(await config.console_channel())
|
||||||
if channel:
|
if channel:
|
||||||
await channel.send(f"Received chat message from {message.author.id}: {message.content[:1900]}")
|
await channel.send(f"Received chat message from {message.author.id}: {message.content[:1900]}", allowed_mentions=discord.AllowedMentions.none())
|
||||||
msg = json.dumps({"event": "send command", "args": [await self.get_chat_command(message)]})
|
msg = json.dumps({"event": "send command", "args": [await self.get_chat_command(message)]})
|
||||||
logger.debug("Sending chat message to server:\n%s", msg)
|
logger.debug("Sending chat message to server:\n%s", msg)
|
||||||
try:
|
try:
|
||||||
|
@ -83,13 +114,41 @@ class Pterodactyl(commands.Cog):
|
||||||
self.retry_counter = 0
|
self.retry_counter = 0
|
||||||
self.task = self.get_task()
|
self.task = self.get_task()
|
||||||
|
|
||||||
|
async def get_topic(self) -> str:
|
||||||
|
topic: str = await config.topic()
|
||||||
|
placeholders = {
|
||||||
|
"H": await config.topic_hostname() or "unset",
|
||||||
|
"O": str(await config.topic_port()),
|
||||||
|
}
|
||||||
|
if await config.api_endpoint() == "minecraft":
|
||||||
|
status, response = await mcsrvstatus.get_status(await config.topic_hostname(), await config.topic_port())
|
||||||
|
if status:
|
||||||
|
placeholders.update({
|
||||||
|
"I": response['ip'],
|
||||||
|
"M": str(response['players']['max']),
|
||||||
|
"P": str(response['players']['online']),
|
||||||
|
"V": response['version'],
|
||||||
|
"D": response['motd']['clean'][0] if response['motd']['clean'] else "unset",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
placeholders.update({
|
||||||
|
"I": response['ip'],
|
||||||
|
"M": "0",
|
||||||
|
"P": "0",
|
||||||
|
"V": "Server Offline",
|
||||||
|
"D": "Server Offline",
|
||||||
|
})
|
||||||
|
for key, value in placeholders.items():
|
||||||
|
topic = topic.replace('.$' + key, value)
|
||||||
|
return topic
|
||||||
|
|
||||||
async def get_chat_command(self, message: discord.Message) -> str:
|
async def get_chat_command(self, message: discord.Message) -> str:
|
||||||
command: str = await config.chat_command()
|
command: str = await config.chat_command()
|
||||||
placeholders = {
|
placeholders = {
|
||||||
"C": str(message.author.color),
|
"C": str(message.author.color),
|
||||||
"D": message.author.discriminator,
|
"D": message.author.discriminator,
|
||||||
"I": str(message.author.id),
|
"I": str(message.author.id),
|
||||||
"M": message.content.replace('"',''),
|
"M": message.content.replace('"','').replace("\n", " "),
|
||||||
"N": message.author.display_name,
|
"N": message.author.display_name,
|
||||||
"U": message.author.name,
|
"U": message.author.name,
|
||||||
"V": await config.invite() or "use [p]pterodactyl config invite to change me",
|
"V": await config.invite() or "use [p]pterodactyl config invite to change me",
|
||||||
|
@ -98,6 +157,22 @@ class Pterodactyl(commands.Cog):
|
||||||
command = command.replace('.$' + key, value)
|
command = command.replace('.$' + key, value)
|
||||||
return command
|
return command
|
||||||
|
|
||||||
|
async def get_player_list(self) -> Optional[Tuple[str, list]]:
|
||||||
|
if await config.api_endpoint() == "minecraft":
|
||||||
|
status, response = await mcsrvstatus.get_status(await config.topic_hostname(), await config.topic_port())
|
||||||
|
if status and 'list' in response['players']:
|
||||||
|
output_str = '\n'.join(response['players']['list'])
|
||||||
|
return output_str, response['players']['list']
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_player_list_embed(self, ctx: Union[commands.Context, discord.Interaction]) -> Optional[discord.Embed]:
|
||||||
|
player_list = await self.get_player_list()
|
||||||
|
if player_list:
|
||||||
|
embed = discord.Embed(color=await self.bot.get_embed_color(ctx.channel), title="Players Online")
|
||||||
|
embed.description = player_list[0]
|
||||||
|
return embed
|
||||||
|
return None
|
||||||
|
|
||||||
async def power(self, ctx: Union[discord.Interaction, commands.Context], action: str, action_ing: str, warning: str = '') -> None:
|
async def power(self, ctx: Union[discord.Interaction, commands.Context], action: str, action_ing: str, warning: str = '') -> None:
|
||||||
if isinstance(ctx, discord.Interaction):
|
if isinstance(ctx, discord.Interaction):
|
||||||
author = ctx.user
|
author = ctx.user
|
||||||
|
@ -111,7 +186,7 @@ class Pterodactyl(commands.Cog):
|
||||||
return await ctx.response.send_message(f"Server is already {action_ing}.", ephemeral=True)
|
return await ctx.response.send_message(f"Server is already {action_ing}.", ephemeral=True)
|
||||||
return await ctx.send(f"Server is already {action_ing}.")
|
return await ctx.send(f"Server is already {action_ing}.")
|
||||||
|
|
||||||
if current_status in ["starting", "stopping"]:
|
if current_status in ["starting", "stopping"] and action != "kill":
|
||||||
if isinstance(ctx, discord.Interaction):
|
if isinstance(ctx, discord.Interaction):
|
||||||
return await ctx.response.send_message("Another power action is already in progress.", ephemeral=True)
|
return await ctx.response.send_message("Another power action is already in progress.", ephemeral=True)
|
||||||
return await ctx.send("Another power action is already in progress.")
|
return await ctx.send("Another power action is already in progress.")
|
||||||
|
@ -148,7 +223,7 @@ class Pterodactyl(commands.Cog):
|
||||||
channel = self.bot.get_channel(await config.console_channel())
|
channel = self.bot.get_channel(await config.console_channel())
|
||||||
if isinstance(ctx, discord.Interaction):
|
if isinstance(ctx, discord.Interaction):
|
||||||
if channel:
|
if channel:
|
||||||
await channel.send(f"Received console command from {ctx.user.id}: {command[:1900]}")
|
await channel.send(f"Received console command from {ctx.user.id}: {command[:1900]}", allowed_mentions=discord.AllowedMentions.none())
|
||||||
try:
|
try:
|
||||||
await self.websocket.send(json.dumps({"event": "send command", "args": [command]}))
|
await self.websocket.send(json.dumps({"event": "send command", "args": [command]}))
|
||||||
await ctx.response.send_message(f"Command sent to server. {box(command, 'json')}", ephemeral=True)
|
await ctx.response.send_message(f"Command sent to server. {box(command, 'json')}", ephemeral=True)
|
||||||
|
@ -160,7 +235,7 @@ class Pterodactyl(commands.Cog):
|
||||||
self.task = self.get_task()
|
self.task = self.get_task()
|
||||||
else:
|
else:
|
||||||
if channel:
|
if channel:
|
||||||
await channel.send(f"Received console command from {ctx.author.id}: {command[:1900]}")
|
await channel.send(f"Received console command from {ctx.author.id}: {command[:1900]}", allowed_mentions=discord.AllowedMentions.none())
|
||||||
try:
|
try:
|
||||||
await self.websocket.send(json.dumps({"event": "send command", "args": [command]}))
|
await self.websocket.send(json.dumps({"event": "send command", "args": [command]}))
|
||||||
await ctx.send(f"Command sent to server. {box(command, 'json')}")
|
await ctx.send(f"Command sent to server. {box(command, 'json')}")
|
||||||
|
@ -191,6 +266,15 @@ class Pterodactyl(commands.Cog):
|
||||||
The command to send to the server."""
|
The command to send to the server."""
|
||||||
return await self.send_command(interaction, command)
|
return await self.send_command(interaction, command)
|
||||||
|
|
||||||
|
@slash_pterodactyl.command(name = "players", description = "Retrieve a list of players on the server.")
|
||||||
|
async def slash_pterodactyl_players(self, interaction: discord.Interaction) -> None:
|
||||||
|
"""Retrieve a list of players on the server."""
|
||||||
|
e = await self.get_player_list_embed(interaction)
|
||||||
|
if e:
|
||||||
|
await interaction.response.send_message(embed=e, ephemeral=True)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message("No players online.", ephemeral=True)
|
||||||
|
|
||||||
@slash_pterodactyl.command(name = "power", description = "Send power actions to the server.")
|
@slash_pterodactyl.command(name = "power", description = "Send power actions to the server.")
|
||||||
@app_commands.choices(action=[
|
@app_commands.choices(action=[
|
||||||
Choice(name="Start", value="start"),
|
Choice(name="Start", value="start"),
|
||||||
|
@ -207,12 +291,23 @@ class Pterodactyl(commands.Cog):
|
||||||
The action to perform on the server."""
|
The action to perform on the server."""
|
||||||
if action.value == "kill":
|
if action.value == "kill":
|
||||||
return await self.power(interaction, action.value, "stopping... (forcefully killed)", warning="**⚠️ Forcefully killing the server process can corrupt data in some cases. ⚠️**\n")
|
return await self.power(interaction, action.value, "stopping... (forcefully killed)", warning="**⚠️ Forcefully killing the server process can corrupt data in some cases. ⚠️**\n")
|
||||||
|
if action.value == "stop":
|
||||||
|
return await self.power(interaction, action.value, "stopping...")
|
||||||
return await self.power(interaction, action.value, f"{action.value}ing...")
|
return await self.power(interaction, action.value, f"{action.value}ing...")
|
||||||
|
|
||||||
@commands.group(autohelp = True, name = "pterodactyl", aliases = ["ptero"])
|
@commands.group(autohelp = True, name = "pterodactyl", aliases = ["ptero"])
|
||||||
async def pterodactyl(self, ctx: commands.Context) -> None:
|
async def pterodactyl(self, ctx: commands.Context) -> None:
|
||||||
"""Pterodactyl allows you to manage your Pterodactyl Panel from Discord."""
|
"""Pterodactyl allows you to manage your Pterodactyl Panel from Discord."""
|
||||||
|
|
||||||
|
@pterodactyl.command(name = "players", aliases=["list", "online", "playerlist", "who"])
|
||||||
|
async def pterodactyl_players(self, ctx: commands.Context) -> None:
|
||||||
|
"""Retrieve a list of players on the server."""
|
||||||
|
e = await self.get_player_list_embed(ctx)
|
||||||
|
if e:
|
||||||
|
await ctx.send(embed=e)
|
||||||
|
else:
|
||||||
|
await ctx.send("No players online.")
|
||||||
|
|
||||||
@pterodactyl.command(name = "command", aliases = ["cmd", "execute", "exec"])
|
@pterodactyl.command(name = "command", aliases = ["cmd", "execute", "exec"])
|
||||||
@commands.admin()
|
@commands.admin()
|
||||||
async def pterodactyl_command(self, ctx: commands.Context, *, command: str) -> None:
|
async def pterodactyl_command(self, ctx: commands.Context, *, command: str) -> None:
|
||||||
|
@ -272,18 +367,60 @@ class Pterodactyl(commands.Cog):
|
||||||
self.retry_counter = 0
|
self.retry_counter = 0
|
||||||
self.task = self.get_task()
|
self.task = self.get_task()
|
||||||
|
|
||||||
@pterodactyl_config.command(name = "consolechannel")
|
@pterodactyl_config.group(name = "console")
|
||||||
|
async def pterodactyl_config_console(self, ctx: commands.Context):
|
||||||
|
"""Configure console settings."""
|
||||||
|
|
||||||
|
@pterodactyl_config_console.command(name = "channel")
|
||||||
async def pterodactyl_config_console_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
|
async def pterodactyl_config_console_channel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
|
||||||
"""Set the channel to send console output to."""
|
"""Set the channel to send console output to."""
|
||||||
await config.console_channel.set(channel.id)
|
await config.console_channel.set(channel.id)
|
||||||
await ctx.send(f"Console channel set to {channel.mention}")
|
await ctx.send(f"Console channel set to {channel.mention}")
|
||||||
|
|
||||||
|
@pterodactyl_config_console.command(name = "commands")
|
||||||
|
async def pterodactyl_config_console_commands(self, ctx: commands.Context, enabled: bool) -> None:
|
||||||
|
"""Enable or disable console commands."""
|
||||||
|
await config.console_commands_enabled.set(enabled)
|
||||||
|
await ctx.send(f"Console commands set to {enabled}")
|
||||||
|
|
||||||
@pterodactyl_config.command(name = "invite")
|
@pterodactyl_config.command(name = "invite")
|
||||||
async def pterodactyl_config_invite(self, ctx: commands.Context, invite: str) -> None:
|
async def pterodactyl_config_invite(self, ctx: commands.Context, invite: str) -> None:
|
||||||
"""Set the invite link for your server."""
|
"""Set the invite link for your server."""
|
||||||
await config.invite.set(invite)
|
await config.invite.set(invite)
|
||||||
await ctx.send(f"Invite link set to {invite}")
|
await ctx.send(f"Invite link set to {invite}")
|
||||||
|
|
||||||
|
@pterodactyl_config.group(name = "topic")
|
||||||
|
async def pterodactyl_config_topic(self, ctx: commands.Context):
|
||||||
|
"""Set the topic for the console and chat channels."""
|
||||||
|
|
||||||
|
@pterodactyl_config_topic.command(name = "host", aliases = ["hostname", "ip"])
|
||||||
|
async def pterodactyl_config_topic_host(self, ctx: commands.Context, host: str) -> None:
|
||||||
|
"""Set the hostname or IP address of your server."""
|
||||||
|
await config.topic_hostname.set(host)
|
||||||
|
await ctx.send(f"Hostname/IP set to `{host}`")
|
||||||
|
|
||||||
|
@pterodactyl_config_topic.command(name = "port")
|
||||||
|
async def pterodactyl_config_topic_port(self, ctx: commands.Context, port: int) -> None:
|
||||||
|
"""Set the port of your server."""
|
||||||
|
await config.topic_port.set(port)
|
||||||
|
await ctx.send(f"Port set to `{port}`")
|
||||||
|
|
||||||
|
@pterodactyl_config_topic.command(name = "text")
|
||||||
|
async def pterodactyl_config_topic_text(self, ctx: commands.Context, *, text: str) -> None:
|
||||||
|
"""Set the text for the console and chat channels.
|
||||||
|
|
||||||
|
Available placeholders:
|
||||||
|
- `.$H` (hostname)
|
||||||
|
- `.$O` (port)
|
||||||
|
Available for Minecraft servers:
|
||||||
|
- `.$I` (ip)
|
||||||
|
- `.$M` (max players)
|
||||||
|
- `.$P` (players online)
|
||||||
|
- `.$V` (version)
|
||||||
|
- `.$D` (description / Message of the Day)"""
|
||||||
|
await config.topic.set(text)
|
||||||
|
await ctx.send(f"Topic set to:\n{box(text, 'yaml')}")
|
||||||
|
|
||||||
@pterodactyl_config.group(name = "chat")
|
@pterodactyl_config.group(name = "chat")
|
||||||
async def pterodactyl_config_chat(self, ctx: commands.Context):
|
async def pterodactyl_config_chat(self, ctx: commands.Context):
|
||||||
"""Configure chat settings."""
|
"""Configure chat settings."""
|
||||||
|
@ -408,7 +545,7 @@ class Pterodactyl(commands.Cog):
|
||||||
await view.wait()
|
await view.wait()
|
||||||
if view.result is True:
|
if view.result is True:
|
||||||
blacklist.update({name: regex})
|
blacklist.update({name: regex})
|
||||||
await msg.edit(f"Updated `{name}` in the regex blacklist.\n{box(regex, 're')}")
|
await msg.edit(content=f"Updated `{name}` in the regex blacklist.\n{box(regex, 're')}")
|
||||||
else:
|
else:
|
||||||
await msg.edit(content="Cancelled.")
|
await msg.edit(content="Cancelled.")
|
||||||
|
|
||||||
|
@ -423,7 +560,7 @@ class Pterodactyl(commands.Cog):
|
||||||
await view.wait()
|
await view.wait()
|
||||||
if view.result is True:
|
if view.result is True:
|
||||||
del blacklist[name]
|
del blacklist[name]
|
||||||
await msg.edit(content="Removed `{name}` from the regex blacklist.")
|
await msg.edit(content=f"Removed `{name}` from the regex blacklist.")
|
||||||
else:
|
else:
|
||||||
await msg.edit(content="Cancelled.")
|
await msg.edit(content="Cancelled.")
|
||||||
else:
|
else:
|
||||||
|
@ -435,6 +572,7 @@ class Pterodactyl(commands.Cog):
|
||||||
base_url = await config.base_url()
|
base_url = await config.base_url()
|
||||||
server_id = await config.server_id()
|
server_id = await config.server_id()
|
||||||
console_channel = await config.console_channel()
|
console_channel = await config.console_channel()
|
||||||
|
console_commands_enabled = await config.console_commands_enabled()
|
||||||
chat_channel = await config.chat_channel()
|
chat_channel = await config.chat_channel()
|
||||||
chat_command = await config.chat_command()
|
chat_command = await config.chat_command()
|
||||||
chat_regex = await config.chat_regex()
|
chat_regex = await config.chat_regex()
|
||||||
|
@ -450,10 +588,14 @@ class Pterodactyl(commands.Cog):
|
||||||
api_endpoint = await config.api_endpoint()
|
api_endpoint = await config.api_endpoint()
|
||||||
invite = await config.invite()
|
invite = await config.invite()
|
||||||
regex_blacklist: dict = await config.regex_blacklist()
|
regex_blacklist: dict = await config.regex_blacklist()
|
||||||
|
topic_text = await config.topic()
|
||||||
|
topic_hostname = await config.topic_hostname()
|
||||||
|
topic_port = await config.topic_port()
|
||||||
embed = discord.Embed(color = await ctx.embed_color(), title="Pterodactyl Configuration")
|
embed = discord.Embed(color = await ctx.embed_color(), title="Pterodactyl Configuration")
|
||||||
embed.description = f"""**Base URL:** {base_url}
|
embed.description = f"""**Base URL:** {base_url}
|
||||||
**Server ID:** `{server_id}`
|
**Server ID:** `{server_id}`
|
||||||
**Console Channel:** <#{console_channel}>
|
**Console Channel:** <#{console_channel}>
|
||||||
|
**Console Commands:** {self.get_bool_str(console_commands_enabled)}
|
||||||
**Chat Channel:** <#{chat_channel}>
|
**Chat Channel:** <#{chat_channel}>
|
||||||
**Startup Message:** {startup_msg}
|
**Startup Message:** {startup_msg}
|
||||||
**Shutdown Message:** {shutdown_msg}
|
**Shutdown Message:** {shutdown_msg}
|
||||||
|
@ -463,6 +605,10 @@ class Pterodactyl(commands.Cog):
|
||||||
**API Endpoint:** `{api_endpoint}`
|
**API Endpoint:** `{api_endpoint}`
|
||||||
**Invite:** {invite}
|
**Invite:** {invite}
|
||||||
|
|
||||||
|
**Topic Hostname:** `{topic_hostname}`
|
||||||
|
**Topic Port:** `{topic_port}`
|
||||||
|
**Topic Text:** {box(topic_text, 'yaml')}
|
||||||
|
|
||||||
**Chat Command:** {box(chat_command, 'json')}
|
**Chat Command:** {box(chat_command, 'json')}
|
||||||
**Chat Regex:** {box(chat_regex, 're')}
|
**Chat Regex:** {box(chat_regex, 're')}
|
||||||
**Server Regex:** {box(server_regex, 're')}
|
**Server Regex:** {box(server_regex, 're')}
|
||||||
|
|
|
@ -15,6 +15,7 @@ from pterodactyl.pterodactyl import Pterodactyl
|
||||||
|
|
||||||
|
|
||||||
async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
|
async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
|
||||||
|
await coginstance.bot.wait_until_red_ready()
|
||||||
base_url = await config.base_url()
|
base_url = await config.base_url()
|
||||||
base_url = base_url[:-1] if base_url.endswith('/') else base_url
|
base_url = base_url[:-1] if base_url.endswith('/') else base_url
|
||||||
|
|
||||||
|
@ -52,18 +53,18 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
|
||||||
if await config.mask_ip() is True:
|
if await config.mask_ip() is True:
|
||||||
content = mask_ip(content)
|
content = mask_ip(content)
|
||||||
|
|
||||||
channel = coginstance.bot.get_channel(await config.console_channel())
|
console_channel = coginstance.bot.get_channel(await config.console_channel())
|
||||||
if channel is not None:
|
chat_channel = coginstance.bot.get_channel(await config.chat_channel())
|
||||||
|
if console_channel is not None:
|
||||||
if content.startswith('['):
|
if content.startswith('['):
|
||||||
pagified_content = pagify(content, delims=[" ", "\n"])
|
pagified_content = pagify(content, delims=[" ", "\n"])
|
||||||
for page in pagified_content:
|
for page in pagified_content:
|
||||||
await channel.send(content=page, allowed_mentions=discord.AllowedMentions.none())
|
await console_channel.send(content=page, allowed_mentions=discord.AllowedMentions.none())
|
||||||
|
|
||||||
server_message = await check_if_server_message(content)
|
server_message = await check_if_server_message(content)
|
||||||
if server_message:
|
if server_message:
|
||||||
channel = coginstance.bot.get_channel(await config.chat_channel())
|
if chat_channel is not None:
|
||||||
if channel is not None:
|
await chat_channel.send(server_message if len(server_message) < 2000 else server_message[:1997] + '...', allowed_mentions=discord.AllowedMentions.none())
|
||||||
await channel.send(server_message if len(server_message) < 2000 else server_message[:1997] + '...', allowed_mentions=discord.AllowedMentions.none())
|
|
||||||
|
|
||||||
chat_message = await check_if_chat_message(content)
|
chat_message = await check_if_chat_message(content)
|
||||||
if chat_message:
|
if chat_message:
|
||||||
|
@ -75,30 +76,27 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
|
||||||
|
|
||||||
join_message = await check_if_join_message(content)
|
join_message = await check_if_join_message(content)
|
||||||
if join_message:
|
if join_message:
|
||||||
channel = coginstance.bot.get_channel(await config.chat_channel())
|
if chat_channel is not None:
|
||||||
if channel is not None:
|
if coginstance.bot.embed_requested(chat_channel):
|
||||||
if coginstance.bot.embed_requested(channel):
|
await chat_channel.send(embed=await generate_join_leave_embed(join_message, True))
|
||||||
await channel.send(embed=await generate_join_leave_embed(join_message, True))
|
|
||||||
else:
|
else:
|
||||||
await channel.send(f"{join_message} joined the game", allowed_mentions=discord.AllowedMentions.none())
|
await chat_channel.send(f"{join_message} joined the game", allowed_mentions=discord.AllowedMentions.none())
|
||||||
|
|
||||||
leave_message = await check_if_leave_message(content)
|
leave_message = await check_if_leave_message(content)
|
||||||
if leave_message:
|
if leave_message:
|
||||||
channel = coginstance.bot.get_channel(await config.chat_channel())
|
if chat_channel is not None:
|
||||||
if channel is not None:
|
if coginstance.bot.embed_requested(chat_channel):
|
||||||
if coginstance.bot.embed_requested(channel):
|
await chat_channel.send(embed=await generate_join_leave_embed(leave_message, False))
|
||||||
await channel.send(embed=await generate_join_leave_embed(leave_message, False))
|
|
||||||
else:
|
else:
|
||||||
await channel.send(f"{leave_message} left the game", allowed_mentions=discord.AllowedMentions.none())
|
await chat_channel.send(f"{leave_message} left the game", allowed_mentions=discord.AllowedMentions.none())
|
||||||
|
|
||||||
achievement_message = await check_if_achievement_message(content)
|
achievement_message = await check_if_achievement_message(content)
|
||||||
if achievement_message:
|
if achievement_message:
|
||||||
channel = coginstance.bot.get_channel(await config.chat_channel())
|
if chat_channel is not None:
|
||||||
if channel is not None:
|
if coginstance.bot.embed_requested(chat_channel):
|
||||||
if coginstance.bot.embed_requested(channel):
|
await chat_channel.send(embed=await generate_achievement_embed(achievement_message['username'], achievement_message['achievement'], achievement_message['challenge']))
|
||||||
await channel.send(embed=await generate_achievement_embed(achievement_message['username'], achievement_message['achievement'], achievement_message['challenge']))
|
|
||||||
else:
|
else:
|
||||||
await channel.send(f"{achievement_message['username']} has {'completed the challenge' if achievement_message['challenge'] else 'made the advancement'} {achievement_message['achievement']}")
|
await chat_channel.send(f"{achievement_message['username']} has {'completed the challenge' if achievement_message['challenge'] else 'made the advancement'} {achievement_message['achievement']}")
|
||||||
|
|
||||||
if message['event'] == 'status':
|
if message['event'] == 'status':
|
||||||
old_status = await config.current_status()
|
old_status = await config.current_status()
|
||||||
|
|
|
@ -5,20 +5,21 @@ description = "My assorted cogs for Red-DiscordBot."
|
||||||
authors = ["SeaswimmerTheFsh"]
|
authors = ["SeaswimmerTheFsh"]
|
||||||
license = "MPL 2"
|
license = "MPL 2"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.11,<3.12"
|
python = ">=3.11,<3.12"
|
||||||
Red-DiscordBot = "^3.5.5"
|
Red-DiscordBot = "^3.5.5"
|
||||||
pytimeparse2 = "^1.7.1"
|
|
||||||
humanize = "^4.8.0"
|
|
||||||
py-dactyl = "^2.0.4"
|
py-dactyl = "^2.0.4"
|
||||||
websockets = "^12.0"
|
websockets = "^12.0"
|
||||||
|
pillow = "^10.3.0"
|
||||||
|
numpy = "^1.26.4"
|
||||||
|
|
||||||
[tool.poetry.group.dev]
|
[tool.poetry.group.dev]
|
||||||
optional = true
|
optional = true
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
ruff = "^0.2.1"
|
ruff = "^0.3.1"
|
||||||
pylint = "^3.1.0"
|
pylint = "^3.1.0"
|
||||||
|
|
||||||
[tool.poetry.group.docs]
|
[tool.poetry.group.docs]
|
||||||
|
|
Loading…
Reference in a new issue