feat(repo): make all cogs pylance-typechecking compliant
Some checks failed
Actions / Lint Code (Ruff & Pylint) (push) Failing after 43s
Actions / Build Documentation (MkDocs) (push) Failing after 24s

at `basic` level, does not include Aurora as it's being rewritten in the `aurora/v3` branch
This commit is contained in:
cswimr 2025-02-01 16:57:45 +00:00
parent ea0b7937f8
commit 2a5b924409
Signed by: cswimr
GPG key ID: 0EC431A8DA8F8087
11 changed files with 184 additions and 139 deletions

15
.vscode/settings.json vendored
View file

@ -11,11 +11,22 @@
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"editor.formatOnSave": true,
"files.exclude": {
"**/.git": true,
"**/__pycache__": true,
"**/.ruff_cache": true,
"**/.mypy_cache": true
}
},
"python.analysis.diagnosticSeverityOverrides": {
"reportAttributeAccessIssue": false, // disabled because `commands.group.command` is listed as Any / Unknown for some reason
"reportCallIssue": "information"
},
"python.analysis.diagnosticMode": "workspace",
"python.analysis.supportDocstringTemplate": true,
"python.analysis.typeCheckingMode": "basic",
"python.analysis.typeEvaluation.enableReachabilityAnalysis": true,
"python.analysis.typeEvaluation.strictDictionaryInference": true,
"python.analysis.typeEvaluation.strictListInference": true,
"python.analysis.typeEvaluation.strictSetInference": true,
"editor.formatOnSave": true,
}

View file

@ -17,7 +17,7 @@ class AntiPolls(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.0.2"
__version__ = "1.0.3"
__documentation__ = "https://seacogs.coastalcommits.com/antipolls/"
def __init__(self, bot: Red):
@ -82,7 +82,7 @@ class AntiPolls(commands.Cog):
return self.logger.trace("Deleted poll message %s", message.id)
return self.logger.verbose("Message %s is not a poll, ignoring", message.id)
@commands.group(name="antipolls", aliases=["ap"])
@commands.group(name="antipolls", aliases=["ap"]) # type: ignore
@commands.guild_only()
@commands.admin_or_permissions(manage_guild=True)
async def antipolls(self, ctx: commands.Context) -> None:
@ -95,6 +95,8 @@ class AntiPolls(commands.Cog):
@antipolls_roles.command(name="add")
async def antipolls_roles_add(self, ctx: commands.Context, *roles: discord.Role) -> None:
"""Add roles to the whitelist."""
assert ctx.guild is not None # using `assert` here and in the rest of this file to satisfy typecheckers
# this is safe because the commands are part of a guild-only command group
async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist:
role_whitelist: list
failed: list[discord.Role] = []
@ -110,6 +112,7 @@ class AntiPolls(commands.Cog):
@antipolls_roles.command(name="remove")
async def antipolls_roles_remove(self, ctx: commands.Context, *roles: discord.Role) -> None:
"""Remove roles from the whitelist."""
assert ctx.guild is not None
async with self.config.guild(ctx.guild).role_whitelist() as role_whitelist:
role_whitelist: list
failed: list[discord.Role] = []
@ -125,10 +128,11 @@ class AntiPolls(commands.Cog):
@antipolls_roles.command(name="list")
async def antipolls_roles_list(self, ctx: commands.Context) -> discord.Message:
"""List roles in the whitelist."""
assert ctx.guild is not None
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]
roles = [role for role in (ctx.guild.get_role(role) for role in role_whitelist) if role is not None]
return await ctx.send(humanize_list([role.mention for role in roles]))
@antipolls.group(name="channels")
@ -138,6 +142,7 @@ class AntiPolls(commands.Cog):
@antipolls_channels.command(name="add")
async def antipolls_channels_add(self, ctx: commands.Context, *channels: discord.TextChannel) -> None:
"""Add channels to the whitelist."""
assert ctx.guild is not None
async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist:
channel_whitelist: list
failed: list[discord.TextChannel] = []
@ -153,6 +158,7 @@ class AntiPolls(commands.Cog):
@antipolls_channels.command(name="remove")
async def antipolls_channels_remove(self, ctx: commands.Context, *channels: discord.TextChannel) -> None:
"""Remove channels from the whitelist."""
assert ctx.guild is not None
async with self.config.guild(ctx.guild).channel_whitelist() as channel_whitelist:
channel_whitelist: list
failed: list[discord.TextChannel] = []
@ -168,14 +174,19 @@ class AntiPolls(commands.Cog):
@antipolls_channels.command(name="list")
async def antipolls_channels_list(self, ctx: commands.Context) -> discord.Message:
"""List channels in the whitelist."""
assert ctx.guild is not None
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]
channels = [channel for channel in (ctx.guild.get_channel(channel) for channel in channel_whitelist) if channel is not None]
for c in channels:
if not c:
channels.remove(c)
return 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."""
assert ctx.guild is not None
await self.config.guild(ctx.guild).manage_messages.set(enabled)
await ctx.tick()

View file

@ -26,7 +26,7 @@ class Backup(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.1.2"
__version__ = "1.1.3"
__documentation__ = "https://seacogs.coastalcommits.com/backup/"
def __init__(self, bot: Red):
@ -45,14 +45,15 @@ class Backup(commands.Cog):
]
return "\n".join(text)
@commands.group(autohelp=True)
@commands.group(autohelp=True) # type: ignore
@commands.is_owner()
async def backup(self, ctx: commands.Context):
async def backup(self, ctx: commands.Context) -> None:
"""Backup your installed cogs."""
pass
@backup.command(name="export")
@commands.is_owner()
async def backup_export(self, ctx: commands.Context):
async def backup_export(self, ctx: commands.Context) -> None:
"""Export your installed repositories and cogs to a file."""
downloader = ctx.bot.get_cog("Downloader")
if downloader is None:
@ -91,13 +92,13 @@ class Backup(commands.Cog):
@backup.command(name="import")
@commands.is_owner()
async def backup_import(self, ctx: commands.Context):
async def backup_import(self, ctx: commands.Context) -> None:
"""Import your installed repositories and cogs from an export file."""
try:
export = json.loads(await ctx.message.attachments[0].read())
except (json.JSONDecodeError, IndexError):
try:
export = json.loads(await ctx.message.reference.resolved.attachments[0].read())
export = json.loads(await ctx.message.reference.resolved.attachments[0].read()) # type: ignore - this is fine to let error because it gets handled
except (json.JSONDecodeError, IndexError, AttributeError):
await ctx.send(error("Please provide a valid JSON export file."))
return

View file

@ -27,7 +27,7 @@ class Bible(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.1.3"
__version__ = "1.1.4"
__documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/"
def __init__(self, bot: Red):
@ -145,6 +145,7 @@ class Bible(commands.Cog):
if response.status == 503:
raise bible.errors.ServiceUnavailableError
assert self.bot.user is not None # bot will always be logged in
fums_url = "https://fums.api.bible/f3"
fums_params = {
"t": data["meta"]["fumsToken"],
@ -246,9 +247,9 @@ class Bible(commands.Cog):
from_verse, to_verse = passage.replace(":", ".").split("-")
if "." not in to_verse:
to_verse = f"{from_verse.split('.')[0]}.{to_verse}"
passage = await self._get_passage(ctx, bible_id, f"{book_id}.{from_verse}-{book_id}.{to_verse}", True)
retrieved_passage = await self._get_passage(ctx, bible_id, f"{book_id}.{from_verse}-{book_id}.{to_verse}", True)
else:
passage = await self._get_passage(ctx, bible_id, f"{book_id}.{passage.replace(':', '.')}", False)
retrieved_passage = await self._get_passage(ctx, bible_id, f"{book_id}.{passage.replace(':', '.')}", False)
except (
bible.errors.BibleAccessError,
bible.errors.NotFoundError,
@ -259,21 +260,21 @@ class Bible(commands.Cog):
await ctx.send(e.message)
return
if len(passage["content"]) > 4096:
if len(retrieved_passage["content"]) > 4096:
await ctx.send("The passage is too long to send.")
return
if await ctx.embed_requested():
icon = self.get_icon(await ctx.embed_color())
embed = Embed(
title=f"{passage['reference']}",
description=passage["content"].replace("", ""),
title=f"{retrieved_passage['reference']}",
description=retrieved_passage["content"].replace("", ""),
color=await ctx.embed_color(),
)
embed.set_footer(text=f"{ctx.prefix}bible passage - Powered by API.Bible - {version.abbreviation_local} ({version.language_local}, {version.description_local})", icon_url="attachment://icon.png")
await ctx.send(embed=embed, file=icon)
else:
await ctx.send(f"## {passage['reference']}\n{passage['content']}")
await ctx.send(f"## {retrieved_passage['reference']}\n{retrieved_passage['content']}")
@bible.command(name="random")
async def bible_random(self, ctx: commands.Context):

View file

@ -16,7 +16,7 @@ class EmojiInfo(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.0.2"
__version__ = "1.0.3"
__documentation__ = "https://seacogs.coastalcommits.com/emojiinfo/"
def __init__(self, bot: Red) -> None:
@ -69,7 +69,7 @@ class EmojiInfo(commands.Cog):
else:
emoji_id = ""
markdown = f"`{emoji}`"
name = f"{bold('Name:')} {emoji.aliases.pop(0)}\n"
name = f"{bold('Name:')} {emoji.aliases.pop(0) if emoji.aliases else emoji.name}\n"
aliases = f"{bold('Aliases:')} {', '.join(emoji.aliases)}\n" if emoji.aliases else ""
group = f"{bold('Group:')} {emoji.group}\n"
@ -82,15 +82,13 @@ class EmojiInfo(commands.Cog):
await interaction.response.defer(ephemeral=ephemeral)
try:
emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji)
(
string,
emoji_url,
) = await self.get_emoji_info(emoji)
retrieved_emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji)
string, emoji_url = await self.get_emoji_info(retrieved_emoji)
self.logger.verbose(f"Emoji:\n{string}")
except (IndexError, UnboundLocalError):
return await interaction.followup.send("Please provide a valid emoji!")
assert isinstance(interaction.channel, discord.TextChannel)
if await self.bot.embed_requested(channel=interaction.channel):
embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await self.bot.get_embed_color(interaction.channel))
embed.set_thumbnail(url=emoji_url)
@ -104,20 +102,18 @@ class EmojiInfo(commands.Cog):
async def emoji(self, ctx: commands.Context, *, emoji: str) -> None:
"""Retrieve information about an emoji."""
try:
emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji)
(
string,
emoji_url,
) = await self.get_emoji_info(emoji)
retrieved_emoji: PartialEmoji = PartialEmoji.from_str(self, value=emoji)
string, emoji_url = await self.get_emoji_info(retrieved_emoji)
self.logger.verbose(f"Emoji:\n{string}")
except (IndexError, UnboundLocalError):
return await ctx.send("Please provide a valid emoji!")
await ctx.send("Please provide a valid emoji!")
return
if await ctx.embed_requested():
embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await ctx.embed_color)
embed = discord.Embed(title="Emoji Information", description=string, color=await self.fetch_primary_color(emoji_url) or await ctx.embed_color())
embed.set_thumbnail(url=emoji_url)
await ctx.send(embed=embed)
return None
return
await ctx.send(content=string)
return None
return

View file

@ -77,7 +77,7 @@ class PartialEmoji(discord.PartialEmoji):
name = groups["name"]
return cls(name=name, animated=animated, id=emoji_id)
path: data_manager.Path = data_manager.bundled_data_path(coginstance) / "emojis.json"
path = data_manager.bundled_data_path(coginstance) / "emojis.json"
with open(path, "r", encoding="UTF-8") as file:
emojis: dict = json.load(file)
emoji_aliases = []

View file

@ -2,18 +2,18 @@ import py_compile
from asyncio import run_coroutine_threadsafe
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, List, Sequence, Tuple
from typing import Generator, List, Sequence
import discord
from red_commons.logging import RedTraceLogger, getLogger
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.core_commands import CoreLogic
from redbot.core.utils.chat_formatting import bold, box, humanize_list
from typing_extensions import override
from watchdog.events import FileSystemEvent, FileSystemMovedEvent, RegexMatchingEventHandler
from watchdog.observers import Observer
if TYPE_CHECKING:
from watchdog.observers import ObserverType
from watchdog.observers.api import BaseObserver
class HotReload(commands.Cog):
@ -21,24 +21,26 @@ class HotReload(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.4.0"
__version__ = "1.4.1"
__documentation__ = "https://seacogs.coastalcommits.com/hotreload/"
def __init__(self, bot: Red) -> None:
super().__init__()
self.bot: Red = bot
self.config = Config.get_conf(self, identifier=294518358420750336, force_registration=True)
self.config: Config = Config.get_conf(cog_instance=self, identifier=294518358420750336, force_registration=True)
self.logger: RedTraceLogger = getLogger(name="red.SeaCogs.HotReload")
self.observers: List[ObserverType] = []
self.observers: List[BaseObserver] = []
self.config.register_global(notify_channel=None, compile_before_reload=False)
watchdog_loggers = [getLogger(name="watchdog.observers.inotify_buffer")]
for watchdog_logger in watchdog_loggers:
watchdog_logger.setLevel("INFO") # SHUT UP!!!!
@override
async def cog_load(self) -> None:
"""Start the observer when the cog is loaded."""
self.bot.loop.create_task(self.start_observer())
_ = self.bot.loop.create_task(self.start_observer())
@override
async def cog_unload(self) -> None:
"""Stop the observer when the cog is unloaded."""
for observer in self.observers:
@ -46,6 +48,7 @@ class HotReload(commands.Cog):
observer.join()
self.logger.info("Stopped observer. No longer watching for file changes.")
@override
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 ""
@ -57,7 +60,7 @@ class HotReload(commands.Cog):
]
return "\n".join(text)
async def get_paths(self) -> Tuple[Path]:
async def get_paths(self) -> Generator[Path]:
"""Retrieve user defined paths."""
cog_manager = self.bot._cog_mgr # noqa: SLF001 # We have to use this private method because there is no public API to get user defined paths
cog_paths = await cog_manager.user_defined_paths()
@ -79,7 +82,7 @@ class HotReload(commands.Cog):
self.logger.warning("Path %s does not exist. Skipping.", path)
continue
self.logger.debug("Adding observer schedule for path %s.", path)
observer.schedule(event_handler=HotReloadHandler(cog=self, path=path), path=path, recursive=True)
observer.schedule(event_handler=HotReloadHandler(cog=self, path=path), path=str(path), recursive=True)
observer.start()
self.logger.info("Started observer. Watching for file changes.")
is_first = False
@ -91,24 +94,24 @@ class HotReload(commands.Cog):
pass
@hotreload_group.command(name="notifychannel")
async def hotreload_notifychannel(self, ctx: commands.Context, channel: commands.TextChannelConverter) -> None:
async def hotreload_notifychannel(self, ctx: commands.Context, channel: discord.TextChannel) -> None:
"""Set the channel to send notifications to."""
await self.config.notify_channel.set(channel.id)
await ctx.send(f"Notifications will be sent to {channel.mention}.")
@hotreload_group.command(name="compile")
@hotreload_group.command(name="compile") # type: ignore
async def hotreload_compile(self, ctx: commands.Context, compile_before_reload: bool) -> None:
"""Set whether to compile modified files before reloading."""
await self.config.compile_before_reload.set(compile_before_reload)
await ctx.send(f"I {'will' if compile_before_reload else 'will not'} compile modified files before hotreloading cogs.")
@hotreload_group.command(name="list")
@hotreload_group.command(name="list") # type: ignore
async def hotreload_list(self, ctx: commands.Context) -> None:
"""List the currently active observers."""
if not self.observers:
await ctx.send("No observers are currently active.")
return
await ctx.send(f"Currently active observers (If there are more than one of these, report an issue): {box(humanize_list(self.observers, style='unit'))}")
await ctx.send(f"Currently active observers (If there are more than one of these, report an issue): {box(humanize_list([str(o) for o in self.observers], style='unit'))}")
class HotReloadHandler(RegexMatchingEventHandler):
@ -129,13 +132,13 @@ class HotReloadHandler(RegexMatchingEventHandler):
if event.event_type not in allowed_events:
return
relative_src_path = Path(event.src_path).relative_to(self.path)
relative_src_path = Path(str(event.src_path)).relative_to(self.path)
src_package_name = relative_src_path.parts[0]
cogs_to_reload = [src_package_name]
if isinstance(event, FileSystemMovedEvent):
dest = f" to {event.dest_path}"
relative_dest_path = Path(event.dest_path).relative_to(self.path)
relative_dest_path = Path(str(event.dest_path)).relative_to(self.path)
dest_package_name = relative_dest_path.parts[0]
if dest_package_name != src_package_name:
cogs_to_reload.append(dest_package_name)
@ -147,7 +150,7 @@ class HotReloadHandler(RegexMatchingEventHandler):
run_coroutine_threadsafe(
coro=self.reload_cogs(
cog_names=cogs_to_reload,
paths=[Path(p) for p in (event.src_path, getattr(event, "dest_path", None)) if p],
paths=[Path(str(p)) for p in (event.src_path, getattr(event, "dest_path", None)) if p],
),
loop=self.cog.bot.loop,
)
@ -163,7 +166,7 @@ class HotReloadHandler(RegexMatchingEventHandler):
self.logger.info("Reloaded cogs: %s", humanize_list(cog_names, style="unit"))
channel = self.cog.bot.get_channel(await self.cog.config.notify_channel())
if channel:
if channel and isinstance(channel, discord.TextChannel):
await channel.send(f"Reloaded cogs: {humanize_list(cog_names, style='unit')}")
def compile_modified_files(self, cog_names: Sequence[str], paths: Sequence[Path]) -> bool:
@ -176,7 +179,7 @@ class HotReloadHandler(RegexMatchingEventHandler):
try:
with NamedTemporaryFile() as temp_file:
self.logger.debug("Attempting to compile %s", path)
py_compile.compile(file=path, cfile=temp_file.name, doraise=True)
py_compile.compile(file=str(path), cfile=temp_file.name, doraise=True)
self.logger.debug("Successfully compiled %s", path)
except py_compile.PyCompileError as e:

View file

@ -37,16 +37,20 @@ class Nerdify(commands.Cog):
]
return "\n".join(text)
@commands.command(aliases=["nerd"])
async def nerdify(
self, ctx: commands.Context, *, text: Optional[str] = None,
self,
ctx: commands.Context,
*,
text: Optional[str] = None,
) -> None:
"""Nerdify the replied to message, previous message, or your own text."""
if not text:
if hasattr(ctx.message, "reference") and ctx.message.reference:
with suppress(
discord.Forbidden, discord.NotFound, discord.HTTPException,
discord.Forbidden,
discord.NotFound,
discord.HTTPException,
):
message_id = ctx.message.reference.message_id
if message_id:
@ -62,7 +66,9 @@ class Nerdify(commands.Cog):
ctx.channel,
self.nerdify_text(text),
allowed_mentions=discord.AllowedMentions(
everyone=False, users=False, roles=False,
everyone=False,
users=False,
roles=False,
),
)
@ -77,7 +83,10 @@ class Nerdify(commands.Cog):
return f'"{text}" 🤓'
async def type_message(
self, destination: discord.abc.Messageable, content: str, **kwargs: Any,
self,
destination: discord.abc.Messageable,
content: str,
**kwargs: Any,
) -> Union[discord.Message, None]:
"""Simulate typing and sending a message to a destination.

View file

@ -1,6 +1,6 @@
import asyncio
import json
from typing import Mapping, Optional, Tuple, Union
from typing import AsyncIterable, Iterable, Mapping, Optional, Tuple, Union
import discord
import websockets
@ -9,8 +9,9 @@ from pydactyl import PterodactylClient
from redbot.core import app_commands, commands
from redbot.core.app_commands import Choice
from redbot.core.bot import Red
from redbot.core.utils.chat_formatting import bold, box, error, humanize_list
from redbot.core.utils.chat_formatting import bold, box, humanize_list
from redbot.core.utils.views import ConfirmView
from typing_extensions import override
from pterodactyl import mcsrvstatus
from pterodactyl.config import config, register_config
@ -22,7 +23,7 @@ class Pterodactyl(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "2.0.5"
__version__ = "2.0.6"
__documentation__ = "https://seacogs.coastalcommits.com/pterodactyl/"
def __init__(self, bot: Red):
@ -32,9 +33,10 @@ class Pterodactyl(commands.Cog):
self.websocket: Optional[websockets.ClientConnection] = None
self.retry_counter: int = 0
register_config(config)
self.task = self.get_task()
self.task = self._get_task()
self.update_topic.start()
@override
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 ""
@ -46,50 +48,57 @@ class Pterodactyl(commands.Cog):
]
return "\n".join(text)
@override
async def cog_load(self) -> None:
pterodactyl_keys = await self.bot.get_shared_api_tokens("pterodactyl")
api_key = pterodactyl_keys.get("api_key")
if api_key is None:
self.task.cancel()
self.maybe_cancel_task()
logger.error("Pterodactyl API key not set. Please set it using `[p]set api`.")
return
base_url = await config.base_url()
if base_url is None:
self.task.cancel()
self.maybe_cancel_task()
logger.error("Pterodactyl base URL not set. Please set it using `[p]pterodactyl config url`.")
return
server_id = await config.server_id()
if server_id is None:
self.task.cancel()
self.maybe_cancel_task()
logger.error("Pterodactyl server ID not set. Please set it using `[p]pterodactyl config serverid`.")
return
self.client = PterodactylClient(base_url, api_key).client
@override
async def cog_unload(self) -> None:
self.update_topic.cancel()
self.maybe_cancel_task()
def maybe_cancel_task(self, reset_retry_counter: bool = True) -> None:
if self.task:
self.task.cancel()
if reset_retry_counter:
self.retry_counter = 0
def get_task(self) -> asyncio.Task:
def _get_task(self) -> asyncio.Task:
from pterodactyl.websocket import establish_websocket_connection
task = self.bot.loop.create_task(establish_websocket_connection(self), name="Pterodactyl Websocket Connection")
task.add_done_callback(self.error_callback)
task.add_done_callback(self._error_callback)
return task
def error_callback(self, fut) -> None: # NOTE Thanks flame442 and zephyrkul for helping me figure this out
def _error_callback(self, fut) -> None: # NOTE Thanks flame442 and zephyrkul for helping me figure this out
try:
fut.result()
except asyncio.CancelledError:
logger.info("WebSocket task has been cancelled.")
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("WebSocket task has failed: %s", e, exc_info=e)
self.task.cancel()
self.maybe_cancel_task(reset_retry_counter=False)
if self.retry_counter < 5:
self.retry_counter += 1
logger.info("Retrying in %s seconds...", 5 * self.retry_counter)
self.task = self.bot.loop.call_later(5 * self.retry_counter, self.get_task)
self.task = self.bot.loop.call_later(5 * self.retry_counter, self._get_task)
else:
logger.info("Retry limit reached. Stopping task.")
@ -100,9 +109,9 @@ class Pterodactyl(commands.Cog):
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)
await console.edit(topic=topic) # type: ignore
if chat:
await chat.edit(topic=topic)
await chat.edit(topic=topic) # type: ignore
@commands.Cog.listener()
async def on_message_without_command(self, message: discord.Message) -> None:
@ -113,13 +122,7 @@ class Pterodactyl(commands.Cog):
return
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]}", allowed_mentions=discord.AllowedMentions.none())
try:
await self.websocket.send(json.dumps({"event": "send command", "args": [message.content]}))
except websockets.exceptions.ConnectionClosed as e:
logger.error("WebSocket connection closed: %s", e)
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
await self._send(json.dumps({"event": "send command", "args": [message.content]}))
if message.channel.id == await config.chat_channel() and message.author.bot is False:
logger.debug("Received chat message from %s: %s", message.author.id, message.content)
channel = self.bot.get_channel(await config.console_channel())
@ -127,13 +130,22 @@ class Pterodactyl(commands.Cog):
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)]})
logger.debug("Sending chat message to server:\n%s", msg)
await self._send(message=msg)
async def _send(self, message: Union[websockets.Data, Iterable[websockets.Data], AsyncIterable[websockets.Data]], text: bool = False):
"""Send a message through the websocket connection. Restarts the websocket connection task if it is closed, and reinvokes itself."""
try:
await self.websocket.send(msg)
await self.websocket.send(message=message, text=text) # type: ignore - we want this to error if `self.websocket` is none
except websockets.exceptions.ConnectionClosed as e:
logger.error("WebSocket connection closed: %s", e)
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
self.maybe_cancel_task()
self.task = self._get_task()
try:
await asyncio.wait_for(fut=self.task, timeout=60)
await self._send(message=message, text=text)
except asyncio.TimeoutError:
logger.error("Timeout while waiting for websocket connection")
raise
async def get_topic(self) -> str:
topic: str = await config.topic()
@ -193,7 +205,7 @@ class Pterodactyl(commands.Cog):
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:
if player_list and isinstance(ctx.channel, discord.abc.Messageable):
embed = discord.Embed(color=await self.bot.get_embed_color(ctx.channel), title="Players Online")
embed.description = player_list[0]
return embed
@ -206,10 +218,12 @@ class Pterodactyl(commands.Cog):
current_status = await config.current_status()
if current_status == action_ing:
return await ctx.send(f"Server is already {action_ing}.", ephemeral=True)
await ctx.send(f"Server is already {action_ing}.", ephemeral=True)
return
if current_status in ["starting", "stopping"] and action != "kill":
return await ctx.send("Another power action is already in progress.", ephemeral=True)
await ctx.send("Another power action is already in progress.", ephemeral=True)
return
view = ConfirmView(ctx.author, disable_buttons=True)
@ -220,13 +234,13 @@ class Pterodactyl(commands.Cog):
if view.result is True:
await message.edit(content=f"Sending websocket command to {action} server...", view=None)
await self.websocket.send(json.dumps({"event": "set state", "args": [action]}))
await self._websocket_send(json.dumps({"event": "set state", "args": [action]}))
await message.edit(content=f"Server {action_ing}", view=None)
return None
return
await message.edit(content="Cancelled.", view=None)
return None
return
async def send_command(self, ctx: Union[discord.Interaction, commands.Context], command: str):
channel = self.bot.get_channel(await config.console_channel())
@ -234,23 +248,15 @@ class Pterodactyl(commands.Cog):
ctx = await self.bot.get_context(ctx)
if channel:
await channel.send(f"Received console command from {ctx.author.id}: {command[:1900]}", allowed_mentions=discord.AllowedMentions.none())
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')}")
except websockets.exceptions.ConnectionClosed as e:
logger.error("WebSocket connection closed: %s", e)
await ctx.send(error("WebSocket connection closed."))
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
@commands.Cog.listener()
async def on_red_api_tokens_update(self, service_name: str, api_tokens: Mapping[str, str]): # pylint: disable=unused-argument
if service_name == "pterodactyl":
logger.info("Configuration value set: api_key\nRestarting task...")
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
self.maybe_cancel_task(reset_retry_counter=True)
self.task = self._get_task()
slash_pterodactyl = app_commands.Group(name="pterodactyl", description="Pterodactyl allows you to manage your Pterodactyl Panel from Discord.")
@ -346,9 +352,8 @@ class Pterodactyl(commands.Cog):
await config.base_url.set(base_url)
await ctx.send(f"Base URL set to {base_url}")
logger.info("Configuration value set: base_url = %s\nRestarting task...", base_url)
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
self.maybe_cancel_task(reset_retry_counter=True)
self.task = self._get_task()
@pterodactyl_config.command(name="serverid")
async def pterodactyl_config_server_id(self, ctx: commands.Context, *, server_id: str) -> None:
@ -356,9 +361,8 @@ class Pterodactyl(commands.Cog):
await config.server_id.set(server_id)
await ctx.send(f"Server ID set to {server_id}")
logger.info("Configuration value set: server_id = %s\nRestarting task...", server_id)
self.task.cancel()
self.retry_counter = 0
self.task = self.get_task()
self.maybe_cancel_task(reset_retry_counter=True)
self.task = self._get_task()
@pterodactyl_config.group(name="console")
async def pterodactyl_config_console(self, ctx: commands.Context):

View file

@ -2,7 +2,7 @@
import json
import re
from pathlib import Path
from typing import Optional, Tuple, Union
from typing import Any, Optional, Tuple, Union
import aiohttp
import discord
@ -56,7 +56,9 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
content = mask_ip(content)
console_channel = coginstance.bot.get_channel(await config.console_channel())
assert isinstance(console_channel, discord.abc.Messageable)
chat_channel = coginstance.bot.get_channel(await config.chat_channel())
assert isinstance(chat_channel, discord.abc.Messageable)
if console_channel is not None:
if content.startswith("["):
pagified_content = pagify(content, delims=[" ", "\n"])
@ -83,7 +85,7 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
embed, img = await generate_join_leave_embed(coginstance=coginstance, username=join_message, join=True)
if img:
with open(img, "rb") as file:
await chat_channel.send(embed=embed, file=file)
await chat_channel.send(embed=embed, file=discord.File(fp=file))
else:
await chat_channel.send(embed=embed)
else:
@ -96,7 +98,7 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
embed, img = await generate_join_leave_embed(coginstance=coginstance, username=leave_message, join=False)
if img:
with open(img, "rb") as file:
await chat_channel.send(embed=embed, file=file)
await chat_channel.send(embed=embed, file=discord.File(fp=file))
else:
await chat_channel.send(embed=embed)
else:
@ -106,7 +108,11 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
if achievement_message:
if chat_channel is not None:
if coginstance.bot.embed_requested(chat_channel):
await chat_channel.send(embed=await generate_achievement_embed(coginstance, achievement_message["username"], achievement_message["achievement"], achievement_message["challenge"]))
embed, img = await generate_achievement_embed(coginstance, achievement_message["username"], achievement_message["achievement"], achievement_message["challenge"])
if img:
await chat_channel.send(embed=embed, file=discord.File(fp=img))
else:
await chat_channel.send(embed=embed)
else:
await chat_channel.send(f"{achievement_message['username']} has {'completed the challenge' if achievement_message['challenge'] else 'made the advancement'} {achievement_message['achievement']}")
@ -130,24 +136,27 @@ async def establish_websocket_connection(coginstance: Pterodactyl) -> None:
await chat.send(await config.shutdown_msg())
async def retrieve_websocket_credentials(coginstance: Pterodactyl) -> Optional[dict]:
async def retrieve_websocket_credentials(coginstance: Pterodactyl) -> dict:
pterodactyl_keys = await coginstance.bot.get_shared_api_tokens("pterodactyl")
api_key = pterodactyl_keys.get("api_key")
if api_key is None:
coginstance.task.cancel()
coginstance.maybe_cancel_task()
raise ValueError("Pterodactyl API key not set. Please set it using `[p]set api`.")
base_url = await config.base_url()
if base_url is None:
coginstance.task.cancel()
coginstance.maybe_cancel_task()
raise ValueError("Pterodactyl base URL not set. Please set it using `[p]pterodactyl config url`.")
server_id = await config.server_id()
if server_id is None:
coginstance.task.cancel()
coginstance.maybe_cancel_task()
raise ValueError("Pterodactyl server ID not set. Please set it using `[p]pterodactyl config serverid`.")
client = PterodactylClient(base_url, api_key).client
coginstance.client = client
websocket_credentials = client.servers.get_websocket(server_id)
websocket_credentials: dict[str, Any] = client.servers.get_websocket(server_id).json()
if not websocket_credentials:
coginstance.maybe_cancel_task()
raise ValueError("Failed to retrieve websocket credentials. Please ensure the API details are correctly configured.")
logger.debug(
"""Websocket connection details retrieved:
Socket: %s
@ -165,44 +174,44 @@ def remove_ansi_escape_codes(text: str) -> str:
return ansi_escape.sub("", text)
async def check_if_server_message(text: str) -> Union[bool, str]:
async def check_if_server_message(text: str) -> Optional[str]:
regex = await config.server_regex()
match: Optional[re.Match[str]] = re.match(regex, text)
if match:
logger.trace("Message is a server message")
return match.group(1)
return False
return None
async def check_if_chat_message(text: str) -> Union[bool, dict]:
async def check_if_chat_message(text: str) -> Optional[dict]:
regex = await config.chat_regex()
match: Optional[re.Match[str]] = re.match(regex, text)
if match:
groups = {"username": match.group(1), "message": match.group(2)}
logger.trace("Message is a chat message\n%s", json.dumps(groups))
return groups
return False
return None
async def check_if_join_message(text: str) -> Union[bool, str]:
async def check_if_join_message(text: str) -> Optional[str]:
regex = await config.join_regex()
match: Optional[re.Match[str]] = re.match(regex, text)
if match:
logger.trace("Message is a join message")
return match.group(1)
return False
return None
async def check_if_leave_message(text: str) -> Union[bool, str]:
async def check_if_leave_message(text: str) -> Optional[str]:
regex = await config.leave_regex()
match: Optional[re.Match[str]] = re.match(regex, text)
if match:
logger.trace("Message is a leave message")
return match.group(1)
return False
return None
async def check_if_achievement_message(text: str) -> Union[bool, dict]:
async def check_if_achievement_message(text: str) -> Optional[dict]:
regex = await config.achievement_regex()
match: Optional[re.Match[str]] = re.match(regex, text)
if match:
@ -213,7 +222,7 @@ async def check_if_achievement_message(text: str) -> Union[bool, dict]:
groups["challenge"] = False
logger.trace("Message is an achievement message")
return groups
return False
return None
async def get_info(username: str) -> Optional[dict]:

View file

@ -42,7 +42,7 @@ class SeaUtils(commands.Cog):
__author__ = ["[cswimr](https://www.coastalcommits.com/cswimr)"]
__git__ = "https://www.coastalcommits.com/cswimr/SeaCogs"
__version__ = "1.0.1"
__version__ = "1.0.2"
__documentation__ = "https://seacogs.coastalcommits.com/seautils/"
def __init__(self, bot: Red) -> None:
@ -74,7 +74,7 @@ class SeaUtils(commands.Cog):
src = obj.function
return inspect.getsource(object=src)
@commands.command(aliases=["source", "src", "code", "showsource"])
@commands.command(aliases=["source", "src", "code", "showsource"]) # type: ignore
@commands.is_owner()
async def showcode(self, ctx: commands.Context, *, object: str) -> None: # pylint: disable=redefined-builtin # noqa: A002
"""Show the code for a particular object."""
@ -102,7 +102,7 @@ class SeaUtils(commands.Cog):
else:
await ctx.send(content="Object not found!", reference=ctx.message.to_reference(fail_if_not_exists=False))
@commands.command(name="dig", aliases=["dnslookup", "nslookup"])
@commands.command(name="dig", aliases=["dnslookup", "nslookup"]) # type: ignore
@commands.is_owner()
async def dig(self, ctx: commands.Context, name: str, record_type: str | None = None, server: str | None = None, port: int = 53) -> None:
"""Retrieve DNS information for a domain.
@ -110,7 +110,7 @@ class SeaUtils(commands.Cog):
Uses `dig` to perform a DNS query. Will fall back to `nslookup` if `dig` is not installed on the system.
`nslookup` does not provide as much information as `dig`, so only the `name` parameter will be used if `nslookup` is used.
Will return the A, AAAA, and CNAME records for a domain by default. You can specify a different record type with the `type` parameter."""
command_opts: list[str | int] = ["dig"]
command_opts: list[str] = ["dig"]
query_types: list[str] = [record_type] if record_type else ["A", "AAAA", "CNAME"]
if server:
command_opts.extend(["@", server])
@ -176,7 +176,7 @@ class SeaUtils(commands.Cog):
embed.add_field(name="Authority Section", value=f"{cf.box(text=authority_section, lang='prolog')}", inline=False)
await ctx.send(embed=embed)
else:
await ctx.send(content=cf.box(text=stdout, lang="yaml"))
await ctx.send(content=cf.box(text=str(stdout), lang="yaml"))
except FileNotFoundError:
try:
ns_process = await asyncio.create_subprocess_exec("nslookup", name, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
@ -208,7 +208,7 @@ class SeaUtils(commands.Cog):
html = await response.text()
soup = BeautifulSoup(html, "html.parser")
pre_tags = soup.find_all("pre")
content: list[Embed | str] = []
content: list[str | Embed] = []
for pre_tag in pre_tags:
text = format_rfc_text(md(pre_tag), number)
if len(text) > 4096:
@ -227,6 +227,6 @@ class SeaUtils(commands.Cog):
if await ctx.embed_requested():
for embed in content:
embed.set_footer(text=f"Page {content.index(embed) + 1}/{len(content)}")
await SimpleMenu(pages=content, disable_after_timeout=True, timeout=300).start(ctx)
await SimpleMenu(pages=content, disable_after_timeout=True, timeout=300).start(ctx) # type: ignore
else:
await ctx.maybe_send_embed(message=cf.error(f"An error occurred while fetching RFC {number}. Status code: {response.status}."))