diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..36c40f6 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +extend-ignore = + Q0, E501, C812, E203, W503, # These default to arguing with Black. We might configure some of them eventually + ANN002, ANN003, # Ignore *args, **kwargs + ANN1, # Ignore self and cls annotations + ANN204, ANN206, # return annotations for special methods and class methods + D105, D107, # Missing Docstrings in magic method and __init__ + S311, # Standard pseudo-random generators are not suitable for security/cryptographic purposes. + D401, # First line should be in imperative mood; try rephrasing + D400, # First line should end with a period + D101, # Missing docstring in public class + + # Plugins we don't currently include: flake8-return + R502, # do not implicitly return None in function able to return non-None value. + R503, # missing explicit return at the end of function ableto return non-None value. +max-line-length=100 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e102ae9..a2e9a36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,15 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-toml - id: check-yaml + args: [--unsafe] - id: check-merge-conflict - id: requirements-txt-fixer - id: end-of-file-fixer + - id: debug-statements + language_version: python3.10 - id: trailing-whitespace args: [--markdown-linebreak-ext=md] @@ -16,23 +19,31 @@ repos: - id: python-check-blanket-noqa - repo: https://github.com/psf/black - rev: 21.7b0 + rev: 22.3.0 hooks: - id: black - args: [--line-length=120] + args: [--line-length=100, --target-version=py310] + language_version: python3.10 - repo: https://github.com/pre-commit/mirrors-isort - rev: V5.9.3 + rev: v5.10.1 hooks: - id: isort args: ["--profile", "black"] - repo: https://github.com/pycqa/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 additional_dependencies: - flake8-annotations~=2.0 - - flake8-bandit~=2.1 + #- flake8-bandit~=2.1 - flake8-docstrings~=1.5 - args: [--max-line-length=120, --ignore=ANN101 D107 ANN102 ANN206 D105 ANN204] + - flake8-bugbear + - flake8-comprehensions + - flake8-quotes + - flake8-raise + - flake8-deprecated + - flake8-print + - flake8-return + language_version: python3.10 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a5300f --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice. + +Software: JARVIS + +License: Expat License + +Licensor: zevaryx diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..9c77c7e --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,55 @@ +# Privacy Policy +Your privacy is important to us. It is JARVIS' policy to respect your privacy and comply with any applicable law and regulation regarding any personal information we may collect about you through our JARVIS bot. + +This policy is effective as of 20 March 2022 and was last updated on 10 March 2022. + +## Information We Collect +Information we collect includes both information you knowingly and actively provide us when using or participating in any of our services and promotions, and any information automatically sent by your devices in the course of accessing our products and services. + +## Log Data +When you use our JARVIS services, if opted in to usage data collection services, we may collect data in certain cicumstances: + +- Administrative activity (i.e. ban, warn, mute) + - Your User ID (either as admin or as the recipient of the activity) + - Guild ID of activity origin + - Your discriminator at time of activity (bans only) + - Your username at time of activity (bans only) +- Admin commands + - User ID of admin who executes admin command +- Reminders + - Your User ID + - The guild in which the command originated + - The channel in which the command originated + - Private text entered via the command +- Starboard + - Message ID of starred message + - Channel ID of starred message + - Guild ID of origin guild +- Automated activity logging + - We store no information about users who edit nicknames, join/leave servers, or other related activities. However, this information, if configured by server admins, is relayed into a Discord channel and is not automatically deleted, nor do we have control over this information. + - This information is also stored by Discord via their Audit Log, which we also have no control over. Please contact Discord for their own privacy policy and asking about your rights on their platform. + +## Use of Information +We use the information we collect to provide, maintain, and improve our services. Common uses where this data may be used includes sending reminders, helping administrate Discord servers, and providing extra utilities into Discord based on user content. + +## Security of Your Personal Information +Although we will do our best to protect the personal information you provide to us, we advise that no method of electronic transmission or storage is 100% secure, and no one can guarantee absolute data security. We will comply with laws applicable to us in respect of any data breach. + +## How Long We Keep Your Personal Information +We keep your personal information only for as long as we need to. This time period may depend on what we are using your information for, in accordance with this privacy policy. If your personal information is no longer required, we will delete it or make it anonymous by removing all details that identify you. + +## Your Rights and Controlling Your Personal Information +You may request access to your personal information, and change what you are okay with us collecting from you. You may also request that we delete your personal identifying information. Please message **zevaryx#5779** on Discord, or join the Discord server at https://discord.gg/4TuFvW5n and ask in there. + +## Limits of Our Policy +Our website may link to external sites that are not operated by us (ie. discord.com). Please be aware that we have no control over the content and policies of those sites, and cannot accept responsibility or liability for their respective privacy practices. + +## Changes to This Policy +At our discretion, we may change our privacy policy to reflect updates to our business processes, current acceptable practices, or legislative or regulatory changes. If we decide to change this privacy policy, we will post the changes here at the same link by which you are accessing this privacy policy. + +## Contact Us +For any questions or concerns regarding your privacy, you may contact us using the following details: + +### Discord +#### zevaryx#5779 +#### https://discord.gg/4TuFvW5n diff --git a/README.md b/README.md index 9f01850..2f0b019 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ +
+ J.A.R.V.I.S + +# Just Another Rather Very Intelligent System +
+ + [![python 3.8+](https://img.shields.io/badge/python-3.8+-blue)]() [![tokei lines of code](https://tokei.rs/b1/git.zevaryx.com/stark-industries/j.a.r.v.i.s.?category=code)](https://git.zevaryx.com/stark-industries/j.a.r.v.i.s.) [![discord chat widget](https://img.shields.io/discord/862402786116763668?style=social&logo=discord)](https://discord.gg/VtgZntXcnZ) +
-
-J.A.R.V.I.S - -# Just Another Very Intelligent System (J.A.R.V.I.S.) Welcome to the J.A.R.V.I.S. Initiative! While the main goal is to create the best discord bot there can be, a great achievement would be to present him to the Robots and have him integrated into the dbrand server. Feel free to suggest anything you may think to be useful… or cool. **Note:** Some commands have been custom made to be used in the dbrand server. @@ -34,19 +38,17 @@ If you wish to contribute to the J.A.R.V.I.S codebase or documentation, join the Join the [Stark R&D Department Discord server](https://discord.gg/VtgZntXcnZ) to be kept up-to-date on code updates and issues. ## Requirements -- MongoDB 4.4 or higher -- Python 3.8 or higher +- MongoDB 5.0 or higher +- Python 3.10 or higher - [tokei](https://github.com/XAMPPRocky/tokei) 12.1 or higher On top of the above requirements, the following pip packages are also required: -- `discord-py>=1.7, <2` +- `dis-snek>=5.0.0` - `psutil>=5.8, <6` - `GitPython>=3.1, <4` - `PyYaml>=5.4, <6` -- `discord-py-slash-command>=2.3.2, <3` - `pymongo>=3.12.0, <4` - `opencv-python>=4.5, <5` -- `ButtonPaginator>=0.0.3` - `Pillow>=8.2.0, <9` - `python-gitlab>=2.9.0, <3` - `ulid-py>=1.1.0, <2` diff --git a/TERMS.md b/TERMS.md new file mode 100644 index 0000000..4182214 --- /dev/null +++ b/TERMS.md @@ -0,0 +1,15 @@ +# Terms Of Use +Please just be reasonable do not try to mess with the bot in a malicious way. This includes but is not limited to: + +- Spamming +- Flooding +- Hacking +- DOS Attacks + +## Contact Us +For any questions or concerns regarding the terms, feel free to contact us: + +### Discord + +#### zevaryx#5779 +#### https://discord.gg/4TuFvW5n diff --git a/config.example.yaml b/config.example.yaml index cfa17f6..6aa129a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,24 +1,34 @@ --- - token: api key here - client_id: 123456789012345678 - logo: alligator2 + token: bot token + twitter: + consumer_key: key + consumer_secret: secret + access_token: access token + access_secret: access secret mongo: connect: - username: user - password: pass - host: localhost + username: username + password: password + host: hostname port: 27017 database: database urls: - url_name: url - url_name2: url2 - max_messages: 1000 - gitlab_token: null + extra: urls + max_messages: 10000 + gitlab_token: token cogs: - - list - - of - - enabled - - cogs - - all - - if - - empty + - admin + - autoreact + - dev + - image + - gl + - remindme + - rolegiver + # - settings + - starboard + - twitter + - util + - verify + log_level: INFO + sync: false + #sync_commands: True diff --git a/jarvis.png b/jarvis.png index cf2071a..8164087 100644 Binary files a/jarvis.png and b/jarvis.png differ diff --git a/jarvis.svg b/jarvis.svg index eb8b3f4..fd13b16 100644 --- a/jarvis.svg +++ b/jarvis.svg @@ -1,16 +1,16 @@ - + - logotests + jarvis - - - - + + + + diff --git a/jarvis/__init__.py b/jarvis/__init__.py index 6307429..703bc1a 100644 --- a/jarvis/__init__.py +++ b/jarvis/__init__.py @@ -1,110 +1,64 @@ -"""Main J.A.R.V.I.S. package.""" -import asyncio +"""Main JARVIS package.""" import logging -from pathlib import Path -from typing import Optional -from discord import Intents -from discord.ext import commands -from discord.utils import find -from discord_slash import SlashCommand -from mongoengine import connect -from psutil import Process +import aioredis +import jurigged +import rook +from jarvis_core.db import connect +from jarvis_core.log import get_logger +from naff import Intents -from jarvis import logo # noqa: F401 -from jarvis import tasks, utils -from jarvis.config import get_config -from jarvis.events import guild, member, message +from jarvis import const +from jarvis.client import Jarvis +from jarvis.cogs import __path__ as cogs_path +from jarvis.config import JarvisConfig +from jarvis.utils import get_extensions -jconfig = get_config() - -logger = logging.getLogger("discord") -logger.setLevel(logging.getLevelName(jconfig.log_level)) -file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w") -file_handler.setFormatter(logging.Formatter("[%(asctime)s][%(levelname)s][%(name)s] %(message)s")) -logger.addHandler(file_handler) - -if asyncio.get_event_loop().is_closed(): - asyncio.set_event_loop(asyncio.new_event_loop()) - -intents = Intents.default() -intents.members = True -restart_ctx = None +__version__ = const.__version__ -jarvis = commands.Bot( - command_prefix=utils.get_prefix, - intents=intents, - help_command=None, - max_messages=jconfig.max_messages, -) - -slash = SlashCommand(jarvis, sync_commands=False, sync_on_cog_reload=True) -jarvis_self = Process() -__version__ = "1.11.4" - - -@jarvis.event -async def on_ready() -> None: - """d.py on_ready override.""" - global restart_ctx - print(" Logged in as {0.user}".format(jarvis)) - print(" Connected to {} guild(s)".format(len(jarvis.guilds))) - with jarvis_self.oneshot(): - print(f" Current PID: {jarvis_self.pid}") - Path(f"jarvis.{jarvis_self.pid}.pid").touch() - if restart_ctx: - channel = None - if "guild" in restart_ctx: - guild = find(lambda x: x.id == restart_ctx["guild"], jarvis.guilds) - if guild: - channel = find(lambda x: x.id == restart_ctx["channel"], guild.channels) - elif "user" in restart_ctx: - channel = jarvis.get_user(restart_ctx["user"]) - if channel: - await channel.send("Core systems restarted and back online.") - restart_ctx = None - - -def run(ctx: dict = None) -> Optional[dict]: - """Run J.A.R.V.I.S.""" - global restart_ctx - if ctx: - restart_ctx = ctx - connect( - db="ctc2", - alias="ctc2", - authentication_source="admin", - **jconfig.mongo["connect"], +async def run() -> None: + """Run JARVIS""" + jconfig = JarvisConfig.from_yaml() + logger = get_logger("jarvis", show_locals=jconfig.log_level == "DEBUG") + logger.setLevel(jconfig.log_level) + file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w") + file_handler.setFormatter( + logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)8s] %(message)s") ) - connect( - db=jconfig.mongo["database"], - alias="main", - authentication_source="admin", - **jconfig.mongo["connect"], + logger.addHandler(file_handler) + + intents = ( + Intents.DEFAULT | Intents.MESSAGES | Intents.GUILD_MEMBERS | Intents.GUILD_MESSAGE_CONTENT ) - jconfig.get_db_config() - for extension in utils.get_extensions(): + redis_config = jconfig.redis.copy() + redis_host = redis_config.pop("host") + + redis = await aioredis.from_url(redis_host, decode_responses=True, **redis_config) + + jarvis = Jarvis( + intents=intents, + sync_interactions=jconfig.sync, + delete_unused_application_cmds=True, + send_command_tracebacks=False, + redis=redis, + ) + + if jconfig.log_level == "DEBUG": + jurigged.watch() + if jconfig.rook_token: + rook.start(token=jconfig.rook_token, labels={"env": "dev"}) + logger.info("Starting JARVIS") + logger.debug("Connecting to database") + connect(**jconfig.mongo["connect"], testing=jconfig.mongo["database"] != "jarvis") + logger.debug("Loading configuration from database") + # jconfig.get_db_config() + + logger.debug("Loading extensions") + for extension in get_extensions(cogs_path): jarvis.load_extension(extension) - print( - " https://discord.com/api/oauth2/authorize?client_id=" - + "{}&permissions=8&scope=bot%20applications.commands".format(jconfig.client_id) # noqa: W503 - ) + logger.debug("Loaded %s", extension) jarvis.max_messages = jconfig.max_messages - tasks.init() - - # Add event listeners - if jconfig.events: - _ = [ - guild.GuildEventHandler(jarvis), - member.MemberEventHandler(jarvis), - message.MessageEventHandler(jarvis), - ] - jarvis.run(jconfig.token, bot=True, reconnect=True) - for cog in jarvis.cogs: - session = getattr(cog, "_session", None) - if session: - session.close() - if restart_ctx: - return restart_ctx + logger.debug("Running JARVIS") + await jarvis.astart(jconfig.token) diff --git a/jarvis/client.py b/jarvis/client.py new file mode 100644 index 0000000..65065a8 --- /dev/null +++ b/jarvis/client.py @@ -0,0 +1,838 @@ +"""Custom JARVIS client.""" +import asyncio +import logging +import re +import traceback +from datetime import datetime, timedelta, timezone + +from aiohttp import ClientSession +from jarvis_core.db import q +from jarvis_core.db.models import ( + Action, + Autopurge, + Autoreact, + Modlog, + Note, + Roleping, + Setting, + Warning, +) +from jarvis_core.filters import invites, url +from jarvis_core.util.ansi import RESET, Fore, Format, fmt +from naff import Client, listen +from naff.api.events.discord import ( + MemberAdd, + MemberRemove, + MemberUpdate, + MessageCreate, + MessageDelete, + MessageUpdate, +) +from naff.api.events.internal import Button +from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException +from naff.client.utils.misc_utils import find_all, get +from naff.models.discord.channel import DMChannel +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.enums import AuditLogEventType, Permissions +from naff.models.discord.message import Message +from naff.models.discord.user import Member +from naff.models.naff.context import Context, InteractionContext, PrefixedContext +from naff.models.naff.tasks.task import Task +from naff.models.naff.tasks.triggers import IntervalTrigger +from pastypy import AsyncPaste as Paste + +from jarvis import const +from jarvis.utils import build_embed +from jarvis.utils.embeds import warning_embed + +DEFAULT_GUILD = 862402786116763668 +DEFAULT_ERROR_CHANNEL = 943395824560394250 +DEFAULT_SITE = "https://paste.zevs.me" + +ERROR_MSG = """ +Command Information: + Guild: {guild_name} + Name: {invoked_name} + Args: +{arg_str} + +Callback: + Args: +{callback_args} + Kwargs: +{callback_kwargs} +""" + +KEY_FMT = fmt(Fore.GRAY) +VAL_FMT = fmt(Fore.WHITE) +CMD_FMT = fmt(Fore.GREEN, Format.BOLD) + + +class Jarvis(Client): + def __init__(self, *args, **kwargs): # noqa: ANN002 ANN003 + redis = kwargs.pop("redis") + super().__init__(*args, **kwargs) + self.redis = redis + self.logger = logging.getLogger(__name__) + self.phishing_domains = [] + self.pre_run_callback = self._prerun + + @Task.create(IntervalTrigger(days=1)) + async def _update_domains(self) -> None: + self.logger.debug("Updating phishing domains") + async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session: + response = await session.get("https://phish.sinking.yachts/v2/recent/86415") + response.raise_for_status() + data = await response.json() + + self.logger.debug(f"Found {len(data)} changes to phishing domains") + + add = 0 + sub = 0 + + for update in data: + if update["type"] == "add": + for domain in update["domains"]: + if domain not in self.phishing_domains: + add += 1 + self.phishing_domains.append(domain) + elif update["type"] == "delete": + for domain in update["domains"]: + if domain in self.phishing_domains: + sub -= 1 + self.phishing_domains.remove(domain) + self.logger.debug(f"{add} additions, {sub} removals") + + async def _prerun(self, ctx: Context, *args, **kwargs) -> None: + name = ctx.invoke_target + if isinstance(ctx, InteractionContext) and ctx.target_id: + kwargs["context target"] = ctx.target + args = " ".join(f"{k}:{v}" for k, v in kwargs.items()) + elif isinstance(ctx, PrefixedContext): + args = " ".join(args) + self.logger.debug(f"Running command `{name}` with args: {args or 'None'}") + + async def _sync_domains(self) -> None: + self.logger.debug("Loading phishing domains") + async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session: + response = await session.get("https://phish.sinking.yachts/v2/all") + response.raise_for_status() + self.phishing_domains = await response.json() + self.logger.info(f"Protected from {len(self.phishing_domains)} phishing domains") + + @listen() + async def on_ready(self) -> None: + """NAFF on_ready override.""" + try: + await self._sync_domains() + self._update_domains.start() + except Exception as e: + self.logger.error("Failed to load anti-phishing", exc_info=e) + self.logger.info("Logged in as {}".format(self.user)) # noqa: T001 + self.logger.info("Connected to {} guild(s)".format(len(self.guilds))) # noqa: T001 + self.logger.info("Current version: {}".format(const.__version__)) + self.logger.info( # noqa: T001 + "https://discord.com/api/oauth2/authorize?client_id=" + "{}&permissions=8&scope=bot%20applications.commands".format(self.user.id) + ) + + async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None: + """NAFF on_error override.""" + if isinstance(error, HTTPException): + errors = error.search_for_message(error.errors) + out = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors) + self.logger.error(out, exc_info=error) + else: + self.logger.error(f"Ignoring exception in {source}", exc_info=error) + + async def on_command_error( + self, ctx: Context, error: Exception, *args: list, **kwargs: dict + ) -> None: + """NAFF on_command_error override.""" + name = ctx.invoke_target + self.logger.debug(f"Handling error in {name}: {error}") + if isinstance(error, CommandOnCooldown): + await ctx.send(str(error), ephemeral=True) + return + elif isinstance(error, CommandCheckFailure): + await ctx.send("I'm afraid I can't let you do that", ephemeral=True) + return + guild = await self.fetch_guild(DEFAULT_GUILD) + channel = await guild.fetch_channel(DEFAULT_ERROR_CHANNEL) + error_time = datetime.now(tz=timezone.utc).strftime("%d-%m-%Y %H:%M-%S.%f UTC") + timestamp = int(datetime.now(tz=timezone.utc).timestamp()) + timestamp = f"" + arg_str = "" + if isinstance(ctx, InteractionContext) and ctx.target_id: + ctx.kwargs["context target"] = ctx.target + if isinstance(ctx, InteractionContext): + for k, v in ctx.kwargs.items(): + arg_str += f" {k}: " + if isinstance(v, str) and len(v) > 100: + v = v[97] + "..." + arg_str += f"{v}\n" + elif isinstance(ctx, PrefixedContext): + for v in ctx.args: + if isinstance(v, str) and len(v) > 100: + v = v[97] + "..." + arg_str += f" - {v}" + callback_args = "\n".join(f" - {i}" for i in args) if args else " None" + callback_kwargs = ( + "\n".join(f" {k}: {v}" for k, v in kwargs.items()) if kwargs else " None" + ) + full_message = ERROR_MSG.format( + guild_name=ctx.guild.name, + error_time=error_time, + invoked_name=name, + arg_str=arg_str, + callback_args=callback_args, + callback_kwargs=callback_kwargs, + ) + tb = traceback.format_exception(error) + if isinstance(error, HTTPException): + errors = error.search_for_message(error.errors) + tb[-1] = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors) + error_message = "".join(traceback.format_exception(error)) + if len(full_message + error_message) >= 1800: + error_message = "\n ".join(error_message.split("\n")) + full_message += "Exception: |\n " + error_message + paste = Paste(content=full_message, site=DEFAULT_SITE) + key = await paste.save() + self.logger.debug(f"Large traceback, saved to Pasty {paste.id}, {key=}") + + await channel.send( + f"JARVIS encountered an error at {timestamp}. Log too big to send over Discord." + f"\nPlease see log at {paste.url}" + ) + else: + await channel.send( + f"JARVIS encountered an error at {timestamp}:" + f"\n```yaml\n{full_message}\n```" + f"\nException:\n```py\n{error_message}\n```" + ) + await ctx.send("Whoops! Encountered an error. The error has been logged.", ephemeral=True) + try: + return await super().on_command_error(ctx, error, *args, **kwargs) + except Exception as e: + self.logger.error("Uncaught exception", exc_info=e) + + # Modlog + async def on_command(self, ctx: Context) -> None: + """NAFF on_command override.""" + name = ctx.invoke_target + if not isinstance(ctx.channel, DMChannel) and name not in ["pw"]: + modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="activitylog")) + if modlog: + channel = await ctx.guild.fetch_channel(modlog.value) + args = [] + if isinstance(ctx, InteractionContext) and ctx.target_id: + args.append(f"{KEY_FMT}context target:{VAL_FMT}{ctx.target}{RESET}") + if isinstance(ctx, InteractionContext): + for k, v in ctx.kwargs.items(): + if isinstance(v, str): + v = v.replace("`", "\\`") + if len(v) > 100: + v = v[:97] + "..." + args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}") + elif isinstance(ctx, PrefixedContext): + for v in ctx.args: + if isinstance(v, str) and len(v) > 100: + v = v[97] + "..." + args.append(f"{VAL_FMT}{v}{RESET}") + args = " ".join(args) + fields = [ + EmbedField( + name="Command", + value=f"```ansi\n{CMD_FMT}{ctx.invoke_target}{RESET} {args}\n```", + inline=False, + ), + ] + embed = build_embed( + title="Command Invoked", + description=f"{ctx.author.mention} invoked a command in {ctx.channel.mention}", + fields=fields, + color="#fc9e3f", + ) + embed.set_author(name=ctx.author.username, icon_url=ctx.author.display_avatar.url) + embed.set_footer( + text=f"{ctx.author.user.username}#{ctx.author.discriminator} | {ctx.author.id}" + ) + if channel: + await channel.send(embed=embed) + else: + self.logger.warning( + f"Activitylog channel no longer exists in {ctx.guild.name}, removing" + ) + await modlog.delete() + + # Events + # Member + @listen() + async def on_member_add(self, event: MemberAdd) -> None: + """Handle on_member_add event.""" + user = event.member + guild = event.guild + unverified = await Setting.find_one(q(guild=guild.id, setting="unverified")) + if unverified: + self.logger.debug(f"Applying unverified role to {user.id} in {guild.id}") + role = await guild.fetch_role(unverified.value) + if role not in user.roles: + await user.add_role(role, reason="User just joined and is unverified") + + @listen() + async def on_member_remove(self, event: MemberRemove) -> None: + """Handle on_member_remove event.""" + user = event.member + guild = event.guild + log = await Setting.find_one(q(guild=guild.id, setting="activitylog")) + if log: + self.logger.debug(f"User {user.id} left {guild.id}") + channel = await guild.fetch_channel(log.channel) + embed = build_embed( + title="Member Left", + description=f"{user.username}#{user.discriminator} left {guild.name}", + fields=[], + ) + embed.set_author(name=user.username, icon_url=user.avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") + await channel.send(embed=embed) + + async def process_verify(self, before: Member, after: Member) -> Embed: + """Process user verification.""" + auditlog = await after.guild.fetch_audit_log( + user_id=before.id, action_type=AuditLogEventType.MEMBER_ROLE_UPDATE + ) + audit_event = get(auditlog.events, reason="Verification passed") + if audit_event: + admin_mention = "[N/A]" + admin_text = "[N/A]" + if admin := await after.guild.fet_member(audit_event.user_id): + admin_mention = admin.mention + admin_text = f"{admin.username}#{admin.discriminator}" + fields = ( + EmbedField(name="Moderator", value=f"{admin_mention} ({admin_text})"), + EmbedField(name="Reason", value=audit_event.reason), + ) + embed = build_embed( + title="User Verified", + description=f"{after.mention} was verified", + fields=fields, + color="#fc9e3f", + ) + embed.set_author(name=after.display_name, icon_url=after.display_avatar.url) + embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}") + return embed + + async def process_rolechange(self, before: Member, after: Member) -> Embed: + """Process role changes.""" + if before.roles == after.roles: + return + + new_roles = [] + removed_roles = [] + + for role in before.roles: + if role not in after.roles: + removed_roles.append(role) + for role in after.roles: + if role not in before.roles: + new_roles.append(role) + + new_text = "\n".join(role.mention for role in new_roles) or "None" + removed_text = "\n".join(role.mention for role in removed_roles) or "None" + + fields = ( + EmbedField(name="Added Roles", value=new_text), + EmbedField(name="Removed Roles", value=removed_text), + ) + embed = build_embed( + title="User Roles Changed", + description=f"{after.mention} had roles changed", + fields=fields, + color="#fc9e3f", + ) + embed.set_author(name=after.display_name, icon_url=after.display_avatar.url) + embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}") + return embed + + async def process_rename(self, before: Member, after: Member) -> None: + """Process name change.""" + if ( + before.nickname == after.nickname + and before.discriminator == after.discriminator + and before.username == after.username + ): + return + + fields = ( + EmbedField( + name="Before", + value=f"{before.display_name} ({before.username}#{before.discriminator})", + ), + EmbedField( + name="After", value=f"{after.display_name} ({after.username}#{after.discriminator})" + ), + ) + embed = build_embed( + title="User Renamed", + description=f"{after.mention} changed their name", + fields=fields, + color="#fc9e3f", + ) + embed.set_author(name=after.display_name, icon_url=after.display_avatar.url) + embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}") + return embed + + @listen() + async def on_member_update(self, event: MemberUpdate) -> None: + """Handle on_member_update event.""" + before = event.before + after = event.after + + if (before.display_name == after.display_name and before.roles == after.roles) or ( + not after or not before + ): + return + + log = await Setting.find_one(q(guild=before.guild.id, setting="activitylog")) + if log: + channel = await before.guild.fetch_channel(log.value) + await asyncio.sleep(0.5) # Wait for audit log + embed = None + if before._role_ids != after._role_ids: + verified = await Setting.find_one(q(guild=before.guild.id, setting="verified")) + v_role = None + if verified: + v_role = await before.guild.fetch_role(verified.value) + if not v_role: + self.logger.debug(f"Guild {before.guild.id} verified role no longer exists") + await verified.delete() + else: + if not before.has_role(v_role) and after.has_role(v_role): + embed = await self.process_verify(before, after) + embed = embed or await self.process_rolechange(before, after) + embed = embed or await self.process_rename(before, after) + if embed: + await channel.send(embed=embed) + + # Message + async def autopurge(self, message: Message) -> None: + """Handle autopurge events.""" + autopurge = await Autopurge.find_one(q(guild=message.guild.id, channel=message.channel.id)) + if autopurge: + self.logger.debug( + f"Autopurging message {message.guild.id}/{message.channel.id}/{message.id}" + ) + await message.delete(delay=autopurge.delay) + + async def autoreact(self, message: Message) -> None: + """Handle autoreact events.""" + autoreact = await Autoreact.find_one( + q( + guild=message.guild.id, + channel=message.channel.id, + ) + ) + if autoreact: + self.logger.debug( + f"Autoreacting to message {message.guild.id}/{message.channel.id}/{message.id}" + ) + for reaction in autoreact.reactions: + await message.add_reaction(reaction) + if autoreact.thread: + name = message.content + if len(name) > 100: + name = name[:97] + "..." + await message.create_thread(name=message.content, reason="Autoreact") + + async def checks(self, message: Message) -> None: + """Other message checks.""" + # #tech + # channel = find(lambda x: x.id == 599068193339736096, message._mention_ids) + # if channel and message.author.id == 293795462752894976: + # await channel.send( + # content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif" # noqa: E501 + # ) + content = re.sub(r"\s+", "", message.content) + match = invites.search(content) + setting = await Setting.find_one(q(guild=message.guild.id, setting="noinvite")) + if not setting: + setting = Setting(guild=message.guild.id, setting="noinvite", value=True) + await setting.commit() + if match: + guild_invites = await message.guild.fetch_invites() + if message.guild.vanity_url_code: + guild_invites.append(message.guild.vanity_url_code) + allowed = [x.code for x in guild_invites] + [ + "dbrand", + "VtgZntXcnZ", + "gPfYGbvTCE", + ] + if (m := match.group(1)) not in allowed and setting.value: + self.logger.debug(f"Removing non-allowed invite `{m}` from {message.guild.id}") + try: + await message.delete() + except Exception: + self.logger.debug("Message deleted before action taken") + + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24) + await Warning( + active=True, + admin=self.user.id, + duration=24, + expires_at=expires_at, + guild=message.guild.id, + reason="Sent an invite link", + user=message.author.id, + ).commit() + embed = warning_embed(message.author, "Sent an invite link") + await message.channel.send(embed=embed) + + async def massmention(self, message: Message) -> None: + """Handle massmention events.""" + massmention = await Setting.find_one( + q( + guild=message.guild.id, + setting="massmention", + ) + ) + + if ( + massmention + and massmention.value > 0 # noqa: W503 + and len(message._mention_ids + message._mention_roles) # noqa: W503 + - (1 if message.author.id in message._mention_ids else 0) # noqa: W503 + > massmention.value # noqa: W503 + ): + self.logger.debug( + f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}" + ) + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24) + await Warning( + active=True, + admin=self.user.id, + duration=24, + expires_at=expires_at, + guild=message.guild.id, + reason="Mass Mention", + user=message.author.id, + ).commit() + embed = warning_embed(message.author, "Mass Mention") + await message.channel.send(embed=embed) + + async def roleping(self, message: Message) -> None: + """Handle roleping events.""" + try: + if message.author.has_permission(Permissions.MANAGE_GUILD): + return + except Exception as e: + self.logger.error("Failed to get permissions, pretending check failed", exc_info=e) + if await Roleping.collection.count_documents(q(guild=message.guild.id, active=True)) == 0: + return + rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None) + + # Get all role IDs involved with message + roles = [x.id async for x in message.mention_roles] + async for mention in message.mention_users: + roles += [x.id for x in mention.roles] + + if not roles: + return + + # Get all roles that are rolepinged + roleping_ids = [r.role for r in rolepings] + + # Get roles in rolepings + role_in_rolepings = find_all(lambda x: x in roleping_ids, roles) + + # Check if the user has the role, so they are allowed to ping it + user_missing_role = any(x.id not in roleping_ids for x in message.author.roles) + + # Admins can ping whoever + user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR) + + # Check if user in a bypass list + def check_has_role(roleping: Roleping) -> bool: + return any(role.id in roleping.bypass["roles"] for role in message.author.roles) + + user_has_bypass = False + for roleping in rolepings: + if message.author.id in roleping.bypass["users"]: + user_has_bypass = True + break + if check_has_role(roleping): + user_has_bypass = True + break + + if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass: + self.logger.debug( + f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}" + ) + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24) + await Warning( + active=True, + admin=self.user.id, + duration=24, + expires_at=expires_at, + guild=message.guild.id, + reason="Pinged a blocked role/user with a blocked role", + user=message.author.id, + ).commit() + embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role") + await message.channel.send(embed=embed) + + async def phishing(self, message: Message) -> None: + """Check if the message contains any known phishing domains.""" + for match in url.finditer(message.content): + if (m := match.group("domain")) in self.phishing_domains: + self.logger.debug( + f"Phishing url `{m}` detected in {message.guild.id}/{message.channel.id}/{message.id}" + ) + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24) + await Warning( + active=True, + admin=self.user.id, + duration=24, + expires_at=expires_at, + guild=message.guild.id, + reason="Phishing URL", + user=message.author.id, + ).commit() + embed = warning_embed(message.author, "Phishing URL") + await message.channel.send(embed=embed) + await message.delete() + return True + return False + + async def malicious_url(self, message: Message) -> None: + """Check if the message contains any known phishing domains.""" + for match in url.finditer(message.content): + async with ClientSession() as session: + resp = await session.get( + "https://spoopy.oceanlord.me/api/check_website", json={"website": match.string} + ) + if resp.status != 200: + break + data = await resp.json() + for item in data["processed"]["urls"].values(): + if not item["safe"]: + self.logger.debug( + f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}" + ) + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24) + await Warning( + active=True, + admin=self.user.id, + duration=24, + expires_at=expires_at, + guild=message.guild.id, + reason="Unsafe URL", + user=message.author.id, + ).commit() + reasons = ", ".join(item["not_safe_reasons"]) + embed = warning_embed(message.author, reasons) + await message.channel.send(embed=embed) + await message.delete() + return True + return False + + @listen() + async def on_message(self, event: MessageCreate) -> None: + """Handle on_message event. Calls other event handlers.""" + message = event.message + if not isinstance(message.channel, DMChannel) and not message.author.bot: + await self.autoreact(message) + await self.massmention(message) + await self.roleping(message) + await self.autopurge(message) + await self.checks(message) + if not await self.phishing(message): + await self.malicious_url(message) + + @listen() + async def on_message_edit(self, event: MessageUpdate) -> None: + """Process on_message_edit events.""" + before = event.before + after = event.after + if not after.author.bot: + modlog = await Setting.find_one(q(guild=after.guild.id, setting="activitylog")) + if modlog: + if not before or before.content == after.content or before.content is None: + return + try: + channel = before.guild.get_channel(modlog.value) + fields = [ + EmbedField( + "Original Message", + before.content if before.content else "N/A", + False, + ), + EmbedField( + "New Message", + after.content if after.content else "N/A", + False, + ), + ] + embed = build_embed( + title="Message Edited", + description=f"{after.author.mention} edited a message in {before.channel.mention}", + fields=fields, + color="#fc9e3f", + timestamp=after.edited_timestamp, + url=after.jump_url, + ) + embed.set_author( + name=after.author.username, + icon_url=after.author.display_avatar.url, + url=after.jump_url, + ) + embed.set_footer( + text=f"{after.author.username}#{after.author.discriminator} | {after.author.id}" + ) + await channel.send(embed=embed) + except Exception as e: + self.logger.warning( + f"Failed to process edit {before.guild.id}/{before.channel.id}/{before.id}: {e}" + ) + if not isinstance(after.channel, DMChannel) and not after.author.bot: + await self.massmention(after) + await self.roleping(after) + await self.checks(after) + await self.roleping(after) + await self.checks(after) + if not await self.phishing(after): + await self.malicious_url(after) + + @listen() + async def on_message_delete(self, event: MessageDelete) -> None: + """Process on_message_delete events.""" + message = event.message + modlog = await Setting.find_one(q(guild=message.guild.id, setting="activitylog")) + if modlog: + try: + content = message.content or "N/A" + except AttributeError: + content = "N/A" + fields = [EmbedField("Original Message", content, False)] + + try: + if message.attachments: + value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments]) + fields.append( + EmbedField( + name="Attachments", + value=value, + inline=False, + ) + ) + + if message.sticker_items: + value = "\n".join([f"Sticker: {x.name}" for x in message.sticker_items]) + fields.append( + EmbedField( + name="Stickers", + value=value, + inline=False, + ) + ) + + if message.embeds: + value = str(len(message.embeds)) + " embeds" + fields.append( + EmbedField( + name="Embeds", + value=value, + inline=False, + ) + ) + + channel = message.guild.get_channel(modlog.value) + embed = build_embed( + title="Message Deleted", + description=f"{message.author.mention}'s message was deleted from {message.channel.mention}", + fields=fields, + color="#fc9e3f", + ) + + embed.set_author( + name=message.author.username, + icon_url=message.author.display_avatar.url, + url=message.jump_url, + ) + embed.set_footer( + text=( + f"{message.author.username}#{message.author.discriminator} | " + f"{message.author.id}" + ) + ) + await channel.send(embed=embed) + except Exception as e: + self.logger.warning( + f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}" + ) + + @listen() + async def on_button(self, event: Button) -> None: + """Process button events.""" + context = event.context + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + if not context.custom_id.startswith("modcase|"): + return await super().on_button(event) + + if not context.author.has_permission(Permissions.MODERATE_MEMBERS): + return + + user_key = f"msg|{context.message.id}" + action_key = "" + + if context.custom_id == "modcase|yes": + if user_id := await self.redis.get(user_key): + action_key = f"{user_id}|{context.guild.id}" + if (user := await context.guild.fetch_member(user_id)) and ( + action_data := await self.redis.get(action_key) + ): + name, parent = action_data.split("|")[:2] + action = Action(action_type=name, parent=parent) + note = Note( + admin=context.author.id, content="Moderation case opened via message" + ) + modlog = Modlog( + user=user.id, + admin=context.author.id, + guild=context.guild.id, + actions=[action], + notes=[note], + ) + await modlog.commit() + + fields = ( + EmbedField(name="Admin", value=context.author.mention), + EmbedField(name="Opening Action", value=f"{name} {parent}"), + ) + embed = build_embed( + title="Moderation Case Opened", + description=f"Moderation case opened against {user.mention}", + fields=fields, + ) + embed.set_author( + name=user.username + "#" + user.discriminator, + icon_url=user.display_avatar.url, + ) + + await context.message.edit(embed=embed) + elif not user: + self.logger.debug("User no longer in guild") + await context.send("User no longer in guild", ephemeral=True) + else: + self.logger.warn("Unable to get action data ( %s )", action_key) + await context.send("Unable to get action data", ephemeral=True) + + for row in context.message.components: + for component in row.components: + component.disabled = True + await context.message.edit(components=context.message.components) + msg = "Cancelled" if context.custom_id == "modcase|no" else "Moderation case opened" + await context.send(msg) + await self.redis.delete(user_key) + await self.redis.delete(action_key) diff --git a/jarvis/cogs/admin/__init__.py b/jarvis/cogs/admin/__init__.py index 0c76e20..e524d89 100644 --- a/jarvis/cogs/admin/__init__.py +++ b/jarvis/cogs/admin/__init__.py @@ -1,16 +1,40 @@ -"""J.A.R.V.I.S. Admin Cogs.""" -from discord.ext.commands import Bot +"""JARVIS Admin Cogs.""" +import logging -from jarvis.cogs.admin import ban, kick, lock, lockdown, mute, purge, roleping, warning +from naff import Client + +from jarvis.cogs.admin import ( + ban, + kick, + lock, + lockdown, + modcase, + mute, + purge, + roleping, + warning, +) -def setup(bot: Bot) -> None: - """Add admin cogs to J.A.R.V.I.S.""" - bot.add_cog(ban.BanCog(bot)) - bot.add_cog(kick.KickCog(bot)) - bot.add_cog(lock.LockCog(bot)) - bot.add_cog(lockdown.LockdownCog(bot)) - bot.add_cog(mute.MuteCog(bot)) - bot.add_cog(purge.PurgeCog(bot)) - bot.add_cog(roleping.RolepingCog(bot)) - bot.add_cog(warning.WarningCog(bot)) +def setup(bot: Client) -> None: + """Add admin cogs to JARVIS""" + logger = logging.getLogger(__name__) + msg = "Loaded jarvis.cogs.admin.{}" + ban.BanCog(bot) + logger.debug(msg.format("ban")) + kick.KickCog(bot) + logger.debug(msg.format("kick")) + lock.LockCog(bot) + logger.debug(msg.format("lock")) + lockdown.LockdownCog(bot) + logger.debug(msg.format("lockdown")) + modcase.CaseCog(bot) + logger.debug(msg.format("modcase")) + mute.MuteCog(bot) + logger.debug(msg.format("mute")) + purge.PurgeCog(bot) + logger.debug(msg.format("purge")) + roleping.RolepingCog(bot) + logger.debug(msg.format("roleping")) + warning.WarningCog(bot) + logger.debug(msg.format("warning")) diff --git a/jarvis/cogs/admin/ban.py b/jarvis/cogs/admin/ban.py index 646049d..2cbe0c6 100644 --- a/jarvis/cogs/admin/ban.py +++ b/jarvis/cogs/admin/ban.py @@ -1,31 +1,33 @@ -"""J.A.R.V.I.S. BanCog.""" +"""JARVIS BanCog.""" import re -from datetime import datetime, timedelta -from ButtonPaginator import Paginator -from discord import User -from discord.ext import commands -from discord.utils import find -from discord_slash import SlashContext, cog_ext -from discord_slash.model import ButtonStyle -from discord_slash.utils.manage_commands import create_choice, create_option +from jarvis_core.db import q +from jarvis_core.db.models import Ban, Unban +from naff import InteractionContext, Permissions +from naff.client.utils.misc_utils import find, find_all +from naff.ext.paginators import Paginator +from naff.models.discord.embed import EmbedField +from naff.models.discord.user import User +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + SlashCommandChoice, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Ban, Unban from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field +from jarvis.utils.cogs import ModcaseCog from jarvis.utils.permissions import admin_or_permissions -class BanCog(CacheCog): - """J.A.R.V.I.S. BanCog.""" - - def __init__(self, bot: commands.Bot): - super().__init__(bot) +class BanCog(ModcaseCog): + """JARVIS BanCog.""" async def discord_apply_ban( self, - ctx: SlashContext, + ctx: InteractionContext, reason: str, user: User, duration: int, @@ -35,9 +37,9 @@ class BanCog(CacheCog): ) -> None: """Apply a Discord ban.""" await ctx.guild.ban(user, reason=reason) - _ = Ban( + b = Ban( user=user.id, - username=user.name, + username=user.username, discrim=user.discriminator, reason=reason, admin=ctx.author.id, @@ -45,7 +47,8 @@ class BanCog(CacheCog): type=mtype, duration=duration, active=active, - ).save() + ) + await b.commit() embed = build_embed( title="User Banned", @@ -54,100 +57,86 @@ class BanCog(CacheCog): ) embed.set_author( - name=user.nick if user.nick else user.name, - icon_url=user.avatar_url, + name=user.display_name, + icon_url=user.avatar.url, ) - embed.set_thumbnail(url=user.avatar_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") + embed.set_thumbnail(url=user.avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") await ctx.send(embed=embed) - async def discord_apply_unban(self, ctx: SlashContext, user: User, reason: str) -> None: + async def discord_apply_unban(self, ctx: InteractionContext, user: User, reason: str) -> None: """Apply a Discord unban.""" await ctx.guild.unban(user, reason=reason) - _ = Unban( + u = Unban( user=user.id, - username=user.name, + username=user.username, discrim=user.discriminator, guild=ctx.guild.id, admin=ctx.author.id, reason=reason, - ).save() + ) + await u.commit() embed = build_embed( title="User Unbanned", description=f"<@{user.id}> was unbanned", - fields=[Field(name="Reason", value=reason)], + fields=[EmbedField(name="Reason", value=reason)], ) embed.set_author( - name=user.name, - icon_url=user.avatar_url, + name=user.username, + icon_url=user.avatar.url, ) - embed.set_thumbnail(url=user.avatar_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") + embed.set_thumbnail(url=user.avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") await ctx.send(embed=embed) - @cog_ext.cog_slash( - name="ban", - description="Ban a user", - options=[ - create_option( - name="user", - description="User to ban", - option_type=6, - required=True, - ), - create_option( - name="reason", - description="Ban reason", - required=True, - option_type=3, - ), - create_option( - name="btype", - description="Ban type", - option_type=3, - required=False, - choices=[ - create_choice(value="perm", name="Permanent"), - create_choice(value="temp", name="Temporary"), - create_choice(value="soft", name="Soft"), - ], - ), - create_option( - name="duration", - description="Ban duration in hours if temporary", - required=False, - option_type=4, - ), + @slash_command(name="ban", description="Ban a user") + @slash_option(name="user", description="User to ban", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="reason", description="Ban reason", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="btype", + description="Ban type", + opt_type=OptionTypes.STRING, + required=True, + choices=[ + SlashCommandChoice(name="Permanent", value="perm"), + SlashCommandChoice(name="Temporary", value="temp"), + SlashCommandChoice(name="Soft", value="soft"), ], ) - @admin_or_permissions(ban_members=True) + @slash_option( + name="duration", + description="Temp ban duration in hours", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) async def _ban( self, - ctx: SlashContext, - user: User = None, - reason: str = None, + ctx: InteractionContext, + user: User, + reason: str, btype: str = "perm", duration: int = 4, ) -> None: - if not user or user == ctx.author: - await ctx.send("You cannot ban yourself.", hidden=True) + if user.id == ctx.author.id: + await ctx.send("You cannot ban yourself.", ephemeral=True) return - if user == self.bot.user: - await ctx.send("I'm afraid I can't let you do that", hidden=True) + if user.id == self.bot.user.id: + await ctx.send("I'm afraid I can't let you do that", ephemeral=True) return if btype == "temp" and duration < 0: - await ctx.send("You cannot set a temp ban to < 0 hours.", hidden=True) + await ctx.send("You cannot set a temp ban to < 0 hours.", ephemeral=True) return elif btype == "temp" and duration > 744: - await ctx.send("You cannot set a temp ban to > 1 month", hidden=True) + await ctx.send("You cannot set a temp ban to > 1 month", ephemeral=True) return if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) + await ctx.send("Reason must be < 100 characters", ephemeral=True) return - if not reason: - reason = "Mr. Stark is displeased with your presence. Please leave." await ctx.defer() @@ -160,10 +149,10 @@ class BanCog(CacheCog): if mtype == "temp": user_message += f"\nDuration: {duration} hours" - fields = [Field(name="Type", value=mtype)] + fields = [EmbedField(name="Type", value=mtype)] if mtype == "temp": - fields.append(Field(name="Duration", value=f"{duration} hour(s)")) + fields.append(EmbedField(name="Duration", value=f"{duration} hour(s)")) user_embed = build_embed( title=f"You have been banned from {ctx.guild.name}", @@ -172,10 +161,10 @@ class BanCog(CacheCog): ) user_embed.set_author( - name=ctx.author.name + "#" + ctx.author.discriminator, - icon_url=ctx.author.avatar_url, + name=ctx.author.username + "#" + ctx.author.discriminator, + icon_url=ctx.author.avatar, ) - user_embed.set_thumbnail(url=ctx.guild.icon_url) + user_embed.set_thumbnail(url=ctx.guild.icon.url) try: await user.send(embed=user_embed) @@ -184,13 +173,13 @@ class BanCog(CacheCog): try: await ctx.guild.ban(user, reason=reason) except Exception as e: - await ctx.send(f"Failed to ban user:\n```\n{e}\n```", hidden=True) + await ctx.send(f"Failed to ban user:\n```\n{e}\n```", ephemeral=True) return send_failed = False if mtype == "soft": await ctx.guild.unban(user, reason="Ban was softban") - fields.append(Field(name="DM Sent?", value=str(not send_failed))) + fields.append(EmbedField(name="DM Sent?", value=str(not send_failed))) if btype != "temp": duration = None active = True @@ -199,33 +188,22 @@ class BanCog(CacheCog): await self.discord_apply_ban(ctx, reason, user, duration, active, fields, mtype) - @cog_ext.cog_slash( - name="unban", - description="Unban a user", - options=[ - create_option( - name="user", - description="User to unban", - option_type=3, - required=True, - ), - create_option( - name="reason", - description="Unban reason", - required=True, - option_type=3, - ), - ], + @slash_command(name="unban", description="Unban a user") + @slash_option( + name="user", description="User to unban", opt_type=OptionTypes.STRING, required=True ) - @admin_or_permissions(ban_members=True) + @slash_option( + name="reason", description="Unban reason", opt_type=OptionTypes.STRING, required=True + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) async def _unban( self, - ctx: SlashContext, + ctx: InteractionContext, user: str, reason: str, ) -> None: if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) + await ctx.send("Reason must be < 100 characters", ephemeral=True) return orig_user = user @@ -233,29 +211,35 @@ class BanCog(CacheCog): discord_ban_info = None database_ban_info = None - bans = await ctx.guild.bans() + bans = await ctx.guild.fetch_bans() # Try to get ban information out of Discord - if re.match("^[0-9]{1,}$", user): # User ID + self.logger.debug(f"{user}") + if re.match(r"^[0-9]{1,}$", user): # User ID user = int(user) discord_ban_info = find(lambda x: x.user.id == user, bans) else: # User name - if re.match("#[0-9]{4}$", user): # User name has discrim + if re.match(r"#[0-9]{4}$", user): # User name has discrim user, discrim = user.split("#") if discrim: discord_ban_info = find( - lambda x: x.user.name == user and x.user.discriminator == discrim, + lambda x: x.user.username == user and x.user.discriminator == discrim, bans, ) else: - results = [x for x in filter(lambda x: x.user.name == user, bans)] + results = find_all(lambda x: x.user.username == user, bans) if results: if len(results) > 1: active_bans = [] for ban in bans: - active_bans.append("{0} ({1}): {2}".format(ban.user.name, ban.user.id, ban.reason)) + active_bans.append( + "{0} ({1}): {2}".format(ban.user.username, ban.user.id, ban.reason) + ) ab_message = "\n".join(active_bans) - message = f"More than one result. Please use one of the following IDs:\n```{ab_message}\n```" + message = ( + "More than one result. " + f"Please use one of the following IDs:\n```{ab_message}\n```" + ) await ctx.send(message) return else: @@ -265,8 +249,10 @@ class BanCog(CacheCog): # try to find the relevant information in the database. # We take advantage of the previous checks to save CPU cycles if not discord_ban_info: - if isinstance(user, int): - database_ban_info = Ban.objects(guild=ctx.guild.id, user=user, active=True).first() + if isinstance(user, User): + database_ban_info = await Ban.find_one( + q(guild=ctx.guild.id, user=user.id, active=True) + ) else: search = { "guild": ctx.guild.id, @@ -275,15 +261,16 @@ class BanCog(CacheCog): } if discrim: search["discrim"] = discrim - database_ban_info = Ban.objects(**search).first() + database_ban_info = await Ban.find_one(q(**search)) if not discord_ban_info and not database_ban_info: - await ctx.send(f"Unable to find user {orig_user}", hidden=True) + await ctx.send(f"Unable to find user {orig_user}", ephemeral=True) - elif discord_ban_info: + elif discord_ban_info and not database_ban_info: await self.discord_apply_unban(ctx, discord_ban_info.user, reason) + else: - discord_ban_info = find(lambda x: x.user.id == database_ban_info["id"], bans) + discord_ban_info = find(lambda x: x.user.id == database_ban_info.id, bans) if discord_ban_info: await self.discord_apply_unban(ctx, discord_ban_info.user, reason) else: @@ -297,63 +284,48 @@ class BanCog(CacheCog): admin=ctx.author.id, reason=reason, ).save() - await ctx.send("Unable to find user in Discord, " + "but removed entry from database.") + await ctx.send("Unable to find user in Discord, but removed entry from database.") - @cog_ext.cog_subcommand( - base="bans", - name="list", - description="List bans", - options=[ - create_option( - name="type", - description="Ban type", - option_type=4, - required=False, - choices=[ - create_choice(value=0, name="All"), - create_choice(value=1, name="Permanent"), - create_choice(value=2, name="Temporary"), - create_choice(value=3, name="Soft"), - ], - ), - create_option( - name="active", - description="Active bans", - option_type=4, - required=False, - choices=[ - create_choice(value=1, name="Yes"), - create_choice(value=0, name="No"), - ], - ), + bans = SlashCommand(name="bans", description="User bans") + + @bans.subcommand(sub_cmd_name="list", sub_cmd_description="List bans") + @slash_option( + name="btype", + description="Ban type", + opt_type=OptionTypes.INTEGER, + required=False, + choices=[ + SlashCommandChoice(name="All", value=0), + SlashCommandChoice(name="Permanent", value=1), + SlashCommandChoice(name="Temporary", value=2), + SlashCommandChoice(name="Soft", value=3), ], ) - @admin_or_permissions(ban_members=True) - async def _bans_list(self, ctx: SlashContext, type: int = 0, active: int = 1) -> None: - active = bool(active) - exists = self.check_cache(ctx, type=type, active=active) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, - ) - return + @slash_option( + name="active", + description="Active bans", + opt_type=OptionTypes.BOOLEAN, + required=False, + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _bans_list( + self, ctx: InteractionContext, btype: int = 0, active: bool = True + ) -> None: types = [0, "perm", "temp", "soft"] search = {"guild": ctx.guild.id} if active: search["active"] = True - if type > 0: - search["type"] = types[type] - bans = Ban.objects(**search).order_by("-created_at") + if btype > 0: + search["type"] = types[btype] + bans = await Ban.find(search).sort([("created_at", -1)]).to_list(None) db_bans = [] fields = [] for ban in bans: if not ban.username: user = await self.bot.fetch_user(ban.user) - ban.username = user.name if user else "[deleted user]" + ban.username = user.username if user else "[deleted user]" fields.append( - Field( + EmbedField( name=f"Username: {ban.username}#{ban.discrim}", value=( f"Date: {ban.created_at.strftime('%d-%m-%Y')}\n" @@ -370,8 +342,8 @@ class BanCog(CacheCog): for ban in bans: if ban.user.id not in db_bans: fields.append( - Field( - name=f"Username: {ban.user.name}#" + f"{ban.user.discriminator}", + EmbedField( + name=f"Username: {ban.user.username}#" + f"{ban.user.discriminator}", value=( f"Date: [unknown]\n" f"User ID: {ban.user.id}\n" @@ -384,9 +356,9 @@ class BanCog(CacheCog): pages = [] title = "Active " if active else "Inactive " - if type > 0: - title += types[type] - if type == 1: + if btype > 0: + title += types[btype] + if btype == 1: title += "a" title += "bans" if len(fields) == 0: @@ -395,35 +367,14 @@ class BanCog(CacheCog): description=f"No {'in' if not active else ''}active bans", fields=[], ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) pages.append(embed) else: for i in range(0, len(bans), 5): - embed = build_embed(title=title, description="", fields=fields[i : i + 5]) # noqa: E203 - embed.set_thumbnail(url=ctx.guild.icon_url) + embed = build_embed(title=title, description="", fields=fields[i : i + 5]) + embed.set_thumbnail(url=ctx.guild.icon.url) pages.append(embed) - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) - self.cache[hash(paginator)] = { - "guild": ctx.guild.id, - "user": ctx.author.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "type": type, - "active": active, - "paginator": paginator, - } - - await paginator.start() + await paginator.send(ctx) diff --git a/jarvis/cogs/admin/kick.py b/jarvis/cogs/admin/kick.py index 9c74910..820b777 100644 --- a/jarvis/cogs/admin/kick.py +++ b/jarvis/cogs/admin/kick.py @@ -1,53 +1,40 @@ -"""J.A.R.V.I.S. KickCog.""" -from discord import User -from discord.ext.commands import Bot -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +"""JARVIS KickCog.""" +from jarvis_core.db.models import Kick +from naff import InteractionContext, Permissions +from naff.models.discord.embed import EmbedField +from naff.models.discord.user import User +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Kick from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field +from jarvis.utils.cogs import ModcaseCog from jarvis.utils.permissions import admin_or_permissions -class KickCog(CacheCog): - """J.A.R.V.I.S. KickCog.""" +class KickCog(ModcaseCog): + """JARVIS KickCog.""" - def __init__(self, bot: Bot): - super().__init__(bot) - - @cog_ext.cog_slash( - name="kick", - description="Kick a user", - options=[ - create_option( - name="user", - description="User to kick", - option_type=6, - required=True, - ), - create_option( - name="reason", - description="Kick reason", - required=False, - option_type=3, - ), - ], + @slash_command(name="kick", description="Kick a user") + @slash_option(name="user", description="User to kick", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="reason", description="Kick reason", opt_type=OptionTypes.STRING, required=True ) - @admin_or_permissions(kick_members=True) - async def _kick(self, ctx: SlashContext, user: User, reason: str = None) -> None: + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _kick(self, ctx: InteractionContext, user: User, reason: str) -> None: if not user or user == ctx.author: - await ctx.send("You cannot kick yourself.", hidden=True) + await ctx.send("You cannot kick yourself.", ephemeral=True) return if user == self.bot.user: - await ctx.send("I'm afraid I can't let you do that", hidden=True) + await ctx.send("I'm afraid I can't let you do that", ephemeral=True) return if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) + await ctx.send("Reason must be < 100 characters", ephemeral=True) return - if not reason: - reason = "Mr. Stark is displeased with your presence. Please leave." + guild_name = ctx.guild.name embed = build_embed( title=f"You have been kicked from {guild_name}", @@ -56,36 +43,38 @@ class KickCog(CacheCog): ) embed.set_author( - name=ctx.author.name + "#" + ctx.author.discriminator, - icon_url=ctx.author.avatar_url, + name=ctx.author.username + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar.url, ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) send_failed = False try: await user.send(embed=embed) except Exception: send_failed = True - await ctx.guild.kick(user, reason=reason) + try: + await ctx.guild.kick(user, reason=reason) + except Exception as e: + await ctx.send(f"Failed to kick user:\n```\n{e}\n```", ephemeral=True) + return - fields = [Field(name="DM Sent?", value=str(not send_failed))] + fields = [EmbedField(name="DM Sent?", value=str(not send_failed))] embed = build_embed( title="User Kicked", description=f"Reason: {reason}", fields=fields, ) - embed.set_author( - name=user.nick if user.nick else user.name, - icon_url=user.avatar_url, - ) - embed.set_thumbnail(url=user.avatar_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_thumbnail(url=user.display_avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") - await ctx.send(embed=embed) - _ = Kick( + k = Kick( user=user.id, reason=reason, admin=ctx.author.id, guild=ctx.guild.id, - ).save() + ) + await k.commit() + await ctx.send(embed=embed) diff --git a/jarvis/cogs/admin/lock.py b/jarvis/cogs/admin/lock.py index 75c6427..602959d 100644 --- a/jarvis/cogs/admin/lock.py +++ b/jarvis/cogs/admin/lock.py @@ -1,134 +1,118 @@ -"""J.A.R.V.I.S. LockCog.""" -from contextlib import suppress +"""JARVIS LockCog.""" +import logging from typing import Union -from discord import Role, TextChannel, User, VoiceChannel -from discord.ext.commands import Bot -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +from jarvis_core.db import q +from jarvis_core.db.models import Lock, Permission +from naff import Client, Cog, InteractionContext +from naff.client.utils.misc_utils import get +from naff.models.discord.channel import GuildText, GuildVoice +from naff.models.discord.enums import Permissions +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Lock -from jarvis.utils.cachecog import CacheCog from jarvis.utils.permissions import admin_or_permissions -class LockCog(CacheCog): - """J.A.R.V.I.S. LockCog.""" +class LockCog(Cog): + """JARVIS LockCog.""" - def __init__(self, bot: Bot): - super().__init__(bot) + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) - async def _lock_channel( - self, - channel: Union[TextChannel, VoiceChannel], - role: Role, - admin: User, - reason: str, - allow_send: bool = False, - ) -> None: - overrides = channel.overwrites_for(role) - if isinstance(channel, TextChannel): - overrides.send_messages = allow_send - elif isinstance(channel, VoiceChannel): - overrides.speak = allow_send - await channel.set_permissions(role, overwrite=overrides, reason=reason) - - async def _unlock_channel( - self, - channel: Union[TextChannel, VoiceChannel], - role: Role, - admin: User, - ) -> None: - overrides = channel.overwrites_for(role) - if isinstance(channel, TextChannel): - overrides.send_messages = None - elif isinstance(channel, VoiceChannel): - overrides.speak = None - await channel.set_permissions(role, overwrite=overrides) - - @cog_ext.cog_slash( - name="lock", - description="Locks a channel", - options=[ - create_option( - name="reason", - description="Lock Reason", - option_type=3, - required=True, - ), - create_option( - name="duration", - description="Lock duration in minutes (default 10)", - option_type=4, - required=False, - ), - create_option( - name="channel", - description="Channel to lock", - option_type=7, - required=False, - ), - ], + @slash_command(name="lock", description="Lock a channel") + @slash_option( + name="reason", + description="Lock Reason", + opt_type=3, + required=True, ) - @admin_or_permissions(manage_channels=True) + @slash_option( + name="duration", + description="Lock duration in minutes (default 10)", + opt_type=4, + required=False, + ) + @slash_option( + name="channel", + description="Channel to lock", + opt_type=7, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_CHANNELS)) async def _lock( self, - ctx: SlashContext, + ctx: InteractionContext, reason: str, duration: int = 10, - channel: Union[TextChannel, VoiceChannel] = None, + channel: Union[GuildText, GuildVoice] = None, ) -> None: - await ctx.defer(hidden=True) + await ctx.defer(ephemeral=True) if duration <= 0: - await ctx.send("Duration must be > 0", hidden=True) + await ctx.send("Duration must be > 0", ephemeral=True) return - elif duration >= 300: - await ctx.send("Duration must be < 5 hours", hidden=True) + + elif duration > 60 * 12: + await ctx.send("Duration must be <= 12 hours", ephemeral=True) return + if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) + await ctx.send("Reason must be <= 100 characters", ephemeral=True) return if not channel: channel = ctx.channel - for role in ctx.guild.roles: - with suppress(Exception): - await self._lock_channel(channel, role, ctx.author, reason) - _ = Lock( + + to_deny = Permissions.CONNECT | Permissions.SPEAK | Permissions.SEND_MESSAGES + + current = get(channel.permission_overwrites, id=ctx.guild.id) + if current: + current = Permission(id=ctx.guild.id, allow=int(current.allow), deny=int(current.deny)) + role = await ctx.guild.fetch_role(ctx.guild.id) + + await channel.add_permission(target=role, deny=to_deny, reason="Locked") + await Lock( channel=channel.id, guild=ctx.guild.id, admin=ctx.author.id, reason=reason, duration=duration, - ).save() + original_perms=current, + ).commit() await ctx.send(f"{channel.mention} locked for {duration} minute(s)") - @cog_ext.cog_slash( - name="unlock", - description="Unlocks a channel", - options=[ - create_option( - name="channel", - description="Channel to lock", - option_type=7, - required=False, - ), - ], + @slash_command(name="unlock", description="Unlock a channel") + @slash_option( + name="channel", + description="Channel to unlock", + opt_type=OptionTypes.CHANNEL, + required=False, ) - @admin_or_permissions(manage_channels=True) + @check(admin_or_permissions(Permissions.MANAGE_CHANNELS)) async def _unlock( self, - ctx: SlashContext, - channel: Union[TextChannel, VoiceChannel] = None, + ctx: InteractionContext, + channel: Union[GuildText, GuildVoice] = None, ) -> None: if not channel: channel = ctx.channel - lock = Lock.objects(guild=ctx.guild.id, channel=channel.id, active=True).first() + lock = await Lock.find_one(q(guild=ctx.guild.id, channel=channel.id, active=True)) if not lock: - await ctx.send(f"{channel.mention} not locked.", hidden=True) + await ctx.send(f"{channel.mention} not locked.", ephemeral=True) return - for role in ctx.guild.roles: - with suppress(Exception): - await self._unlock_channel(channel, role, ctx.author) + + overwrite = get(channel.permission_overwrites, id=ctx.guild.id) + if overwrite and lock.original_perms: + overwrite.allow = lock.original_perms.allow + overwrite.deny = lock.original_perms.deny + await channel.edit_permission(overwrite, reason="Unlock") + elif overwrite and not lock.original_perms: + await channel.delete_permission(target=overwrite, reason="Unlock") + lock.active = False - lock.save() + await lock.commit() await ctx.send(f"{channel.mention} unlocked") diff --git a/jarvis/cogs/admin/lockdown.py b/jarvis/cogs/admin/lockdown.py index dcf37ba..d06b3e3 100644 --- a/jarvis/cogs/admin/lockdown.py +++ b/jarvis/cogs/admin/lockdown.py @@ -1,100 +1,170 @@ -"""J.A.R.V.I.S. LockdownCog.""" -from contextlib import suppress -from datetime import datetime +"""JARVIS LockdownCog.""" +import logging -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +from jarvis_core.db import q +from jarvis_core.db.models import Lock, Lockdown, Permission +from naff import Client, Cog, InteractionContext +from naff.client.utils.misc_utils import find_all, get +from naff.models.discord.channel import GuildCategory, GuildChannel +from naff.models.discord.enums import Permissions +from naff.models.discord.guild import Guild +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Lock -from jarvis.utils.cachecog import CacheCog from jarvis.utils.permissions import admin_or_permissions -class LockdownCog(CacheCog): - """J.A.R.V.I.S. LockdownCog.""" +async def lock( + bot: Client, target: GuildChannel, admin: Member, reason: str, duration: int +) -> None: + """ + Lock an existing channel - def __init__(self, bot: commands.Bot): - super().__init__(bot) + Args: + bot: Bot instance + target: Target channel + admin: Admin who initiated lockdown + """ + to_deny = Permissions.SEND_MESSAGES | Permissions.CONNECT | Permissions.SPEAK + current = get(target.permission_overwrites, id=target.guild.id) + if current: + current = Permission(id=target.guild.id, allow=int(current.allow), deny=int(current.deny)) + role = await target.guild.fetch_role(target.guild.id) + await target.add_permission(target=role, deny=to_deny, reason="Lockdown") + await Lock( + channel=target.id, + guild=target.guild.id, + admin=admin.id, + reason=reason, + duration=duration, + original_perms=current, + ).commit() - @cog_ext.cog_subcommand( - base="lockdown", - name="start", - description="Locks a server", - options=[ - create_option( - name="reason", - description="Lockdown Reason", - option_type=3, - required=True, - ), - create_option( - name="duration", - description="Lockdown duration in minutes (default 10)", - option_type=4, - required=False, - ), - ], + +async def lock_all(bot: Client, guild: Guild, admin: Member, reason: str, duration: int) -> None: + """ + Lock all channels + + Args: + bot: Bot instance + guild: Target guild + admin: Admin who initiated lockdown + """ + role = await guild.fetch_role(guild.id) + categories = find_all(lambda x: isinstance(x, GuildCategory), guild.channels) + for category in categories: + await lock(bot, category, admin, reason, duration) + perms = category.permissions_for(role) + + for channel in category.channels: + if perms != channel.permissions_for(role): + await lock(bot, channel, admin, reason, duration) + + +async def unlock_all(bot: Client, guild: Guild, admin: Member) -> None: + """ + Unlock all locked channels + + Args: + bot: Bot instance + target: Target channel + admin: Admin who ended lockdown + """ + locks = Lock.find(q(guild=guild.id, active=True)) + async for lock in locks: + target = await guild.fetch_channel(lock.channel) + if target: + overwrite = get(target.permission_overwrites, id=guild.id) + if overwrite and lock.original_perms: + overwrite.allow = lock.original_perms.allow + overwrite.deny = lock.original_perms.deny + await target.edit_permission(overwrite, reason="Lockdown end") + elif overwrite and not lock.original_perms: + await target.delete_permission(target=overwrite, reason="Lockdown end") + lock.active = False + await lock.commit() + lockdown = await Lockdown.find_one(q(guild=guild.id, active=True)) + if lockdown: + lockdown.active = False + await lockdown.commit() + + +class LockdownCog(Cog): + """JARVIS LockdownCog.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + + lockdown = SlashCommand( + name="lockdown", + description="Manage server-wide lockdown", ) - @admin_or_permissions(manage_channels=True) + + @lockdown.subcommand( + sub_cmd_name="start", + sub_cmd_description="Lockdown the server", + ) + @slash_option( + name="reason", description="Lockdown reason", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="duration", + description="Duration in minutes", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_CHANNELS)) async def _lockdown_start( self, - ctx: SlashContext, + ctx: InteractionContext, reason: str, duration: int = 10, ) -> None: - await ctx.defer(hidden=True) + await ctx.defer() if duration <= 0: - await ctx.send("Duration must be > 0", hidden=True) + await ctx.send("Duration must be > 0", ephemeral=True) return elif duration >= 300: - await ctx.send("Duration must be < 5 hours", hidden=True) + await ctx.send("Duration must be < 5 hours", ephemeral=True) return - channels = ctx.guild.channels - roles = ctx.guild.roles - updates = [] - for channel in channels: - for role in roles: - with suppress(Exception): - await self._lock_channel(channel, role, ctx.author, reason) - updates.append( - Lock( - channel=channel.id, - guild=ctx.guild.id, - admin=ctx.author.id, - reason=reason, - duration=duration, - active=True, - created_at=datetime.utcnow(), - ) - ) - if updates: - Lock.objects().insert(updates) - await ctx.send(f"Server locked for {duration} minute(s)") - @cog_ext.cog_subcommand( - base="lockdown", - name="end", - description="Unlocks a server", - ) - @commands.has_permissions(administrator=True) + exists = await Lockdown.find_one(q(guild=ctx.guild.id, active=True)) + if exists: + await ctx.send("Server already in lockdown", ephemeral=True) + return + + await lock_all(self.bot, ctx.guild, ctx.author, reason, duration) + role = await ctx.guild.fetch_role(ctx.guild.id) + original_perms = role.permissions + new_perms = role.permissions & ~Permissions.SEND_MESSAGES + await role.edit(permissions=new_perms) + await Lockdown( + admin=ctx.author.id, + duration=duration, + guild=ctx.guild.id, + reason=reason, + original_perms=int(original_perms), + ).commit() + await ctx.send("Server now in lockdown.") + + @lockdown.subcommand(sub_cmd_name="end", sub_cmd_description="End a lockdown") + @check(admin_or_permissions(Permissions.MANAGE_CHANNELS)) async def _lockdown_end( self, - ctx: SlashContext, + ctx: InteractionContext, ) -> None: - channels = ctx.guild.channels - roles = ctx.guild.roles - update = False - locks = Lock.objects(guild=ctx.guild.id, active=True) - if not locks: - await ctx.send("No lockdown detected.", hidden=True) - return await ctx.defer() - for channel in channels: - for role in roles: - with suppress(Exception): - await self._unlock_channel(channel, role, ctx.author) - update = True - if update: - Lock.objects(guild=ctx.guild.id, active=True).update(set__active=False) - await ctx.send("Server unlocked") + + lockdown = await Lockdown.find_one(q(guild=ctx.guild.id, active=True)) + if not lockdown: + await ctx.send("Server not in lockdown", ephemeral=True) + return + + await unlock_all(self.bot, ctx.guild, ctx.author) + await ctx.send("Server no longer in lockdown.") diff --git a/jarvis/cogs/admin/modcase.py b/jarvis/cogs/admin/modcase.py new file mode 100644 index 0000000..3cc446f --- /dev/null +++ b/jarvis/cogs/admin/modcase.py @@ -0,0 +1,332 @@ +"""JARVIS Moderation Case management.""" +from typing import TYPE_CHECKING, List, Optional + +from jarvis_core.db import q +from jarvis_core.db.models import Modlog, Note, actions +from naff import Cog, InteractionContext, Permissions +from naff.ext.paginators import Paginator +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import check +from rich.console import Console +from rich.table import Table + +from jarvis.utils import build_embed +from jarvis.utils.permissions import admin_or_permissions + +if TYPE_CHECKING: + from naff.models.discord.guild import Guild + +ACTIONS_LOOKUP = { + "ban": actions.Ban, + "kick": actions.Kick, + "mute": actions.Mute, + "unban": actions.Unban, + "warning": actions.Warning, +} + + +class CaseCog(Cog): + """JARVIS CaseCog.""" + + async def get_summary_embed(self, mod_case: Modlog, guild: "Guild") -> Embed: + """ + Get Moderation case summary embed. + + Args: + mod_case: Moderation case + guild: Originating guild + """ + action_table = Table() + action_table.add_column(header="Type", justify="left", style="orange4", no_wrap=True) + action_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True) + action_table.add_column(header="Reason", justify="left", style="white") + + note_table = Table() + note_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True) + note_table.add_column(header="Content", justify="left", style="white") + + console = Console() + + action_output = "" + action_output_extra = "" + for idx, action in enumerate(mod_case.actions): + parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent)) + if not parent_action: + action.orphaned = True + action_table.add_row(action.action_type.title(), "[N/A]", "[N/A]") + else: + admin = await self.bot.fetch_user(parent_action.admin) + admin_text = "[N/A]" + if admin: + admin_text = f"{admin.username}#{admin.discriminator}" + action_table.add_row(action.action_type.title(), admin_text, parent_action.reason) + with console.capture() as cap: + console.print(action_table) + + tmp_output = cap.get() + if len(tmp_output) >= 800: + action_output_extra = f"... and {len(mod_case.actions[idx:])} more actions" + break + + action_output = tmp_output + + note_output = "" + note_output_extra = "" + notes = sorted(mod_case.notes, key=lambda x: x.created_at) + for idx, note in enumerate(notes): + admin = await self.bot.fetch_user(note.admin) + admin_text = "[N/A]" + if admin: + admin_text = f"{admin.username}#{admin.discriminator}" + note_table.add_row(admin_text, note.content) + + with console.capture() as cap: + console.print(note_table) + + tmp_output = cap.get() + if len(tmp_output) >= 1000: + note_output_extra = f"... and {len(notes[idx:])} more notes" + break + + note_output = tmp_output + + status = "Open" if mod_case.open else "Closed" + + user = await self.bot.fetch_user(mod_case.user) + username = "[N/A]" + user_text = "[N/A]" + if user: + username = f"{user.username}#{user.discriminator}" + user_text = user.mention + + admin = await self.bot.fetch_user(mod_case.admin) + admin_text = "[N/A]" + if admin: + admin_text = admin.mention + + action_output = f"```ansi\n{action_output}\n{action_output_extra}\n```" + note_output = f"```ansi\n{note_output}\n{note_output_extra}\n```" + + fields = ( + EmbedField( + name="Actions", value=action_output if mod_case.actions else "No Actions Found" + ), + EmbedField(name="Notes", value=note_output if mod_case.notes else "No Notes Found"), + ) + + embed = build_embed( + title=f"Moderation Case [`{mod_case.nanoid}`]", + description=f"{status} case against {user_text} [**opened by {admin_text}**]", + fields=fields, + timestamp=mod_case.created_at, + ) + icon_url = None + if user: + icon_url = user.avatar.url + + embed.set_author(name=username, icon_url=icon_url) + embed.set_footer(text=str(mod_case.user)) + + await mod_case.commit() + return embed + + async def get_action_embeds(self, mod_case: Modlog, guild: "Guild") -> List[Embed]: + """ + Get Moderation case action embeds. + + Args: + mod_case: Moderation case + guild: Originating guild + """ + embeds = [] + user = await self.bot.fetch_user(mod_case.user) + username = "[N/A]" + user_mention = "[N/A]" + avatar_url = None + if user: + username = f"{user.username}#{user.discriminator}" + avatar_url = user.avatar.url + user_mention = user.mention + + for action in mod_case.actions: + if action.orphaned: + continue + parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent)) + if not parent_action: + action.orphaned = True + continue + + admin = await self.bot.fetch_user(parent_action.admin) + admin_text = "[N/A]" + if admin: + admin_text = admin.mention + + fields = (EmbedField(name=action.action_type.title(), value=parent_action.reason),) + embed = build_embed( + title="Moderation Case Action", + description=f"{admin_text} initiated an action against {user_mention}", + fields=fields, + timestamp=parent_action.created_at, + ) + embed.set_author(name=username, icon_url=avatar_url) + embeds.append(embed) + + await mod_case.commit() + return embeds + + cases = SlashCommand(name="cases", description="Manage moderation cases") + + @cases.subcommand(sub_cmd_name="list", sub_cmd_description="List moderation cases") + @slash_option( + name="user", + description="User to filter cases to", + opt_type=OptionTypes.USER, + required=False, + ) + @slash_option( + name="closed", + description="Include closed cases", + opt_type=OptionTypes.BOOLEAN, + required=False, + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _cases_list( + self, ctx: InteractionContext, user: Optional[Member] = None, closed: bool = False + ) -> None: + query = q(guild=ctx.guild.id) + if not closed: + query.update(q(open=True)) + if user: + query.update(q(user=user.id)) + cases = await Modlog.find(query).sort("created_at", -1).to_list(None) + + if len(cases) == 0: + await ctx.send("No cases to view", ephemeral=True) + return + + pages = [await self.get_summary_embed(c, ctx.guild) for c in cases] + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) + await paginator.send(ctx) + + case = SlashCommand(name="case", description="Manage a moderation case") + show = case.group(name="show", description="Show information about a specific case") + + @show.subcommand(sub_cmd_name="summary", sub_cmd_description="Summarize a specific case") + @slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_show_summary(self, ctx: InteractionContext, cid: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid)) + if not case: + await ctx.send(f"Could not find case with ID {cid}", ephemeral=True) + return + + embed = await self.get_summary_embed(case, ctx.guild) + await ctx.send(embed=embed) + + @show.subcommand(sub_cmd_name="actions", sub_cmd_description="Get case actions") + @slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_show_actions(self, ctx: InteractionContext, cid: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid)) + if not case: + await ctx.send(f"Could not find case with ID {cid}", ephemeral=True) + return + + pages = await self.get_action_embeds(case, ctx.guild) + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) + await paginator.send(ctx) + + @case.subcommand(sub_cmd_name="close", sub_cmd_description="Show a specific case") + @slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_close(self, ctx: InteractionContext, cid: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid)) + if not case: + await ctx.send(f"Could not find case with ID {cid}", ephemeral=True) + return + + case.open = False + await case.commit() + + embed = await self.get_summary_embed(case, ctx.guild) + await ctx.send(embed=embed) + + @case.subcommand(sub_cmd_name="repoen", sub_cmd_description="Reopen a specific case") + @slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_reopen(self, ctx: InteractionContext, cid: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid)) + if not case: + await ctx.send(f"Could not find case with ID {cid}", ephemeral=True) + return + + case.open = True + await case.commit() + + embed = await self.get_summary_embed(case, ctx.guild) + await ctx.send(embed=embed) + + @case.subcommand(sub_cmd_name="note", sub_cmd_description="Add a note to a specific case") + @slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True) + @slash_option( + name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_note(self, ctx: InteractionContext, cid: str, note: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid)) + if not case: + await ctx.send(f"Could not find case with ID {cid}", ephemeral=True) + return + + if not case.open: + await ctx.send("Case is closed, please re-open to add a new comment", ephemeral=True) + return + + if len(note) > 50: + await ctx.send("Note must be <= 50 characters", ephemeral=True) + return + + note = Note(admin=ctx.author.id, content=note) + + case.notes.append(note) + await case.commit() + + embed = await self.get_summary_embed(case, ctx.guild) + await ctx.send(embed=embed) + + @case.subcommand(sub_cmd_name="new", sub_cmd_description="Open a new case") + @slash_option(name="user", description="Target user", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True + ) + @check(admin_or_permissions(Permissions.BAN_MEMBERS)) + async def _case_new(self, ctx: InteractionContext, user: Member, note: str) -> None: + case = await Modlog.find_one(q(guild=ctx.guild.id, user=user.id, open=True)) + if case: + await ctx.send(f"Case already open with ID `{case.nanoid}`", ephemeral=True) + return + + if not isinstance(user, Member): + await ctx.send("User must be in this guild", ephemeral=True) + return + + if len(note) > 50: + await ctx.send("Note must be <= 50 characters", ephemeral=True) + return + + note = Note(admin=ctx.author.id, content=note) + + case = Modlog( + user=user.id, guild=ctx.guild.id, admin=ctx.author.id, notes=[note], actions=[] + ) + await case.commit() + await case.reload() + + embed = await self.get_summary_embed(case, ctx.guild) + await ctx.send(embed=embed) diff --git a/jarvis/cogs/admin/mute.py b/jarvis/cogs/admin/mute.py index 7bf851c..cbcfab3 100644 --- a/jarvis/cogs/admin/mute.py +++ b/jarvis/cogs/admin/mute.py @@ -1,132 +1,215 @@ -"""J.A.R.V.I.S. MuteCog.""" -from discord import Member -from discord.ext import commands -from discord.utils import get -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +"""JARVIS MuteCog.""" +import asyncio +from datetime import datetime, timedelta, timezone + +from dateparser import parse +from dateparser_data.settings import default_parsers +from jarvis_core.db.models import Mute +from naff import InteractionContext, Permissions +from naff.client.errors import Forbidden +from naff.models.discord.embed import EmbedField +from naff.models.discord.modal import InputText, Modal, TextStyles +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + CommandTypes, + OptionTypes, + SlashCommandChoice, + context_menu, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Mute, Setting from jarvis.utils import build_embed -from jarvis.utils.field import Field +from jarvis.utils.cogs import ModcaseCog from jarvis.utils.permissions import admin_or_permissions -class MuteCog(commands.Cog): - """J.A.R.V.I.S. MuteCog.""" +class MuteCog(ModcaseCog): + """JARVIS MuteCog.""" - def __init__(self, bot: commands.Bot): - self.bot = bot - - @cog_ext.cog_slash( - name="mute", - description="Mute a user", - options=[ - create_option( - name="user", - description="User to mute", - option_type=6, - required=True, - ), - create_option( - name="reason", - description="Reason for mute", - option_type=3, - required=True, - ), - create_option( - name="duration", - description="Duration of mute in minutes, default 30", - option_type=4, - required=False, - ), - ], - ) - @admin_or_permissions(mute_members=True) - async def _mute(self, ctx: SlashContext, user: Member, reason: str, duration: int = 30) -> None: - if user == ctx.author: - await ctx.send("You cannot mute yourself.", hidden=True) - return - if user == self.bot.user: - await ctx.send("I'm afraid I can't let you do that", hidden=True) - return - if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) - return - mute_setting = Setting.objects(guild=ctx.guild.id, setting="mute").first() - if not mute_setting: - await ctx.send( - "Please configure a mute role with /settings mute first", - hidden=True, - ) - return - role = get(ctx.guild.roles, id=mute_setting.value) - if role in user.roles: - await ctx.send("User already muted", hidden=True) - return - await user.add_roles(role, reason=reason) - if duration < 0 or duration > 300: - duration = -1 - _ = Mute( + async def _apply_timeout( + self, ctx: InteractionContext, user: Member, reason: str, until: datetime + ) -> None: + await user.timeout(communication_disabled_until=until, reason=reason) + duration = int((until - datetime.now(tz=timezone.utc)).seconds / 60) + await Mute( user=user.id, reason=reason, admin=ctx.author.id, guild=ctx.guild.id, duration=duration, - active=True if duration >= 0 else False, - ).save() + active=True, + ).commit() + ts = int(until.timestamp()) embed = build_embed( title="User Muted", description=f"{user.mention} has been muted", - fields=[Field(name="Reason", value=reason)], + fields=[ + EmbedField(name="Reason", value=reason), + EmbedField(name="Until", value=f" "), + ], ) - embed.set_author( - name=user.nick if user.nick else user.name, - icon_url=user.avatar_url, - ) - embed.set_thumbnail(url=user.avatar_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") - await ctx.send(embed=embed) + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_thumbnail(url=user.display_avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") + return embed - @cog_ext.cog_slash( - name="unmute", - description="Unmute a user", - options=[ - create_option( - name="user", - description="User to unmute", - option_type=6, - required=True, + @context_menu(name="Mute User", context_type=CommandTypes.USER) + @check( + admin_or_permissions( + Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS + ) + ) + async def _timeout_cm(self, ctx: InteractionContext) -> None: + modal = Modal( + title=f"Muting {ctx.target.mention}", + components=[ + InputText( + label="Reason?", + placeholder="Spamming, harrassment, etc", + style=TextStyles.SHORT, + custom_id="reason", + max_length=100, + ), + InputText( + label="Duration", + placeholder="1h 30m | in 5 minutes | in 4 weeks", + style=TextStyles.SHORT, + custom_id="until", + max_length=100, + ), + ], + ) + await ctx.send_modal(modal) + try: + response = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5) + reason = response.responses.get("reason") + until = response.responses.get("until") + except asyncio.TimeoutError: + return + base_settings = { + "PREFER_DATES_FROM": "future", + "TIMEZONE": "UTC", + "RETURN_AS_TIMEZONE_AWARE": True, + } + rt_settings = base_settings.copy() + rt_settings["PARSERS"] = [ + x for x in default_parsers if x not in ["absolute-time", "timestamp"] + ] + + rt_until = parse(until, settings=rt_settings) + + at_settings = base_settings.copy() + at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"] + at_until = parse(until, settings=at_settings) + + old_until = until + if rt_until: + until = rt_until + elif at_until: + until = at_until + else: + self.logger.debug(f"Failed to parse delay: {until}") + await response.send( + f"`{until}` is not a parsable date, please try again", ephemeral=True ) + return + if until < datetime.now(tz=timezone.utc): + await response.send( + f"`{old_until}` is in the past, which isn't allowed", ephemeral=True + ) + return + try: + embed = await self._apply_timeout(ctx, ctx.target, reason, until) + await response.send(embed=embed) + except Forbidden: + await response.send("Unable to mute this user", ephemeral=True) + + @slash_command(name="mute", description="Mute a user") + @slash_option(name="user", description="User to mute", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="reason", + description="Reason for mute", + opt_type=OptionTypes.STRING, + required=True, + ) + @slash_option( + name="time", + description="Duration of mute, default 1", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @slash_option( + name="scale", + description="Time scale, default Hour(s)", + opt_type=OptionTypes.INTEGER, + required=False, + choices=[ + SlashCommandChoice(name="Minute(s)", value=1), + SlashCommandChoice(name="Hour(s)", value=60), + SlashCommandChoice(name="Day(s)", value=3600), + SlashCommandChoice(name="Week(s)", value=604800), ], ) - @admin_or_permissions(mute_members=True) - async def _unmute(self, ctx: SlashContext, user: Member) -> None: - mute_setting = Setting.objects(guild=ctx.guild.id, setting="mute").first() - if not mute_setting: - await ctx.send( - "Please configure a mute role with /settings mute first.", - hidden=True, - ) + @check( + admin_or_permissions( + Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS + ) + ) + async def _timeout( + self, ctx: InteractionContext, user: Member, reason: str, time: int = 1, scale: int = 60 + ) -> None: + if user == ctx.author: + await ctx.send("You cannot mute yourself.", ephemeral=True) + return + if user == self.bot.user: + await ctx.send("I'm afraid I can't let you do that", ephemeral=True) + return + if len(reason) > 100: + await ctx.send("Reason must be < 100 characters", ephemeral=True) return - role = get(ctx.guild.roles, id=mute_setting.value) - if role in user.roles: - await user.remove_roles(role, reason="Unmute") - else: - await ctx.send("User is not muted.", hidden=True) + # Max 4 weeks (2419200 seconds) per API + duration = time * scale + if duration > 2419200: + await ctx.send("Mute must be less than 4 weeks (2419200 seconds)", ephemeral=True) return - _ = Mute.objects(guild=ctx.guild.id, user=user.id).update(set__active=False) + until = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) + try: + embed = await self._apply_timeout(ctx, user, reason, until) + await ctx.send(embed=embed) + except Forbidden: + await ctx.send("Unable to mute this user", ephemeral=True) + + @slash_command(name="unmute", description="Unmute a user") + @slash_option( + name="user", description="User to unmute", opt_type=OptionTypes.USER, required=True + ) + @check( + admin_or_permissions( + Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS + ) + ) + async def _unmute(self, ctx: InteractionContext, user: Member) -> None: + if ( + not user.communication_disabled_until + or user.communication_disabled_until.timestamp() + < datetime.now(tz=timezone.utc).timestamp() # noqa: W503 + ): + await ctx.send("User is not muted", ephemeral=True) + return + + await user.timeout(communication_disabled_until=datetime.now(tz=timezone.utc)) + embed = build_embed( title="User Unmuted", description=f"{user.mention} has been unmuted", fields=[], ) - embed.set_author( - name=user.nick if user.nick else user.name, - icon_url=user.avatar_url, - ) - embed.set_thumbnail(url=user.avatar_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_thumbnail(url=user.display_avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") await ctx.send(embed=embed) diff --git a/jarvis/cogs/admin/purge.py b/jarvis/cogs/admin/purge.py index adae1ac..abcb588 100644 --- a/jarvis/cogs/admin/purge.py +++ b/jarvis/cogs/admin/purge.py @@ -1,138 +1,140 @@ -"""J.A.R.V.I.S. PurgeCog.""" -from discord import TextChannel -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +"""JARVIS PurgeCog.""" +import logging + +from jarvis_core.db import q +from jarvis_core.db.models import Autopurge, Purge +from naff import Client, Cog, InteractionContext, Permissions +from naff.models.discord.channel import GuildText +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Autopurge, Purge from jarvis.utils.permissions import admin_or_permissions -class PurgeCog(commands.Cog): - """J.A.R.V.I.S. PurgeCog.""" +class PurgeCog(Cog): + """JARVIS PurgeCog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_slash( - name="purge", - description="Purge messages from channel", - options=[ - create_option( - name="amount", - description="Amount of messages to purge", - required=False, - option_type=4, - ) - ], + @slash_command(name="purge", description="Purge messages from channel") + @slash_option( + name="amount", + description="Amount of messages to purge, default 10", + opt_type=OptionTypes.INTEGER, + required=False, ) - @admin_or_permissions(manage_messages=True) - async def _purge(self, ctx: SlashContext, amount: int = 10) -> None: + @check(admin_or_permissions(Permissions.MANAGE_MESSAGES)) + async def _purge(self, ctx: InteractionContext, amount: int = 10) -> None: if amount < 1: - await ctx.send("Amount must be >= 1", hidden=True) + await ctx.send("Amount must be >= 1", ephemeral=True) return await ctx.defer() - channel = ctx.channel + messages = [] - async for message in channel.history(limit=amount + 1): + async for message in ctx.channel.history(limit=amount + 1): messages.append(message) - await channel.delete_messages(messages) - _ = Purge( + await ctx.channel.delete_messages(messages, reason=f"Purge by {ctx.author.username}") + await Purge( channel=ctx.channel.id, guild=ctx.guild.id, admin=ctx.author.id, count=amount, - ).save() + ).commit() - @cog_ext.cog_subcommand( - base="autopurge", - name="add", - description="Automatically purge messages after x seconds", - options=[ - create_option( - name="channel", - description="Channel to autopurge", - option_type=7, - required=True, - ), - create_option( - name="delay", - description="Seconds to keep message before purge, default 30", - option_type=4, - required=False, - ), - ], + @slash_command( + name="autopurge", sub_cmd_name="add", sub_cmd_description="Automatically purge messages" ) - @admin_or_permissions(manage_messages=True) - async def _autopurge_add(self, ctx: SlashContext, channel: TextChannel, delay: int = 30) -> None: - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a TextChannel", hidden=True) + @slash_option( + name="channel", + description="Channel to autopurge", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @slash_option( + name="delay", + description="Seconds to keep message before purge, default 30", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_MESSAGES)) + async def _autopurge_add( + self, ctx: InteractionContext, channel: GuildText, delay: int = 30 + ) -> None: + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a GuildText channel", ephemeral=True) return if delay <= 0: - await ctx.send("Delay must be > 0", hidden=True) + await ctx.send("Delay must be > 0", ephemeral=True) return elif delay > 300: - await ctx.send("Delay must be < 5 minutes", hidden=True) + await ctx.send("Delay must be < 5 minutes", ephemeral=True) return - autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id).first() + + autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id)) if autopurge: - await ctx.send("Autopurge already exists.", hidden=True) + await ctx.send("Autopurge already exists.", ephemeral=True) return - _ = Autopurge( + + await Autopurge( guild=ctx.guild.id, channel=channel.id, admin=ctx.author.id, delay=delay, - ).save() + ).commit() + await ctx.send(f"Autopurge set up on {channel.mention}, delay is {delay} seconds") - @cog_ext.cog_subcommand( - base="autopurge", - name="remove", - description="Remove an autopurge", - options=[ - create_option( - name="channel", - description="Channel to remove from autopurge", - option_type=7, - required=True, - ), - ], + @slash_command( + name="autopurge", sub_cmd_name="remove", sub_cmd_description="Remove an autopurge" ) - @admin_or_permissions(manage_messages=True) - async def _autopurge_remove(self, ctx: SlashContext, channel: TextChannel) -> None: - autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id) + @slash_option( + name="channel", + description="Channel to remove from autopurge", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_MESSAGES)) + async def _autopurge_remove(self, ctx: InteractionContext, channel: GuildText) -> None: + autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id)) if not autopurge: - await ctx.send("Autopurge does not exist.", hidden=True) + await ctx.send("Autopurge does not exist.", ephemeral=True) return - autopurge.delete() + await autopurge.delete() await ctx.send(f"Autopurge removed from {channel.mention}.") - @cog_ext.cog_subcommand( - base="autopurge", - name="update", - description="Update autopurge on a channel", - options=[ - create_option( - name="channel", - description="Channel to update", - option_type=7, - required=True, - ), - create_option( - name="delay", - description="New time to save", - option_type=4, - required=True, - ), - ], + @slash_command( + name="autopurge", + sub_cmd_name="update", + sub_cmd_description="Update autopurge on a channel", ) - @admin_or_permissions(manage_messages=True) - async def _autopurge_update(self, ctx: SlashContext, channel: TextChannel, delay: int) -> None: - autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id) + @slash_option( + name="channel", + description="Channel to update", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @slash_option( + name="delay", + description="New time to save", + opt_type=OptionTypes.INTEGER, + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_MESSAGES)) + async def _autopurge_update( + self, ctx: InteractionContext, channel: GuildText, delay: int + ) -> None: + autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id)) if not autopurge: - await ctx.send("Autopurge does not exist.", hidden=True) + await ctx.send("Autopurge does not exist.", ephemeral=True) return + autopurge.delay = delay - autopurge.save() + await autopurge.commit() + await ctx.send(f"Autopurge delay updated to {delay} seconds on {channel.mention}.") diff --git a/jarvis/cogs/admin/roleping.py b/jarvis/cogs/admin/roleping.py index 46ee2c5..3bbe1da 100644 --- a/jarvis/cogs/admin/roleping.py +++ b/jarvis/cogs/admin/roleping.py @@ -1,103 +1,98 @@ -"""J.A.R.V.I.S. RolepingCog.""" -from datetime import datetime, timedelta +"""JARVIS RolepingCog.""" +import logging -from ButtonPaginator import Paginator -from discord import Member, Role -from discord.ext.commands import Bot -from discord_slash import SlashContext, cog_ext -from discord_slash.model import ButtonStyle -from discord_slash.utils.manage_commands import create_option +from jarvis_core.db import q +from jarvis_core.db.models import Roleping +from naff import Client, Cog, InteractionContext, Permissions +from naff.client.utils.misc_utils import find_all +from naff.ext.paginators import Paginator +from naff.models.discord.embed import EmbedField +from naff.models.discord.role import Role +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Roleping from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field from jarvis.utils.permissions import admin_or_permissions -class RolepingCog(CacheCog): - """J.A.R.V.I.S. RolepingCog.""" +class RolepingCog(Cog): + """JARVIS RolepingCog.""" - def __init__(self, bot: Bot): - super().__init__(bot) + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_subcommand( - base="roleping", - name="add", - description="Add a role to roleping", - options=[ - create_option( - name="role", - description="Role to add to roleping", - option_type=8, - required=True, - ) - ], + roleping = SlashCommand( + name="roleping", description="Set up warnings for pinging specific roles" ) - @admin_or_permissions(manage_guild=True) - async def _roleping_add(self, ctx: SlashContext, role: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=role.id).first() + + @roleping.subcommand( + sub_cmd_name="add", + sub_cmd_description="Add a role to roleping", + ) + @slash_option(name="role", description="Role to add", opt_type=OptionTypes.ROLE, required=True) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_add(self, ctx: InteractionContext, role: Role) -> None: + roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) if roleping: - await ctx.send(f"Role `{role.name}` already in roleping.", hidden=True) + await ctx.send(f"Role `{role.name}` already in roleping.", ephemeral=True) return - _ = Roleping( + + if role.id == ctx.guild.id: + await ctx.send("Cannot add `@everyone` to roleping", ephemeral=True) + return + + _ = await Roleping( role=role.id, guild=ctx.guild.id, admin=ctx.author.id, active=True, bypass={"roles": [], "users": []}, - ).save() + ).commit() await ctx.send(f"Role `{role.name}` added to roleping.") - @cog_ext.cog_subcommand( - base="roleping", - name="remove", - description="Remove a role from the roleping", - options=[ - create_option( - name="role", - description="Role to remove from roleping", - option_type=8, - required=True, - ) - ], + @roleping.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role") + @slash_option( + name="role", description="Role to remove", opt_type=OptionTypes.ROLE, required=True ) - @admin_or_permissions(manage_guild=True) - async def _roleping_remove(self, ctx: SlashContext, role: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=role.id) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_remove(self, ctx: InteractionContext, role: Role) -> None: + roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) if not roleping: - await ctx.send("Roleping does not exist", hidden=True) + await ctx.send("Roleping does not exist", ephemeral=True) return - roleping.delete() + try: + await roleping.delete() + except Exception: + self.logger.debug("Ignoring deletion error") await ctx.send(f"Role `{role.name}` removed from roleping.") - @cog_ext.cog_subcommand( - base="roleping", - name="list", - description="List all blocklisted roles", - ) - async def _roleping_list(self, ctx: SlashContext) -> None: - exists = self.check_cache(ctx) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, - ) - return + @roleping.subcommand(sub_cmd_name="list", sub_cmd_description="Lick all blocklisted roles") + async def _roleping_list(self, ctx: InteractionContext) -> None: - rolepings = Roleping.objects(guild=ctx.guild.id) + rolepings = await Roleping.find(q(guild=ctx.guild.id)).to_list(None) if not rolepings: - await ctx.send("No rolepings configured", hidden=True) + await ctx.send("No rolepings configured", ephemeral=True) return embeds = [] for roleping in rolepings: - role = ctx.guild.get_role(roleping.role) - bypass_roles = list(filter(lambda x: x.id in roleping.bypass["roles"], ctx.guild.roles)) - bypass_roles = [r.mention or "||`[redacted]`||" for r in bypass_roles] - bypass_users = [ctx.guild.get_member(u).mention or "||`[redacted]`||" for u in roleping.bypass["users"]] + role = await ctx.guild.fetch_role(roleping.role) + if not role: + await roleping.delete() + continue + broles = find_all(lambda x: x.id in roleping.bypass["roles"], ctx.guild.roles) + bypass_roles = [r.mention or "||`[redacted]`||" for r in broles] + bypass_users = [ + (await ctx.guild.fetch_member(u)).mention or "||`[redacted]`||" + for u in roleping.bypass["users"] + ] bypass_roles = bypass_roles or ["None"] bypass_users = bypass_users or ["None"] embed = build_embed( @@ -105,229 +100,175 @@ class RolepingCog(CacheCog): description=role.mention, color=str(role.color), fields=[ - Field( + EmbedField( name="Created At", value=roleping.created_at.strftime("%a, %b %d, %Y %I:%M %p"), inline=False, ), - Field(name="Active", value=str(roleping.active)), - Field( + # EmbedField(name="Active", value=str(roleping.active), inline=True), + EmbedField( name="Bypass Users", value="\n".join(bypass_users), + inline=True, ), - Field( + EmbedField( name="Bypass Roles", value="\n".join(bypass_roles), + inline=True, ), ], ) - admin = ctx.guild.get_member(roleping.admin) + admin = await ctx.guild.fetch_member(roleping.admin) if not admin: admin = self.bot.user - embed.set_author(name=admin.nick or admin.name, icon_url=admin.avatar_url) - embed.set_footer(text=f"{admin.name}#{admin.discriminator} | {admin.id}") + embed.set_author(name=admin.display_name, icon_url=admin.display_avatar.url) + embed.set_footer(text=f"{admin.username}#{admin.discriminator} | {admin.id}") embeds.append(embed) - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=embeds, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(embeds) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) + paginator = Paginator.create_from_embeds(self.bot, *embeds, timeout=300) - self.cache[hash(paginator)] = { - "user": ctx.author.id, - "guild": ctx.guild.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "paginator": paginator, - } + await paginator.send(ctx) - await paginator.start() - - @cog_ext.cog_subcommand( - base="roleping", - subcommand_group="bypass", - name="user", - description="Add a user as a bypass to a roleping", - base_desc="Block roles from being pinged", - sub_group_desc="Allow specific users/roles to ping rolepings", - options=[ - create_option( - name="user", - description="User to add", - option_type=6, - required=True, - ), - create_option( - name="rping", - description="Rolepinged role", - option_type=8, - required=True, - ), - ], + bypass = roleping.group( + name="bypass", description="Allow specific users/roles to ping rolepings" ) - @admin_or_permissions(manage_guild=True) - async def _roleping_bypass_user(self, ctx: SlashContext, user: Member, rping: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() + + @bypass.subcommand( + sub_cmd_name="user", + sub_cmd_description="Add a user as a bypass to a roleping", + ) + @slash_option( + name="bypass", description="User to add", opt_type=OptionTypes.USER, required=True + ) + @slash_option( + name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_bypass_user( + self, ctx: InteractionContext, bypass: Member, role: Role + ) -> None: + roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) if not roleping: - await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True) + await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True) return - if user.id in roleping.bypass["users"]: - await ctx.send(f"{user.mention} already in bypass", hidden=True) + if bypass.id in roleping.bypass["users"]: + await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True) return if len(roleping.bypass["users"]) == 10: await ctx.send( "Already have 10 users in bypass. Please consider using roles for roleping bypass", - hidden=True, + ephemeral=True, ) return - matching_role = list(filter(lambda x: x.id in roleping.bypass["roles"], user.roles)) + matching_role = list(filter(lambda x: x.id in roleping.bypass["roles"], bypass.roles)) if matching_role: await ctx.send( - f"{user.mention} already has bypass via {matching_role[0].mention}", - hidden=True, + f"{bypass.mention} already has bypass via {matching_role[0].mention}", + ephemeral=True, ) return - roleping.bypass["users"].append(user.id) - roleping.save() - await ctx.send(f"{user.nick or user.name} user bypass added for `{rping.name}`") + roleping.bypass["users"].append(bypass.id) + await roleping.commit() + await ctx.send(f"{bypass.display_name} user bypass added for `{role.name}`") - @cog_ext.cog_subcommand( - base="roleping", - subcommand_group="bypass", - name="role", - description="Add a role as a bypass to a roleping", - base_desc="Block roles from being pinged", - sub_group_desc="Allow specific users/roles to ping rolepings", - options=[ - create_option( - name="role", - description="Role to add", - option_type=8, - required=True, - ), - create_option( - name="rping", - description="Rolepinged role", - option_type=8, - required=True, - ), - ], + @bypass.subcommand( + sub_cmd_name="role", + sub_cmd_description="Add a role as a bypass to roleping", ) - @admin_or_permissions(manage_guild=True) - async def _roleping_bypass_role(self, ctx: SlashContext, role: Role, rping: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() + @slash_option( + name="bypass", description="Role to add", opt_type=OptionTypes.ROLE, required=True + ) + @slash_option( + name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_bypass_role( + self, ctx: InteractionContext, bypass: Role, role: Role + ) -> None: + if bypass.id == ctx.guild.id: + await ctx.send("Cannot add `@everyone` as a bypass", ephemeral=True) + return + roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) if not roleping: - await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True) + await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True) return - if role.id in roleping.bypass["roles"]: - await ctx.send(f"{role.mention} already in bypass", hidden=True) + if bypass.id in roleping.bypass["roles"]: + await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True) return if len(roleping.bypass["roles"]) == 10: await ctx.send( - "Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass", - hidden=True, + "Already have 10 roles in bypass. " + "Please consider consolidating roles for roleping bypass", + ephemeral=True, ) return - roleping.bypass["roles"].append(role.id) - roleping.save() - await ctx.send(f"{role.name} role bypass added for `{rping.name}`") + roleping.bypass["roles"].append(bypass.id) + await roleping.commit() + await ctx.send(f"{bypass.name} role bypass added for `{role.name}`") - @cog_ext.cog_subcommand( - base="roleping", - subcommand_group="restore", - name="user", - description="Remove a role bypass", - base_desc="Block roles from being pinged", - sub_group_desc="Remove a bypass from a roleping (restoring it)", - options=[ - create_option( - name="user", - description="User to add", - option_type=6, - required=True, - ), - create_option( - name="rping", - description="Rolepinged role", - option_type=8, - required=True, - ), - ], + restore = roleping.group(name="restore", description="Remove a roleping bypass") + + @restore.subcommand( + sub_cmd_name="user", + sub_cmd_description="Remove a bypass from a roleping (restoring it)", ) - @admin_or_permissions(manage_guild=True) - async def _roleping_restore_user(self, ctx: SlashContext, user: Member, rping: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() - if not roleping: - await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True) - return - - if user.id not in roleping.bypass["users"]: - await ctx.send(f"{user.mention} not in bypass", hidden=True) - return - - roleping.bypass["users"].delete(user.id) - roleping.save() - await ctx.send(f"{user.nick or user.name} user bypass removed for `{rping.name}`") - - @cog_ext.cog_subcommand( - base="roleping", - subcommand_group="restore", - name="role", - description="Remove a role bypass", - base_desc="Block roles from being pinged", - sub_group_desc="Remove a bypass from a roleping (restoring it)", - options=[ - create_option( - name="role", - description="Role to add", - option_type=8, - required=True, - ), - create_option( - name="rping", - description="Rolepinged role", - option_type=8, - required=True, - ), - ], + @slash_option( + name="bypass", description="User to remove", opt_type=OptionTypes.USER, required=True ) - @admin_or_permissions(manage_guild=True) - async def _roleping_restore_role(self, ctx: SlashContext, role: Role, rping: Role) -> None: - roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() + @slash_option( + name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_restore_user( + self, ctx: InteractionContext, bypass: Member, role: Role + ) -> None: + roleping: Roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) if not roleping: - await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True) + await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True) return - if role.id in roleping.bypass["roles"]: - await ctx.send(f"{role.mention} already in bypass", hidden=True) + if bypass.id not in roleping.bypass.users: + await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True) return - if len(roleping.bypass["roles"]) == 10: - await ctx.send( - "Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass", - hidden=True, - ) + roleping.bypass.users.remove(bypass.id) + await roleping.commit() + await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`") + + @restore.subcommand( + sub_cmd_name="role", + sub_cmd_description="Remove a bypass from a roleping (restoring it)", + ) + @slash_option( + name="bypass", description="Role to remove", opt_type=OptionTypes.ROLE, required=True + ) + @slash_option( + name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _roleping_restore_role( + self, ctx: InteractionContext, bypass: Role, role: Role + ) -> None: + roleping: Roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id)) + if not roleping: + await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True) return - roleping.bypass["roles"].append(role.id) - roleping.save() - await ctx.send(f"{role.name} role bypass added for `{rping.name}`") + if bypass.id not in roleping.bypass.roles: + await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True) + return + + roleping.bypass.roles.remove(bypass.id) + await roleping.commit() + await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`") diff --git a/jarvis/cogs/admin/warning.py b/jarvis/cogs/admin/warning.py index ec18e9a..c118ee0 100644 --- a/jarvis/cogs/admin/warning.py +++ b/jarvis/cogs/admin/warning.py @@ -1,144 +1,120 @@ -"""J.A.R.V.I.S. WarningCog.""" -from datetime import datetime, timedelta +"""JARVIS WarningCog.""" +from datetime import datetime, timedelta, timezone -from ButtonPaginator import Paginator -from discord import User -from discord.ext.commands import Bot -from discord_slash import SlashContext, cog_ext -from discord_slash.model import ButtonStyle -from discord_slash.utils.manage_commands import create_choice, create_option +from jarvis_core.db import q +from jarvis_core.db.models import Warning +from naff import InteractionContext, Permissions +from naff.client.utils.misc_utils import get_all +from naff.ext.paginators import Paginator +from naff.models.discord.embed import EmbedField +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Warning from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field +from jarvis.utils.cogs import ModcaseCog +from jarvis.utils.embeds import warning_embed from jarvis.utils.permissions import admin_or_permissions -class WarningCog(CacheCog): - """J.A.R.V.I.S. WarningCog.""" +class WarningCog(ModcaseCog): + """JARVIS WarningCog.""" - def __init__(self, bot: Bot): - super().__init__(bot) - - @cog_ext.cog_slash( - name="warn", - description="Warn a user", - options=[ - create_option( - name="user", - description="User to warn", - option_type=6, - required=True, - ), - create_option( - name="reason", - description="Reason for warning", - option_type=3, - required=True, - ), - create_option( - name="duration", - description="Duration of warning in hours, default 24", - option_type=4, - required=False, - ), - ], + @slash_command(name="warn", description="Warn a user") + @slash_option(name="user", description="User to warn", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="reason", + description="Reason for warning", + opt_type=OptionTypes.STRING, + required=True, ) - @admin_or_permissions(manage_guild=True) - async def _warn(self, ctx: SlashContext, user: User, reason: str, duration: int = 24) -> None: + @slash_option( + name="duration", + description="Duration of warning in hours, default 24", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _warn( + self, ctx: InteractionContext, user: Member, reason: str, duration: int = 24 + ) -> None: if len(reason) > 100: - await ctx.send("Reason must be < 100 characters", hidden=True) + await ctx.send("Reason must be < 100 characters", ephemeral=True) return if duration <= 0: - await ctx.send("Duration must be > 0", hidden=True) + await ctx.send("Duration must be > 0", ephemeral=True) return elif duration >= 120: - await ctx.send("Duration must be < 5 days", hidden=True) + await ctx.send("Duration must be < 5 days", ephemeral=True) + return + if not await ctx.guild.fetch_member(user.id): + await ctx.send("User not in guild", ephemeral=True) return await ctx.defer() - _ = Warning( + expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=duration) + await Warning( user=user.id, reason=reason, admin=ctx.author.id, guild=ctx.guild.id, duration=duration, + expires_at=expires_at, active=True, - ).save() - fields = [Field("Reason", reason, False)] - embed = build_embed( - title="Warning", - description=f"{user.mention} has been warned", - fields=fields, - ) - embed.set_author( - name=user.nick if user.nick else user.name, - icon_url=user.avatar_url, - ) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") - + ).commit() + embed = warning_embed(user, reason) await ctx.send(embed=embed) - @cog_ext.cog_slash( - name="warnings", - description="Get count of user warnings", - options=[ - create_option( - name="user", - description="User to view", - option_type=6, - required=True, - ), - create_option( - name="active", - description="View only active", - option_type=4, - required=False, - choices=[ - create_choice(name="Yes", value=1), - create_choice(name="No", value=0), - ], - ), - ], + @slash_command(name="warnings", description="Get count of user warnings") + @slash_option(name="user", description="User to view", opt_type=OptionTypes.USER, required=True) + @slash_option( + name="active", + description="View active only", + opt_type=OptionTypes.BOOLEAN, + required=False, ) - @admin_or_permissions(manage_guild=True) - async def _warnings(self, ctx: SlashContext, user: User, active: bool = 1) -> None: - active = bool(active) - exists = self.check_cache(ctx, user_id=user.id, active=active) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _warnings(self, ctx: InteractionContext, user: Member, active: bool = True) -> None: + warnings = ( + await Warning.find( + q( + user=user.id, + guild=ctx.guild.id, + ) ) + .sort("created_at", -1) + .to_list(None) + ) + if len(warnings) == 0: + await ctx.send("That user has no warnings.", ephemeral=True) return - warnings = Warning.objects( - user=user.id, - guild=ctx.guild.id, - ).order_by("-created_at") - active_warns = Warning.objects(user=user.id, guild=ctx.guild.id, active=True).order_by("-created_at") + active_warns = get_all(warnings, active=True) pages = [] if active: - if active_warns.count() == 0: + if len(active_warns) == 0: embed = build_embed( title="Warnings", - description=f"{warnings.count()} total | 0 currently active", + description=f"{len(warnings)} total | 0 currently active", fields=[], ) - embed.set_author(name=user.name, icon_url=user.avatar_url) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_author(name=user.username, icon_url=user.display_avatar.url) + embed.set_thumbnail(url=ctx.guild.icon.url) pages.append(embed) else: fields = [] for warn in active_warns: - admin = ctx.guild.get_member(warn.admin) + admin = await ctx.guild.fetch_member(warn.admin) + ts = int(warn.created_at.timestamp()) admin_name = "||`[redacted]`||" if admin: - admin_name = f"{admin.name}#{admin.discriminator}" + admin_name = f"{admin.username}#{admin.discriminator}" fields.append( - Field( - name=warn.created_at.strftime("%Y-%m-%d %H:%M:%S UTC"), + EmbedField( + name=f"", value=f"{warn.reason}\nAdmin: {admin_name}\n\u200b", inline=False, ) @@ -146,23 +122,26 @@ class WarningCog(CacheCog): for i in range(0, len(fields), 5): embed = build_embed( title="Warnings", - description=f"{warnings.count()} total | {active_warns.count()} currently active", - fields=fields[i : i + 5], # noqa: E203 + description=( + f"{len(warnings)} total | {len(active_warns)} currently active" + ), + fields=fields[i : i + 5], ) embed.set_author( - name=user.name + "#" + user.discriminator, - icon_url=user.avatar_url, + name=user.username + "#" + user.discriminator, + icon_url=user.display_avatar.url, ) - embed.set_thumbnail(url=ctx.guild.icon_url) - embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") + embed.set_thumbnail(url=ctx.guild.icon.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") pages.append(embed) else: fields = [] for warn in warnings: + ts = int(warn.created_at.timestamp()) title = "[A] " if warn.active else "[I] " - title += warn.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") + title += f"" fields.append( - Field( + EmbedField( name=title, value=warn.reason + "\n\u200b", inline=False, @@ -171,37 +150,15 @@ class WarningCog(CacheCog): for i in range(0, len(fields), 5): embed = build_embed( title="Warnings", - description=f"{warnings.count()} total | {active_warns.count()} currently active", - fields=fields[i : i + 5], # noqa: E203 + description=(f"{len(warnings)} total | {len(active_warns)} currently active"), + fields=fields[i : i + 5], ) embed.set_author( - name=user.name + "#" + user.discriminator, - icon_url=user.avatar_url, + name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) pages.append(embed) - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) - self.cache[hash(paginator)] = { - "guild": ctx.guild.id, - "user": ctx.author.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "user_id": user.id, - "active": active, - "paginator": paginator, - } - - await paginator.start() + await paginator.send(ctx) diff --git a/jarvis/cogs/autoreact.py b/jarvis/cogs/autoreact.py index 378f705..751474d 100644 --- a/jarvis/cogs/autoreact.py +++ b/jarvis/cogs/autoreact.py @@ -1,199 +1,211 @@ -"""J.A.R.V.I.S. Autoreact Cog.""" +"""JARVIS Autoreact Cog.""" +import logging import re +from typing import Optional, Tuple -from discord import TextChannel -from discord.ext import commands -from discord.utils import find -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +from jarvis_core.db import q +from jarvis_core.db.models import Autoreact +from naff import Client, Cog, InteractionContext, Permissions +from naff.client.utils.misc_utils import find +from naff.models.discord.channel import GuildText +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import check from jarvis.data.unicode import emoji_list -from jarvis.db.models import Autoreact from jarvis.utils.permissions import admin_or_permissions -class AutoReactCog(commands.Cog): - """J.A.R.V.I.S. Autoreact Cog.""" +class AutoReactCog(Cog): + """JARVIS Autoreact Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) self.custom_emote = re.compile(r"^<:\w+:(\d+)>$") - @cog_ext.cog_subcommand( - base="autoreact", - name="create", - description="Add an autoreact to a channel", - options=[ - create_option( - name="channel", - description="Channel to monitor", - option_type=7, - required=True, - ) - ], - ) - @admin_or_permissions(manage_guild=True) - async def _autoreact_create(self, ctx: SlashContext, channel: TextChannel) -> None: - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a text channel", hidden=True) - return - exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() - if exists: - await ctx.send(f"Autoreact already exists for {channel.mention}.", hidden=True) - return + async def create_autoreact( + self, ctx: InteractionContext, channel: GuildText, thread: bool + ) -> Tuple[bool, Optional[str]]: + """ + Create an autoreact monitor on a channel. - _ = Autoreact( + Args: + ctx: Interaction context of command + channel: Channel to monitor + thread: Create a thread + + Returns: + Tuple of success? and error message + """ + exists = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) + if exists: + return False, f"Autoreact already exists for {channel.mention}." + + await Autoreact( guild=ctx.guild.id, channel=channel.id, reactions=[], + thread=thread, admin=ctx.author.id, - ).save() - await ctx.send(f"Autoreact created for {channel.mention}!") + ).commit() - @cog_ext.cog_subcommand( - base="autoreact", - name="delete", - description="Delete an autoreact from a channel", - options=[ - create_option( - name="channel", - description="Channel to stop monitoring", - option_type=7, - required=True, - ) - ], - ) - @admin_or_permissions(manage_guild=True) - async def _autoreact_delete(self, ctx: SlashContext, channel: TextChannel) -> None: - exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).delete() - if exists: - await ctx.send(f"Autoreact removed from {channel.mention}") - else: - await ctx.send(f"Autoreact not found on {channel.mention}", hidden=True) + return True, None - @cog_ext.cog_subcommand( - base="autoreact", - name="add", - description="Add an autoreact emote to an existing autoreact", - options=[ - create_option( - name="channel", - description="Autoreact channel to add emote to", - option_type=7, - required=True, - ), - create_option( - name="emote", - description="Emote to add", - option_type=3, - required=True, - ), - ], + async def delete_autoreact(self, ctx: InteractionContext, channel: GuildText) -> bool: + """ + Remove an autoreact monitor on a channel. + + Args: + ctx: Interaction context of command + channel: Channel to stop monitoring + + Returns: + Success? + """ + ar = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) + if ar: + await ar.delete() + return True + return False + + autoreact = SlashCommand(name="autoreact", description="Channel message autoreacts") + + @autoreact.subcommand( + sub_cmd_name="add", + sub_cmd_description="Add an autoreact emote to a channel", ) - @admin_or_permissions(manage_guild=True) - async def _autoreact_add(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None: + @slash_option( + name="channel", + description="Autoreact channel to add emote to", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @slash_option( + name="thread", description="Create a thread?", opt_type=OptionTypes.BOOLEAN, required=False + ) + @slash_option( + name="emote", description="Emote to add", opt_type=OptionTypes.STRING, required=False + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _autoreact_add( + self, ctx: InteractionContext, channel: GuildText, thread: bool = True, emote: str = None + ) -> None: await ctx.defer() - custom_emoji = self.custom_emote.match(emote) - standard_emoji = emote in emoji_list - if not custom_emoji and not standard_emoji: - await ctx.send( - "Please use either an emote from this server or a unicode emoji.", - hidden=True, - ) - return - if custom_emoji: - emoji_id = int(custom_emoji.group(1)) - if not find(lambda x: x.id == emoji_id, ctx.guild.emojis): - await ctx.send("Please use a custom emote from this server.", hidden=True) + if emote: + custom_emoji = self.custom_emote.match(emote) + standard_emoji = emote in emoji_list + if not custom_emoji and not standard_emoji: + await ctx.send( + "Please use either an emote from this server or a unicode emoji.", + ephemeral=True, + ) return - exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() - if not exists: - await ctx.send(f"Please create autoreact first with /autoreact create {channel.mention}") - return - if emote in exists.reactions: + if custom_emoji: + emoji_id = int(custom_emoji.group(1)) + if not find(lambda x: x.id == emoji_id, await ctx.guild.fetch_all_custom_emojis()): + await ctx.send("Please use a custom emote from this server.", ephemeral=True) + return + autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) + if not autoreact: + await self.create_autoreact(ctx, channel, thread) + autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) + if emote and emote in autoreact.reactions: await ctx.send( f"Emote already added to {channel.mention} autoreactions.", - hidden=True, + ephemeral=True, ) return - if len(exists.reactions) >= 5: + if emote and len(autoreact.reactions) >= 5: await ctx.send( "Max number of reactions hit. Remove a different one to add this one", - hidden=True, + ephemeral=True, ) return - exists.reactions.append(emote) - exists.save() - await ctx.send(f"Added {emote} to {channel.mention} autoreact.") + if emote: + autoreact.reactions.append(emote) + autoreact.thread = thread + await autoreact.commit() + message = "" + if emote: + message += f" Added {emote} to {channel.mention} autoreact." + message += f" Set autoreact thread creation to {thread} in {channel.mention}" + await ctx.send(message) - @cog_ext.cog_subcommand( - base="autoreact", - name="remove", - description="Remove an autoreact emote from an existing autoreact", - options=[ - create_option( - name="channel", - description="Autoreact channel to remove emote from", - option_type=7, - required=True, - ), - create_option( - name="emote", - description="Emote to remove", - option_type=3, - required=True, - ), - ], + @autoreact.subcommand( + sub_cmd_name="remove", + sub_cmd_description="Remove an autoreact emote to a channel", ) - @admin_or_permissions(manage_guild=True) - async def _autoreact_remove(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None: - exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() - if not exists: + @slash_option( + name="channel", + description="Autoreact channel to remove emote from", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @slash_option( + name="emote", + description="Emote to remove (use all to delete)", + opt_type=OptionTypes.STRING, + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _autoreact_remove( + self, ctx: InteractionContext, channel: GuildText, emote: str + ) -> None: + autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) + if not autoreact: await ctx.send( - f"Please create autoreact first with /autoreact create {channel.mention}", - hidden=True, + f"Please create autoreact first with /autoreact add {channel.mention} {emote}", + ephemeral=True, ) return - if emote not in exists.reactions: + if emote.lower() == "all": + await self.delete_autoreact(ctx, channel) + await ctx.send(f"Autoreact removed from {channel.mention}") + elif emote not in autoreact.reactions: await ctx.send( f"{emote} not used in {channel.mention} autoreactions.", - hidden=True, + ephemeral=True, ) return - exists.reactions.remove(emote) - exists.save() - await ctx.send(f"Removed {emote} from {channel.mention} autoreact.") + else: + autoreact.reactions.remove(emote) + await autoreact.commit() + if len(autoreact.reactions) == 0 and not autoreact.thread: + await self.delete_autoreact(ctx, channel) + await ctx.send(f"Removed {emote} from {channel.mention} autoreact.") - @cog_ext.cog_subcommand( - base="autoreact", - name="list", - description="List all autoreacts on a channel", - options=[ - create_option( - name="channel", - description="Autoreact channel to list", - option_type=7, - required=True, - ), - ], + @autoreact.subcommand( + sub_cmd_name="list", + sub_cmd_description="List all autoreacts on a channel", ) - @admin_or_permissions(manage_guild=True) - async def _autoreact_list(self, ctx: SlashContext, channel: TextChannel) -> None: - exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() + @slash_option( + name="channel", + description="Autoreact channel to list", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + async def _autoreact_list(self, ctx: InteractionContext, channel: GuildText) -> None: + exists = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id)) if not exists: await ctx.send( - f"Please create autoreact first with /autoreact create {channel.mention}", - hidden=True, + f"Please create autoreact first with /autoreact add {channel.mention} ", + ephemeral=True, ) return message = "" if len(exists.reactions) > 0: - message = f"Current active autoreacts on {channel.mention}:\n" + "\n".join(exists.reactions) + message = f"Current active autoreacts on {channel.mention}:\n" + "\n".join( + exists.reactions + ) else: message = f"No reactions set on {channel.mention}" await ctx.send(message) -def setup(bot: commands.Bot) -> None: - """Add AutoReactCog to J.A.R.V.I.S.""" - bot.add_cog(AutoReactCog(bot)) +def setup(bot: Client) -> None: + """Add AutoReactCog to JARVIS""" + AutoReactCog(bot) diff --git a/jarvis/cogs/botutil.py b/jarvis/cogs/botutil.py new file mode 100644 index 0000000..aaeb377 --- /dev/null +++ b/jarvis/cogs/botutil.py @@ -0,0 +1,120 @@ +"""JARVIS bot utility commands.""" +import logging +import platform +from io import BytesIO + +import psutil +from aiofile import AIOFile, LineReader +from naff import Client, Cog, PrefixedContext, prefixed_command +from naff.models.discord.embed import EmbedField +from naff.models.discord.file import File +from rich.console import Console + +from jarvis.utils import build_embed +from jarvis.utils.updates import update + + +class BotutilCog(Cog): + """JARVIS Bot Utility Cog.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.add_cog_check(self.is_owner) + + async def is_owner(self, ctx: PrefixedContext) -> bool: + """Checks if author is bot owner.""" + return ctx.author.id == self.bot.owner.id + + @prefixed_command(name="tail") + async def _tail(self, ctx: PrefixedContext, count: int = 10) -> None: + lines = [] + async with AIOFile("jarvis.log", "r") as af: + async for line in LineReader(af): + lines.append(line) + if len(lines) == count + 1: + lines.pop(0) + log = "".join(lines) + if len(log) > 1500: + with BytesIO() as file_bytes: + file_bytes.write(log.encode("UTF8")) + file_bytes.seek(0) + log = File(file_bytes, file_name=f"tail_{count}.log") + await ctx.reply(content=f"Here's the last {count} lines of the log", file=log) + else: + await ctx.reply(content=f"```\n{log}\n```") + + @prefixed_command(name="log") + async def _log(self, ctx: PrefixedContext) -> None: + async with AIOFile("jarvis.log", "r") as af: + with BytesIO() as file_bytes: + raw = await af.read_bytes() + file_bytes.write(raw) + file_bytes.seek(0) + log = File(file_bytes, file_name="jarvis.log") + await ctx.reply(content="Here's the latest log", file=log) + + @prefixed_command(name="crash") + async def _crash(self, ctx: PrefixedContext) -> None: + raise Exception("As you wish") + + @prefixed_command(name="sysinfo") + async def _sysinfo(self, ctx: PrefixedContext) -> None: + st_ts = int(self.bot.start_time.timestamp()) + ut_ts = int(psutil.boot_time()) + fields = ( + EmbedField(name="Operation System", value=platform.system() or "Unknown", inline=False), + EmbedField(name="Version", value=platform.release() or "N/A", inline=False), + EmbedField(name="System Start Time", value=f" ()"), + EmbedField(name="Python Version", value=platform.python_version()), + EmbedField(name="Bot Start Time", value=f" ()"), + ) + embed = build_embed(title="System Info", description="", fields=fields) + embed.set_image(url=self.bot.user.avatar.url) + await ctx.send(embed=embed) + + @prefixed_command(name="update") + async def _update(self, ctx: PrefixedContext) -> None: + status = await update(self.bot) + if status: + console = Console() + with console.capture() as capture: + console.print(status.table) + self.logger.debug(capture.get()) + self.logger.debug(len(capture.get())) + added = "\n".join(status.added) + removed = "\n".join(status.removed) + changed = "\n".join(status.changed) + + fields = [ + EmbedField(name="Old Commit", value=status.old_hash), + EmbedField(name="New Commit", value=status.new_hash), + ] + if added: + fields.append(EmbedField(name="New Modules", value=f"```\n{added}\n```")) + if removed: + fields.append(EmbedField(name="Removed Modules", value=f"```\n{removed}\n```")) + if changed: + fields.append(EmbedField(name="Changed Modules", value=f"```\n{changed}\n```")) + + embed = build_embed( + "Update Status", description="Updates have been applied", fields=fields + ) + embed.set_thumbnail(url="https://dev.zevaryx.com/git.png") + + self.logger.info("Updates applied") + content = f"```ansi\n{capture.get()}\n```" + if len(content) < 3000: + await ctx.reply(content, embed=embed) + else: + await ctx.reply(f"Total Changes: {status.lines['total_lines']}", embed=embed) + + else: + embed = build_embed(title="Update Status", description="No changes applied", fields=[]) + embed.set_thumbnail(url="https://dev.zevaryx.com/git.png") + await ctx.reply(embed=embed) + + +def setup(bot: Client) -> None: + """Add BotutilCog to JARVIS""" + BotutilCog(bot) diff --git a/jarvis/cogs/ctc2.py b/jarvis/cogs/ctc2.py index b236e69..2a20c0f 100644 --- a/jarvis/cogs/ctc2.py +++ b/jarvis/cogs/ctc2.py @@ -1,119 +1,126 @@ -"""J.A.R.V.I.S. Complete the Code 2 Cog.""" +"""JARVIS Complete the Code 2 Cog.""" +import logging import re -from datetime import datetime, timedelta import aiohttp -from ButtonPaginator import Paginator -from discord import Member, User -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.model import ButtonStyle +from jarvis_core.db import q +from jarvis_core.db.models import Guess +from naff import Client, Cog, InteractionContext +from naff.ext.paginators import Paginator +from naff.models.discord.components import ActionRow, Button, ButtonStyles +from naff.models.discord.embed import EmbedField +from naff.models.discord.user import Member, User +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets -from jarvis.db.models import Guess from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field -guild_ids = [578757004059738142, 520021794380447745, 862402786116763668] +guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668] valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>,\u0080-\U000E0FFF]*") invites = re.compile( - r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", + r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", # noqa: E501 flags=re.IGNORECASE, ) -class CTCCog(CacheCog): - """J.A.R.V.I.S. Complete the Code 2 Cog.""" +class CTCCog(Cog): + """JARVIS Complete the Code 2 Cog.""" - def __init__(self, bot: commands.Bot): - super().__init__(bot) + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) self._session = aiohttp.ClientSession() self.url = "https://completethecodetwo.cards/pw" def __del__(self): self._session.close() - @cog_ext.cog_subcommand( - base="ctc2", - name="about", - description="CTC2 related commands", - guild_ids=guild_ids, - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _about(self, ctx: SlashContext) -> None: - await ctx.send("See https://completethecode.com for more information") + ctc2 = SlashCommand(name="ctc2", description="CTC2 related commands", scopes=guild_ids) - @cog_ext.cog_subcommand( - base="ctc2", - name="pw", - description="Guess a password for https://completethecodetwo.cards", - guild_ids=guild_ids, + @ctc2.subcommand(sub_cmd_name="about") + @cooldown(bucket=Buckets.USER, rate=1, interval=30) + async def _about(self, ctx: InteractionContext) -> None: + components = [ + ActionRow( + Button(style=ButtonStyles.URL, url="https://completethecode.com", label="More Info") + ) + ] + await ctx.send( + "See https://completethecode.com for more information", components=components + ) + + @ctc2.subcommand( + sub_cmd_name="pw", + sub_cmd_description="Guess a password for https://completethecodetwo.cards", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _pw(self, ctx: SlashContext, guess: str) -> None: + @slash_option( + name="guess", description="Guess a password", opt_type=OptionTypes.STRING, required=True + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _pw(self, ctx: InteractionContext, guess: str) -> None: if len(guess) > 800: await ctx.send( - "Listen here, dipshit. Don't be like <@256110768724901889>. Make your guesses < 800 characters.", - hidden=True, + ( + "Listen here, dipshit. Don't be like <@256110768724901889>. " + "Make your guesses < 800 characters." + ), + ephemeral=True, ) return elif not valid.fullmatch(guess): await ctx.send( - "Listen here, dipshit. Don't be like <@256110768724901889>. Make your guesses *readable*.", - hidden=True, + ( + "Listen here, dipshit. Don't be like <@256110768724901889>. " + "Make your guesses *readable*." + ), + ephemeral=True, ) return elif invites.search(guess): await ctx.send( "Listen here, dipshit. No using this to bypass sending invite links.", - hidden=True, + ephemeral=True, ) return - guessed = Guess.objects(guess=guess).first() + guessed = await Guess.find_one(q(guess=guess)) if guessed: - await ctx.send("Already guessed, dipshit.", hidden=True) + await ctx.send("Already guessed, dipshit.", ephemeral=True) return + result = await self._session.post(self.url, data=guess) correct = False if 200 <= result.status < 400: await ctx.send(f"{ctx.author.mention} got it! Password is {guess}!") correct = True else: - await ctx.send("Nope.", hidden=True) - _ = Guess(guess=guess, user=ctx.author.id, correct=correct).save() + await ctx.send("Nope.", ephemeral=True) + await Guess(guess=guess, user=ctx.author.id, correct=correct).commit() - @cog_ext.cog_subcommand( - base="ctc2", - name="guesses", - description="Show guesses made for https://completethecodetwo.cards", - guild_ids=guild_ids, + @ctc2.subcommand( + sub_cmd_name="guesses", + sub_cmd_description="Show guesses made for https://completethecodetwo.cards", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _guesses(self, ctx: SlashContext) -> None: - exists = self.check_cache(ctx) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, - ) - return - - guesses = Guess.objects().order_by("-correct", "-id") + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _guesses(self, ctx: InteractionContext) -> None: + await ctx.defer() + guesses = Guess.find().sort("correct", -1).sort("id", -1) fields = [] - for guess in guesses: - user = ctx.guild.get_member(guess["user"]) + async for guess in guesses: + user = await self.bot.fetch_user(guess["user"]) if not user: - user = await self.bot.fetch_user(guess["user"]) - if not user: - user = "[redacted]" + user = "[redacted]" if isinstance(user, (Member, User)): - user = user.name + "#" + user.discriminator + user = user.username + "#" + user.discriminator name = "Correctly" if guess["correct"] else "Incorrectly" name += " guessed by: " + user fields.append( - Field( + EmbedField( name=name, value=guess["guess"] + "\n\u200b", inline=False, @@ -123,9 +130,9 @@ class CTCCog(CacheCog): for i in range(0, len(fields), 5): embed = build_embed( title="completethecodetwo.cards guesses", - description=f"{len(fields)} guesses so far", - fields=fields[i : i + 5], # noqa: E203 - url="https://completethecodetwo.cards", + description=f"**{len(fields)} guesses so far**", + fields=fields[i : i + 5], + url="https://ctc2.zevaryx.com/gueses", ) embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") embed.set_footer( @@ -134,30 +141,11 @@ class CTCCog(CacheCog): ) pages.append(embed) - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - timeout=60 * 5, # 5 minute timeout - only=ctx.author, - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) - self.cache[hash(paginator)] = { - "guild": ctx.guild.id, - "user": ctx.author.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "paginator": paginator, - } - - await paginator.start() + await paginator.send(ctx) -def setup(bot: commands.Bot) -> None: - """Add CTCCog to J.A.R.V.I.S.""" - bot.add_cog(CTCCog(bot)) +def setup(bot: Client) -> None: + """Add CTCCog to JARVIS""" + CTCCog(bot) diff --git a/jarvis/cogs/dbrand.py b/jarvis/cogs/dbrand.py index 79e4904..f42bfae 100644 --- a/jarvis/cogs/dbrand.py +++ b/jarvis/cogs/dbrand.py @@ -1,165 +1,100 @@ -"""J.A.R.V.I.S. dbrand cog.""" +"""JARVIS dbrand cog.""" +import logging import re import aiohttp -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +from naff import Client, Cog, InteractionContext +from naff.models.discord.embed import EmbedField +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets -from jarvis.config import get_config +from jarvis.config import JarvisConfig from jarvis.data.dbrand import shipping_lookup from jarvis.utils import build_embed -from jarvis.utils.field import Field -guild_ids = [578757004059738142, 520021794380447745, 862402786116763668] +guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668] -class DbrandCog(commands.Cog): +class DbrandCog(Cog): """ - dbrand functions for J.A.R.V.I.S. + dbrand functions for JARVIS Mostly support functions. Credit @cpixl for the shipping API """ - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) self.base_url = "https://dbrand.com/" self._session = aiohttp.ClientSession() self._session.headers.update({"Content-Type": "application/json"}) - self.api_url = get_config().urls["dbrand_shipping"] + self.api_url = JarvisConfig.from_yaml().urls["dbrand_shipping"] self.cache = {} def __del__(self): self._session.close() - @cog_ext.cog_subcommand( - base="db", - name="skin", - guild_ids=guild_ids, - description="See what skins are available", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _skin(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "/skins") + db = SlashCommand(name="db", description="dbrand commands", scopes=guild_ids) - @cog_ext.cog_subcommand( - base="db", - name="robotcamo", - guild_ids=guild_ids, - description="Get some robot camo. Make Tony Stark proud", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _camo(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "robot-camo") + @db.subcommand(sub_cmd_name="info", sub_cmd_description="Get useful links") + @cooldown(bucket=Buckets.USER, rate=1, interval=30) + async def _info(self, ctx: InteractionContext) -> None: + urls = [ + f"[Get Skins]({self.base_url + 'skins'})", + f"[Robot Camo]({self.base_url + 'robot-camo'})", + f"[Get a Grip]({self.base_url + 'grip'})", + f"[Shop All Products]({self.base_url + 'shop'})", + f"[Order Status]({self.base_url + 'order-status'})", + f"[dbrand Status]({self.base_url + 'status'})", + f"[Be (not) extorted]({self.base_url + 'not-extortion'})", + "[Robot Camo Wallpapers](https://db.io/wallpapers)", + ] + embed = build_embed( + title="Useful Links", description="\n\n".join(urls), fields=[], color="#FFBB00" + ) + embed.set_footer( + text="dbrand.com", + icon_url="https://dev.zevaryx.com/db_logo.png", + ) + embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") + embed.set_author( + name="dbrand", url=self.base_url, icon_url="https://dev.zevaryx.com/db_logo.png" + ) + await ctx.send(embed=embed) - @cog_ext.cog_subcommand( - base="db", - name="grip", - guild_ids=guild_ids, - description="See devices with Grip support", + @db.subcommand( + sub_cmd_name="contact", + sub_cmd_description="Contact support", ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _grip(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "grip") - - @cog_ext.cog_subcommand( - base="db", - name="contact", - guild_ids=guild_ids, - description="Contact support", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _contact(self, ctx: SlashContext) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=30) + async def _contact(self, ctx: InteractionContext) -> None: await ctx.send("Contact dbrand support here: " + self.base_url + "contact") - @cog_ext.cog_subcommand( - base="db", - name="support", - guild_ids=guild_ids, - description="Contact support", + @db.subcommand( + sub_cmd_name="support", + sub_cmd_description="Contact support", ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _support(self, ctx: SlashContext) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=30) + async def _support(self, ctx: InteractionContext) -> None: await ctx.send("Contact dbrand support here: " + self.base_url + "contact") - @cog_ext.cog_subcommand( - base="db", - name="orderstat", - guild_ids=guild_ids, - description="Get your order status", + @db.subcommand( + sub_cmd_name="ship", + sub_cmd_description="Get shipping information for your country", ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _orderstat(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "order-status") - - @cog_ext.cog_subcommand( - base="db", - name="orders", - guild_ids=guild_ids, - description="Get your order status", + @slash_option( + name="search", + description="Country search query (2 character code, country name, flag emoji)", + opt_type=OptionTypes.STRING, + required=True, ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _orders(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "order-status") - - @cog_ext.cog_subcommand( - base="db", - name="status", - guild_ids=guild_ids, - description="dbrand status", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _status(self, ctx: SlashContext) -> None: - await ctx.send(self.base_url + "status") - - @cog_ext.cog_subcommand( - base="db", - name="buy", - guild_ids=guild_ids, - description="Give us your money!", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _buy(self, ctx: SlashContext) -> None: - await ctx.send("Give us your money! " + self.base_url + "shop") - - @cog_ext.cog_subcommand( - base="db", - name="extortion", - guild_ids=guild_ids, - description="(not) extortion", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _extort(self, ctx: SlashContext) -> None: - await ctx.send("Be (not) extorted here: " + self.base_url + "not-extortion") - - @cog_ext.cog_subcommand( - base="db", - name="wallpapers", - description="Robot Camo Wallpapers", - guild_ids=guild_ids, - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _wallpapers(self, ctx: SlashContext) -> None: - await ctx.send("Get robot camo wallpapers here: https://db.io/wallpapers") - - @cog_ext.cog_subcommand( - base="db", - name="ship", - description="Get shipping information for your country", - guild_ids=guild_ids, - options=[ - ( - create_option( - name="search", - description="Country search query (2 character code, country name, emoji)", - option_type=3, - required=True, - ) - ) - ], - ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _shipping(self, ctx: SlashContext, search: str) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _shipping(self, ctx: InteractionContext, search: str) -> None: await ctx.defer() if not re.match(r"^[A-Z- ]+$", search, re.IGNORECASE): if re.match( @@ -173,7 +108,6 @@ class DbrandCog(commands.Cog): elif search == "🏳️": search = "fr" else: - print(search) await ctx.send("Please use text to search for shipping.") return if len(search) > 2: @@ -193,14 +127,14 @@ class DbrandCog(commands.Cog): fields = None if data is not None and data["is_valid"] and data["shipping_available"]: fields = [] - fields.append(Field(data["short-name"], data["time-title"])) + fields.append(EmbedField(data["short-name"], data["time-title"])) for service in data["shipping_services_available"][1:]: service_data = await self._session.get(self.api_url + dest + "/" + service["url"]) if service_data.status > 400: continue service_data = await service_data.json() fields.append( - Field( + EmbedField( service_data["short-name"], service_data["time-title"], ) @@ -230,7 +164,8 @@ class DbrandCog(commands.Cog): embed = build_embed( title="Check Shipping Times", description=( - "Country not found.\nYou can [view all shipping " "destinations here](https://dbrand.com/shipping)" + "Country not found.\nYou can [view all shipping " + "destinations here](https://dbrand.com/shipping)" ), fields=[], url="https://dbrand.com/shipping", @@ -262,6 +197,6 @@ class DbrandCog(commands.Cog): await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: - """Add dbrandcog to J.A.R.V.I.S.""" - bot.add_cog(DbrandCog(bot)) +def setup(bot: Client) -> None: + """Add dbrandcog to JARVIS""" + DbrandCog(bot) diff --git a/jarvis/cogs/dev.py b/jarvis/cogs/dev.py index 4345c0c..bc4cb42 100644 --- a/jarvis/cogs/dev.py +++ b/jarvis/cogs/dev.py @@ -1,26 +1,38 @@ -"""J.A.R.V.I.S. Developer Cog.""" +"""JARVIS Developer Cog.""" import base64 import hashlib +import logging import re import subprocess # noqa: S404 import uuid as uuidpy -from typing import Any, Union import ulid as ulidpy from bson import ObjectId -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_choice, create_option +from jarvis_core.filters import invites, url +from jarvis_core.util import convert_bytesize, hash +from jarvis_core.util.http import get_size +from naff import Client, Cog, InteractionContext +from naff.models.discord.embed import EmbedField +from naff.models.discord.message import Attachment +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommandChoice, + slash_command, + slash_option, +) +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets -from jarvis.utils import build_embed, convert_bytesize -from jarvis.utils.field import Field +from jarvis.utils import build_embed supported_hashes = {x for x in hashlib.algorithms_guaranteed if "shake" not in x} OID_VERIFY = re.compile(r"^([1-9][0-9]{0,3}|0)(\.([1-9][0-9]{0,3}|0)){5,13}$") -URL_VERIFY = re.compile(r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+") +URL_VERIFY = re.compile( + r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" +) DN_VERIFY = re.compile( - r"^(?:(?PCN=(?P[^,]*)),)?(?:(?P(?:(?:CN|OU)=[^,]+,?)+),)?(?P(?:DC=[^,]+,?)+)$" + r"^(?:(?PCN=(?P[^,]*)),)?(?:(?P(?:(?:CN|OU)=[^,]+,?)+),)?(?P(?:DC=[^,]+,?)+)$" # noqa: E501 ) ULID_VERIFY = re.compile(r"^[0-9a-z]{26}$", re.IGNORECASE) UUID_VERIFY = re.compile( @@ -28,102 +40,99 @@ UUID_VERIFY = re.compile( re.IGNORECASE, ) -invites = re.compile( - r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", - flags=re.IGNORECASE, -) - UUID_GET = {3: uuidpy.uuid3, 5: uuidpy.uuid5} - -def hash_obj(hash: Any, data: Union[str, bytes], text: bool = True) -> str: - """Hash data with hash object. - - Data can be text or binary - """ - if text: - hash.update(data.encode("UTF-8")) - return hash.hexdigest() - BSIZE = 65536 - block_idx = 0 - while block_idx * BSIZE < len(data): - block = data[BSIZE * block_idx : BSIZE * (block_idx + 1)] # noqa: E203 - hash.update(block) - block_idx += 1 - return hash.hexdigest() +MAX_FILESIZE = 5 * (1024**3) # 5GB -class DevCog(commands.Cog): - """J.A.R.V.I.S. Developer Cog.""" +class DevCog(Cog): + """JARVIS Developer Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_slash( - name="hash", - description="Hash some data", - options=[ - create_option( - name="method", - description="Hash method", - option_type=3, - required=True, - choices=[create_choice(name=x, value=x) for x in supported_hashes], - ), - create_option( - name="data", - description="Data to hash", - option_type=3, - required=True, - ), - ], + @slash_command(name="hash", description="Hash some data") + @slash_option( + name="method", + description="Hash method", + opt_type=OptionTypes.STRING, + required=True, + choices=[SlashCommandChoice(name=x, value=x) for x in supported_hashes], ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _hash(self, ctx: SlashContext, method: str, data: str) -> None: - if not data: + @slash_option( + name="data", + description="Data to hash", + opt_type=OptionTypes.STRING, + required=False, + ) + @slash_option( + name="attach", description="File to hash", opt_type=OptionTypes.ATTACHMENT, required=False + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _hash( + self, ctx: InteractionContext, method: str, data: str = None, attach: Attachment = None + ) -> None: + if not data and not attach: await ctx.send( "No data to hash", - hidden=True, + ephemeral=True, ) return - text = True - # Default to sha256, just in case - hash = getattr(hashlib, method, hashlib.sha256)() - hex = hash_obj(hash, data, text) - data_size = convert_bytesize(len(data)) - title = data if text else ctx.message.attachments[0].filename + if data and invites.match(data): + await ctx.send("No hashing invites", ephemeral=True) + return + title = data + if attach: + data = attach.url + title = attach.filename + elif url.match(data): + try: + if (size := await get_size(data)) > MAX_FILESIZE: + await ctx.send("Please hash files that are <= 5GB in size", ephemeral=True) + self.logger.debug(f"Refused to hash file of size {convert_bytesize(size)}") + return + except Exception as e: + await ctx.send(f"Failed to retrieve URL: ```\n{e}\n```", ephemeral=True) + return + title = data.split("/")[-1] + + await ctx.defer() + try: + hexstr, size, c_type = await hash(data, method) + except Exception as e: + await ctx.send(f"Failed to hash data: ```\n{e}\n```", ephemeral=True) + return + + data_size = convert_bytesize(size) description = "Hashed using " + method fields = [ - Field("Data Size", data_size, False), - Field("Hash", f"`{hex}`", False), + EmbedField("Content Type", c_type, False), + EmbedField("Data Size", data_size, False), + EmbedField("Hash", f"`{hexstr}`", False), ] embed = build_embed(title=title, description=description, fields=fields) await ctx.send(embed=embed) - @cog_ext.cog_slash( - name="uuid", - description="Generate a UUID", - options=[ - create_option( - name="version", - description="UUID version", - option_type=3, - required=True, - choices=[create_choice(name=x, value=x) for x in ["3", "4", "5"]], - ), - create_option( - name="data", - description="Data for UUID version 3,5", - option_type=3, - required=False, - ), - ], + @slash_command(name="uuid", description="Generate a UUID") + @slash_option( + name="version", + description="UUID version", + opt_type=OptionTypes.STRING, + required=True, + choices=[SlashCommandChoice(name=x, value=x) for x in ["3", "4", "5"]], ) - async def _uuid(self, ctx: SlashContext, version: str, data: str = None) -> None: + @slash_option( + name="data", + description="Data for UUID version 3,5", + opt_type=OptionTypes.STRING, + required=False, + ) + async def _uuid(self, ctx: InteractionContext, version: str, data: str = None) -> None: version = int(version) if version in [3, 5] and not data: - await ctx.send(f"UUID{version} requires data.", hidden=True) + await ctx.send(f"UUID{version} requires data.", ephemeral=True) return if version == 4: await ctx.send(f"UUID4: `{uuidpy.uuid4()}`") @@ -139,40 +148,46 @@ class DevCog(commands.Cog): to_send = UUID_GET[version](uuidpy.NAMESPACE_DNS, data) await ctx.send(f"UUID{version}: `{to_send}`") - @cog_ext.cog_slash( + @slash_command( name="objectid", description="Generate an ObjectID", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _objectid(self, ctx: SlashContext) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _objectid(self, ctx: InteractionContext) -> None: await ctx.send(f"ObjectId: `{str(ObjectId())}`") - @cog_ext.cog_slash( + @slash_command( name="ulid", description="Generate a ULID", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _ulid(self, ctx: SlashContext) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _ulid(self, ctx: InteractionContext) -> None: await ctx.send(f"ULID: `{ulidpy.new().str}`") - @cog_ext.cog_slash( + @slash_command( name="uuid2ulid", description="Convert a UUID to a ULID", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _uuid2ulid(self, ctx: SlashContext, uuid: str) -> None: + @slash_option( + name="uuid", description="UUID to convert", opt_type=OptionTypes.STRING, required=True + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _uuid2ulid(self, ctx: InteractionContext, uuid: str) -> None: if UUID_VERIFY.match(uuid): u = ulidpy.parse(uuid) await ctx.send(f"ULID: `{u.str}`") else: await ctx.send("Invalid UUID") - @cog_ext.cog_slash( + @slash_command( name="ulid2uuid", description="Convert a ULID to a UUID", ) - @commands.cooldown(1, 2, commands.BucketType.user) - async def _ulid2uuid(self, ctx: SlashContext, ulid: str) -> None: + @slash_option( + name="ulid", description="ULID to convert", opt_type=OptionTypes.STRING, required=True + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=2) + async def _ulid2uuid(self, ctx: InteractionContext, ulid: str) -> None: if ULID_VERIFY.match(ulid): ulid = ulidpy.parse(ulid) await ctx.send(f"UUID: `{ulid.uuid}`") @@ -181,82 +196,85 @@ class DevCog(commands.Cog): base64_methods = ["b64", "b16", "b32", "a85", "b85"] - @cog_ext.cog_slash( - name="encode", - description="Encode some data", - options=[ - create_option( - name="method", - description="Encode method", - option_type=3, - required=True, - choices=[create_choice(name=x, value=x) for x in base64_methods], - ), - create_option( - name="data", - description="Data to encode", - option_type=3, - required=True, - ), - ], + @slash_command(name="encode", description="Encode some data") + @slash_option( + name="method", + description="Encode method", + opt_type=OptionTypes.STRING, + required=True, + choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods], ) - async def _encode(self, ctx: SlashContext, method: str, data: str) -> None: + @slash_option( + name="data", + description="Data to encode", + opt_type=OptionTypes.STRING, + required=True, + ) + async def _encode(self, ctx: InteractionContext, method: str, data: str) -> None: + if invites.search(data): + await ctx.send( + "Please don't use this to bypass invite restrictions", + ephemeral=True, + ) + return mstr = method method = getattr(base64, method + "encode") - encoded = method(data.encode("UTF-8")).decode("UTF-8") + try: + encoded = method(data.encode("UTF-8")).decode("UTF-8") + except Exception as e: + await ctx.send(f"Failed to encode data: {e}") + return fields = [ - Field(name="Plaintext", value=f"`{data}`", inline=False), - Field(name=mstr, value=f"`{encoded}`", inline=False), + EmbedField(name="Plaintext", value=f"`{data}`", inline=False), + EmbedField(name=mstr, value=f"`{encoded}`", inline=False), ] embed = build_embed(title="Decoded Data", description="", fields=fields) await ctx.send(embed=embed) - @cog_ext.cog_slash( - name="decode", - description="Decode some data", - options=[ - create_option( - name="method", - description="Decode method", - option_type=3, - required=True, - choices=[create_choice(name=x, value=x) for x in base64_methods], - ), - create_option( - name="data", - description="Data to encode", - option_type=3, - required=True, - ), - ], + @slash_command(name="decode", description="Decode some data") + @slash_option( + name="method", + description="Decode method", + opt_type=OptionTypes.STRING, + required=True, + choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods], ) - async def _decode(self, ctx: SlashContext, method: str, data: str) -> None: + @slash_option( + name="data", + description="Data to encode", + opt_type=OptionTypes.STRING, + required=True, + ) + async def _decode(self, ctx: InteractionContext, method: str, data: str) -> None: mstr = method method = getattr(base64, method + "decode") - decoded = method(data.encode("UTF-8")).decode("UTF-8") + try: + decoded = method(data.encode("UTF-8")).decode("UTF-8") + except Exception as e: + await ctx.send(f"Failed to decode data: {e}") + return if invites.search(decoded): await ctx.send( "Please don't use this to bypass invite restrictions", - hidden=True, + ephemeral=True, ) return fields = [ - Field(name="Plaintext", value=f"`{data}`", inline=False), - Field(name=mstr, value=f"`{decoded}`", inline=False), + EmbedField(name="Plaintext", value=f"`{data}`", inline=False), + EmbedField(name=mstr, value=f"`{decoded}`", inline=False), ] embed = build_embed(title="Decoded Data", description="", fields=fields) await ctx.send(embed=embed) - @cog_ext.cog_slash( - name="cloc", - description="Get J.A.R.V.I.S. lines of code", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _cloc(self, ctx: SlashContext) -> None: - output = subprocess.check_output(["tokei", "-C", "--sort", "code"]).decode("UTF-8") # noqa: S603, S607 - await ctx.send(f"```\n{output}\n```") + @slash_command(name="cloc", description="Get JARVIS lines of code") + @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) + async def _cloc(self, ctx: InteractionContext) -> None: + output = subprocess.check_output( # noqa: S603, S607 + ["tokei", "-C", "--sort", "code"] + ).decode("UTF-8") + await ctx.send(f"```haskell\n{output}\n```") -def setup(bot: commands.Bot) -> None: - """Add DevCog to J.A.R.V.I.S.""" - bot.add_cog(DevCog(bot)) +def setup(bot: Client) -> None: + """Add DevCog to JARVIS""" + DevCog(bot) diff --git a/jarvis/cogs/error.py b/jarvis/cogs/error.py deleted file mode 100644 index 82f93a9..0000000 --- a/jarvis/cogs/error.py +++ /dev/null @@ -1,52 +0,0 @@ -"""J.A.R.V.I.S. error handling cog.""" -from discord.ext import commands -from discord_slash import SlashContext - -from jarvis import slash - - -class ErrorHandlerCog(commands.Cog): - """J.A.R.V.I.S. error handling cog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.Cog.listener() - async def on_command_error(self, ctx: commands.Context, error: Exception) -> None: - """d.py on_command_error override.""" - if isinstance(error, commands.errors.MissingPermissions): - await ctx.send("I'm afraid I can't let you do that.") - elif isinstance(error, commands.errors.CommandNotFound): - return - elif isinstance(error, commands.errors.CommandOnCooldown): - await ctx.send( - "Command on cooldown. " + f"Please wait {error.retry_after:0.2f}s before trying again", - ) - else: - await ctx.send(f"Error processing command:\n```{error}```") - ctx.command.reset_cooldown(ctx) - - @commands.Cog.listener() - async def on_slash_command_error(self, ctx: SlashContext, error: Exception) -> None: - """discord_slash on_slash_command_error override.""" - if isinstance(error, commands.errors.MissingPermissions) or isinstance(error, commands.errors.CheckFailure): - await ctx.send("I'm afraid I can't let you do that.", hidden=True) - elif isinstance(error, commands.errors.CommandNotFound): - return - elif isinstance(error, commands.errors.CommandOnCooldown): - await ctx.send( - "Command on cooldown. " + f"Please wait {error.retry_after:0.2f}s before trying again", - hidden=True, - ) - else: - await ctx.send( - f"Error processing command:\n```{error}```", - hidden=True, - ) - raise error - slash.commands[ctx.command].reset_cooldown(ctx) - - -def setup(bot: commands.Bot) -> None: - """Add ErrorHandlerCog to J.A.R.V.I.S.""" - bot.add_cog(ErrorHandlerCog(bot)) diff --git a/jarvis/cogs/gitlab.py b/jarvis/cogs/gitlab.py deleted file mode 100644 index a3f2a95..0000000 --- a/jarvis/cogs/gitlab.py +++ /dev/null @@ -1,495 +0,0 @@ -"""J.A.R.V.I.S. GitLab Cog.""" -from datetime import datetime, timedelta - -import gitlab -from ButtonPaginator import Paginator -from discord import Embed -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.model import ButtonStyle -from discord_slash.utils.manage_commands import create_choice, create_option - -from jarvis.config import get_config -from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field - -guild_ids = [862402786116763668] - - -class GitlabCog(CacheCog): - """J.A.R.V.I.S. GitLab Cog.""" - - def __init__(self, bot: commands.Bot): - super().__init__(bot) - config = get_config() - self._gitlab = gitlab.Gitlab("https://git.zevaryx.com", private_token=config.gitlab_token) - # J.A.R.V.I.S. GitLab ID is 29 - self.project = self._gitlab.projects.get(29) - - @cog_ext.cog_subcommand( - base="gl", - name="issue", - description="Get an issue from GitLab", - guild_ids=guild_ids, - options=[create_option(name="id", description="Issue ID", option_type=4, required=True)], - ) - async def _issue(self, ctx: SlashContext, id: int) -> None: - try: - issue = self.project.issues.get(int(id)) - except gitlab.exceptions.GitlabGetError: - await ctx.send("Issue does not exist.", hidden=True) - return - assignee = issue.assignee - if assignee: - assignee = assignee["name"] - else: - assignee = "None" - - created_at = datetime.strptime(issue.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - - labels = issue.labels - if labels: - labels = "\n".join(issue.labels) - if not labels: - labels = "None" - - fields = [ - Field(name="State", value=issue.state[0].upper() + issue.state[1:]), - Field(name="Assignee", value=assignee), - Field(name="Labels", value=labels), - ] - color = self.project.labels.get(issue.labels[0]).color - fields.append(Field(name="Created At", value=created_at)) - if issue.state == "closed": - closed_at = datetime.strptime(issue.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - fields.append(Field(name="Closed At", value=closed_at)) - if issue.milestone: - fields.append( - Field( - name="Milestone", - value=f"[{issue.milestone['title']}]({issue.milestone['web_url']})", - inline=False, - ) - ) - if len(issue.title) > 200: - issue.title = issue.title[:200] + "..." - embed = build_embed( - title=f"[#{issue.iid}] {issue.title}", - description=issue.description, - fields=fields, - color=color, - url=issue.web_url, - ) - embed.set_author( - name=issue.author["name"], - icon_url=issue.author["avatar_url"], - url=issue.author["web_url"], - ) - embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png") - await ctx.send(embed=embed) - - @cog_ext.cog_subcommand( - base="gl", - name="milestone", - description="Get a milestone from GitLab", - guild_ids=guild_ids, - options=[ - create_option( - name="id", - description="Milestone ID", - option_type=4, - required=True, - ) - ], - ) - async def _milestone(self, ctx: SlashContext, id: int) -> None: - try: - milestone = self.project.milestones.get(int(id)) - except gitlab.exceptions.GitlabGetError: - await ctx.send("Milestone does not exist.", hidden=True) - return - - created_at = datetime.strptime(milestone.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - - fields = [ - Field( - name="State", - value=milestone.state[0].upper() + milestone.state[1:], - ), - Field(name="Start Date", value=milestone.start_date), - Field(name="Due Date", value=milestone.due_date), - Field(name="Created At", value=created_at), - ] - - if milestone.updated_at: - updated_at = datetime.strptime(milestone.updated_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( - "%Y-%m-%d %H:%M:%S UTC" - ) - fields.append(Field(name="Updated At", value=updated_at)) - - if len(milestone.title) > 200: - milestone.title = milestone.title[:200] + "..." - - embed = build_embed( - title=f"[#{milestone.iid}] {milestone.title}", - description=milestone.description, - fields=fields, - color="#00FFEE", - url=milestone.web_url, - ) - embed.set_author( - name="J.A.R.V.I.S.", - url="https://git.zevaryx.com/jarvis", - icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png", - ) - embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png") - await ctx.send(embed=embed) - - @cog_ext.cog_subcommand( - base="gl", - name="mergerequest", - description="Get an merge request from GitLab", - guild_ids=guild_ids, - options=[ - create_option( - name="id", - description="Merge Request ID", - option_type=4, - required=True, - ) - ], - ) - async def _mergerequest(self, ctx: SlashContext, id: int) -> None: - try: - mr = self.project.mergerequests.get(int(id)) - except gitlab.exceptions.GitlabGetError: - await ctx.send("Merge request does not exist.", hidden=True) - return - assignee = mr.assignee - if assignee: - assignee = assignee["name"] - else: - assignee = "None" - - created_at = datetime.strptime(mr.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - - labels = mr.labels - if labels: - labels = "\n".join(mr.labels) - if not labels: - labels = "None" - - fields = [ - Field(name="State", value=mr.state[0].upper() + mr.state[1:]), - Field(name="Assignee", value=assignee), - Field(name="Labels", value=labels), - ] - if mr.labels: - color = self.project.labels.get(mr.labels[0]).color - else: - color = "#00FFEE" - fields.append(Field(name="Created At", value=created_at)) - if mr.state == "merged": - merged_at = datetime.strptime(mr.merged_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - fields.append(Field(name="Merged At", value=merged_at)) - elif mr.state == "closed": - closed_at = datetime.strptime(mr.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC") - fields.append(Field(name="Closed At", value=closed_at)) - if mr.milestone: - fields.append( - Field( - name="Milestone", - value=f"[{mr.milestone['title']}]({mr.milestone['web_url']})", - inline=False, - ) - ) - if len(mr.title) > 200: - mr.title = mr.title[:200] + "..." - embed = build_embed( - title=f"[#{mr.iid}] {mr.title}", - description=mr.description, - fields=fields, - color=color, - url=mr.web_url, - ) - embed.set_author( - name=mr.author["name"], - icon_url=mr.author["avatar_url"], - url=mr.author["web_url"], - ) - embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png") - await ctx.send(embed=embed) - - def build_embed_page(self, api_list: list, t_state: str, name: str) -> Embed: - """Build an embed page for the paginator.""" - title = "" - if t_state: - title = f"{t_state} " - title += f"J.A.R.V.I.S. {name}s" - fields = [] - for item in api_list: - fields.append( - Field( - name=f"[#{item.iid}] {item.title}", - value=item.description + f"\n\n[View this {name}]({item.web_url})", - inline=False, - ) - ) - - embed = build_embed( - title=title, - description="", - fields=fields, - url=f"https://git.zevaryx.com/stark-industries/j.a.r.v.i.s./{name.replace(' ', '_')}s", - ) - embed.set_author( - name="J.A.R.V.I.S.", - url="https://git.zevaryx.com/jarvis", - icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png", - ) - embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png") - return embed - - @cog_ext.cog_subcommand( - base="gl", - name="issues", - description="Get open issues from GitLab", - guild_ids=guild_ids, - options=[ - create_option( - name="state", - description="State of issues to get", - option_type=3, - required=False, - choices=[ - create_choice(name="Open", value="opened"), - create_choice(name="Closed", value="closed"), - create_choice(name="All", value="all"), - ], - ) - ], - ) - async def _issues(self, ctx: SlashContext, state: str = "opened") -> None: - exists = self.check_cache(ctx, state=state) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - "Please use existing interaction: " + f"{exists['paginator']._message.jump_url}", - hidden=True, - ) - return - await ctx.defer() - m_state = state - if m_state == "all": - m_state = None - issues = [] - page = 1 - try: - while curr_page := self.project.issues.list( - page=page, - state=m_state, - order_by="created_at", - sort="desc", - per_page=100, - ): - issues += curr_page - page += 1 - except gitlab.exceptions.GitlabGetError: - # Only send error on first page. Otherwise, use pages retrieved - if page == 1: - await ctx.send("Unable to get issues") - return - - if len(issues) == 0: - await ctx.send("No issues match that criteria") - return - - t_state = state - if t_state == "opened": - t_state = "open" - pages = [] - t_state = t_state[0].upper() + t_state[1:] - for i in range(0, len(issues), 5): - pages.append(self.build_embed_page(issues[i : i + 5], t_state=t_state, name="issue")) # noqa: E203 - - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) - - self.cache[hash(paginator)] = { - "user": ctx.author.id, - "guild": ctx.guild.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "state": state, - "paginator": paginator, - } - - await paginator.start() - - @cog_ext.cog_subcommand( - base="gl", - name="mergerequests", - description="Get open issues from GitLab", - guild_ids=guild_ids, - options=[ - create_option( - name="state", - description="State of issues to get", - option_type=3, - required=False, - choices=[ - create_choice(name="Open", value="opened"), - create_choice(name="Closed", value="closed"), - create_choice(name="Merged", value="merged"), - create_choice(name="All", value="all"), - ], - ) - ], - ) - async def _mergerequests(self, ctx: SlashContext, state: str = "opened") -> None: - exists = self.check_cache(ctx, state=state) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - "Please use existing interaction: " + f"{exists['paginator']._message.jump_url}", - hidden=True, - ) - return - await ctx.defer() - m_state = state - if m_state == "all": - m_state = None - merges = [] - page = 1 - try: - while curr_page := self.project.mergerequests.list( - page=page, - state=m_state, - order_by="created_at", - sort="desc", - per_page=100, - ): - merges += curr_page - page += 1 - except gitlab.exceptions.GitlabGetError: - # Only send error on first page. Otherwise, use pages retrieved - if page == 1: - await ctx.send("Unable to get merge requests") - return - - if len(merges) == 0: - await ctx.send("No merge requests match that criteria") - return - - t_state = state - if t_state == "opened": - t_state = "open" - pages = [] - t_state = t_state[0].upper() + t_state[1:] - for i in range(0, len(merges), 5): - pages.append(self.build_embed_page(merges[i : i + 5], t_state=t_state, name="merge request")) # noqa: E203 - - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) - - self.cache[hash(paginator)] = { - "user": ctx.author.id, - "guild": ctx.guild.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "state": state, - "paginator": paginator, - } - - await paginator.start() - - @cog_ext.cog_subcommand( - base="gl", - name="milestones", - description="Get open issues from GitLab", - guild_ids=guild_ids, - ) - async def _milestones(self, ctx: SlashContext) -> None: - exists = self.check_cache(ctx) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, - ) - return - await ctx.defer() - milestones = [] - page = 1 - try: - while curr_page := self.project.milestones.list( - page=page, - order_by="created_at", - sort="desc", - per_page=100, - ): - milestones += curr_page - page += 1 - except gitlab.exceptions.GitlabGetError: - # Only send error on first page. Otherwise, use pages retrieved - if page == 1: - await ctx.send("Unable to get milestones") - return - - if len(milestones) == 0: - await ctx.send("No milestones exist") - return - - pages = [] - for i in range(0, len(milestones), 5): - pages.append(self.build_embed_page(milestones[i : i + 5], t_state=None, name="milestone")) # noqa: E203 - - paginator = Paginator( - bot=self.bot, - ctx=ctx, - embeds=pages, - only=ctx.author, - timeout=60 * 5, # 5 minute timeout - disable_after_timeout=True, - use_extend=len(pages) > 2, - left_button_style=ButtonStyle.grey, - right_button_style=ButtonStyle.grey, - basic_buttons=["◀", "▶"], - ) - - self.cache[hash(paginator)] = { - "user": ctx.author.id, - "guild": ctx.guild.id, - "timeout": datetime.utcnow() + timedelta(minutes=5), - "command": ctx.subcommand_name, - "paginator": paginator, - } - - await paginator.start() - - -def setup(bot: commands.Bot) -> None: - """Add GitlabCog to J.A.R.V.I.S. if Gitlab token exists.""" - if get_config().gitlab_token: - bot.add_cog(GitlabCog(bot)) diff --git a/jarvis/cogs/gl.py b/jarvis/cogs/gl.py new file mode 100644 index 0000000..cde44af --- /dev/null +++ b/jarvis/cogs/gl.py @@ -0,0 +1,471 @@ +"""JARVIS GitLab Cog.""" +import asyncio +import logging +from datetime import datetime + +import gitlab +from naff import Client, Cog, InteractionContext +from naff.ext.paginators import Paginator +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.modal import InputText, Modal, TextStyles +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + SlashCommandChoice, + slash_command, + slash_option, +) +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets + +from jarvis.config import JarvisConfig +from jarvis.utils import build_embed + +guild_ids = [862402786116763668] + + +class GitlabCog(Cog): + """JARVIS GitLab Cog.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + config = JarvisConfig.from_yaml() + self._gitlab = gitlab.Gitlab("https://git.zevaryx.com", private_token=config.gitlab_token) + # JARVIS GitLab ID is 29 + self.project = self._gitlab.projects.get(29) + + gl = SlashCommand(name="gl", description="Get GitLab info", scopes=guild_ids) + + @gl.subcommand( + sub_cmd_name="issue", + sub_cmd_description="Get an issue from GitLab", + ) + @slash_option(name="id", description="Issue ID", opt_type=OptionTypes.INTEGER, required=True) + async def _issue(self, ctx: InteractionContext, id: int) -> None: + try: + issue = self.project.issues.get(int(id)) + except gitlab.exceptions.GitlabGetError: + await ctx.send("Issue does not exist.", ephemeral=True) + return + assignee = issue.assignee + if assignee: + assignee = assignee["name"] + else: + assignee = "None" + + created_at = datetime.strptime(issue.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + + labels = issue.labels + if labels: + labels = "\n".join(issue.labels) + else: + labels = "None" + + fields = [ + EmbedField(name="State", value=issue.state.title()), + EmbedField(name="Assignee", value=assignee), + EmbedField(name="Labels", value=labels), + ] + color = "#FC6D27" + if issue.labels: + color = self.project.labels.get(issue.labels[0]).color + fields.append(EmbedField(name="Created At", value=created_at)) + if issue.state == "closed": + closed_at = datetime.strptime(issue.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + fields.append(EmbedField(name="Closed At", value=closed_at)) + if issue.milestone: + fields.append( + EmbedField( + name="Milestone", + value=f"[{issue.milestone['title']}]({issue.milestone['web_url']})", + inline=False, + ) + ) + if len(issue.title) > 200: + issue.title = issue.title[:200] + "..." + embed = build_embed( + title=f"[#{issue.iid}] {issue.title}", + description=issue.description, + fields=fields, + color=color, + url=issue.web_url, + ) + embed.set_author( + name=issue.author["name"], + icon_url=issue.author["avatar_url"], + url=issue.author["web_url"], + ) + embed.set_thumbnail( + url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png" + ) + await ctx.send(embed=embed) + + @gl.subcommand( + sub_cmd_name="milestone", + sub_cmd_description="Get a milestone from GitLab", + ) + @slash_option( + name="id", description="Milestone ID", opt_type=OptionTypes.INTEGER, required=True + ) + async def _milestone(self, ctx: InteractionContext, id: int) -> None: + try: + milestone = self.project.milestones.get(int(id)) + except gitlab.exceptions.GitlabGetError: + await ctx.send("Milestone does not exist.", ephemeral=True) + return + + created_at = datetime.strptime(milestone.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + + fields = [ + EmbedField( + name="State", + value=milestone.state[0].upper() + milestone.state[1:], + ), + EmbedField(name="Start Date", value=milestone.start_date), + EmbedField(name="Due Date", value=milestone.due_date), + EmbedField(name="Created At", value=created_at), + ] + + if milestone.updated_at: + updated_at = datetime.strptime(milestone.updated_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + fields.append(EmbedField(name="Updated At", value=updated_at)) + + if len(milestone.title) > 200: + milestone.title = milestone.title[:200] + "..." + + embed = build_embed( + title=f"[#{milestone.iid}] {milestone.title}", + description=milestone.description, + fields=fields, + color="#00FFEE", + url=milestone.web_url, + ) + embed.set_author( + name="JARVIS", + url="https://git.zevaryx.com/jarvis", + icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png", + ) + embed.set_thumbnail( + url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png" + ) + await ctx.send(embed=embed) + + @gl.subcommand( + sub_cmd_name="mr", + sub_cmd_description="Get a merge request from GitLab", + ) + @slash_option( + name="id", description="Merge Request ID", opt_type=OptionTypes.INTEGER, required=True + ) + async def _mergerequest(self, ctx: InteractionContext, id: int) -> None: + try: + mr = self.project.mergerequests.get(int(id)) + except gitlab.exceptions.GitlabGetError: + await ctx.send("Merge request does not exist.", ephemeral=True) + return + assignee = mr.assignee + if assignee: + assignee = assignee["name"] + else: + assignee = "None" + + created_at = datetime.strptime(mr.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + + labels = mr.labels + if labels: + labels = "\n".join(mr.labels) + if not labels: + labels = "None" + + fields = [ + EmbedField(name="State", value=mr.state[0].upper() + mr.state[1:], inline=True), + EmbedField(name="Draft?", value=str(mr.draft), inline=True), + EmbedField(name="Assignee", value=assignee, inline=True), + EmbedField(name="Labels", value=labels, inline=True), + ] + if mr.labels: + color = self.project.labels.get(mr.labels[0]).color + else: + color = "#00FFEE" + fields.append(EmbedField(name="Created At", value=created_at, inline=True)) + if mr.state == "merged": + merged_at = datetime.strptime(mr.merged_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + fields.append(EmbedField(name="Merged At", value=merged_at, inline=True)) + elif mr.state == "closed": + closed_at = datetime.strptime(mr.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime( + "%Y-%m-%d %H:%M:%S UTC" + ) + fields.append(EmbedField(name="Closed At", value=closed_at, inline=True)) + if mr.milestone: + fields.append( + EmbedField( + name="Milestone", + value=f"[{mr.milestone['title']}]({mr.milestone['web_url']})", + inline=False, + ) + ) + if len(mr.title) > 200: + mr.title = mr.title[:200] + "..." + embed = build_embed( + title=f"[#{mr.iid}] {mr.title}", + description=mr.description, + fields=fields, + color=color, + url=mr.web_url, + ) + embed.set_author( + name=mr.author["name"], + icon_url=mr.author["avatar_url"], + url=mr.author["web_url"], + ) + embed.set_thumbnail( + url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png" + ) + await ctx.send(embed=embed) + + def build_embed_page(self, api_list: list, t_state: str, name: str) -> Embed: + """Build an embed page for the paginator.""" + title = "" + if t_state: + title = f"{t_state} " + title += f"JARVIS {name}s" + fields = [] + for item in api_list: + description = item.description or "No description" + fields.append( + EmbedField( + name=f"[#{item.iid}] {item.title}", + value=(description[:200] + f"...\n\n[View this {name}]({item.web_url})"), + inline=False, + ) + ) + + embed = build_embed( + title=title, + description="", + fields=fields, + url=f"https://git.zevaryx.com/stark-industries/JARVIS/{name.replace(' ', '_')}s", + ) + embed.set_author( + name="JARVIS", + url="https://git.zevaryx.com/jarvis", + icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png", + ) + embed.set_thumbnail( + url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png" + ) + return embed + + @gl.subcommand( + sub_cmd_name="issues", + sub_cmd_description="Get issues from GitLab", + ) + @slash_option( + name="state", + description="State of issues to get", + opt_type=OptionTypes.STRING, + required=False, + choices=[ + SlashCommandChoice(name="Open", value="opened"), + SlashCommandChoice(name="Closed", value="closed"), + SlashCommandChoice(name="All", value="all"), + ], + ) + async def _issues(self, ctx: InteractionContext, state: str = "opened") -> None: + await ctx.defer() + m_state = state + if m_state == "all": + m_state = None + issues = [] + page = 1 + try: + while curr_page := self.project.issues.list( + page=page, + state=m_state, + order_by="created_at", + sort="desc", + per_page=100, + ): + issues += curr_page + page += 1 + except gitlab.exceptions.GitlabGetError: + # Only send error on first page. Otherwise, use pages retrieved + if page == 1: + await ctx.send("Unable to get issues") + return + + if len(issues) == 0: + await ctx.send("No issues match that criteria") + return + + t_state = state + if t_state == "opened": + t_state = "open" + pages = [] + t_state = t_state[0].upper() + t_state[1:] + for i in range(0, len(issues), 5): + pages.append(self.build_embed_page(issues[i : i + 5], t_state=t_state, name="issue")) + + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) + + await paginator.send(ctx) + + @gl.subcommand( + sub_cmd_name="mrs", + sub_cmd_description="Get merge requests from GitLab", + ) + @slash_option( + name="state", + description="State of merge requests to get", + opt_type=OptionTypes.STRING, + required=False, + choices=[ + SlashCommandChoice(name="Open", value="opened"), + SlashCommandChoice(name="Closed", value="closed"), + SlashCommandChoice(name="All", value="all"), + ], + ) + async def _mergerequests(self, ctx: InteractionContext, state: str = "opened") -> None: + await ctx.defer() + m_state = state + if m_state == "all": + m_state = None + merges = [] + page = 1 + try: + while curr_page := self.project.mergerequests.list( + page=page, + state=m_state, + order_by="created_at", + sort="desc", + per_page=100, + ): + merges += curr_page + page += 1 + except gitlab.exceptions.GitlabGetError: + # Only send error on first page. Otherwise, use pages retrieved + if page == 1: + await ctx.send("Unable to get merge requests") + return + + if len(merges) == 0: + await ctx.send("No merge requests match that criteria") + return + + t_state = state + if t_state == "opened": + t_state = "open" + pages = [] + t_state = t_state[0].upper() + t_state[1:] + for i in range(0, len(merges), 5): + pages.append( + self.build_embed_page(merges[i : i + 5], t_state=t_state, name="merge request") + ) + + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) + + await paginator.send(ctx) + + @gl.subcommand( + sub_cmd_name="milestones", + sub_cmd_description="Get milestones from GitLab", + ) + async def _milestones(self, ctx: InteractionContext) -> None: + await ctx.defer() + milestones = [] + page = 1 + try: + while curr_page := self.project.milestones.list( + page=page, + order_by="created_at", + sort="desc", + per_page=100, + ): + milestones += curr_page + page += 1 + except gitlab.exceptions.GitlabGetError: + # Only send error on first page. Otherwise, use pages retrieved + if page == 1: + await ctx.send("Unable to get milestones") + return + + if len(milestones) == 0: + await ctx.send("No milestones exist") + return + + pages = [] + for i in range(0, len(milestones), 5): + pages.append( + self.build_embed_page(milestones[i : i + 5], t_state=None, name="milestone") + ) + + paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300) + + await paginator.send(ctx) + + @slash_command(name="issue", description="Report an issue on GitLab", scopes=guild_ids) + @slash_option( + name="user", + description="Credit someone else for this issue", + opt_type=OptionTypes.USER, + required=False, + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=600) + async def _open_issue(self, ctx: InteractionContext, user: Member = None) -> None: + user = user or ctx.author + modal = Modal( + title="Open a new issue on GitLab", + components=[ + InputText( + label="Issue Title", + placeholder="Descriptive Title", + style=TextStyles.SHORT, + custom_id="title", + max_length=200, + ), + InputText( + label="Description (supports Markdown!)", + placeholder="Detailed Description", + style=TextStyles.PARAGRAPH, + custom_id="description", + ), + ], + ) + await ctx.send_modal(modal) + try: + resp = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5) + title = resp.responses.get("title") + desc = resp.responses.get("description") + except asyncio.TimeoutError: + return + if not title.startswith("[Discord]"): + title = "[Discord] " + title + desc = f"Opened by `@{user.username}` on Discord\n\n" + desc + issue = self.project.issues.create(data={"title": title, "description": desc}) + embed = build_embed( + title=f"Issue #{issue.id} Created", + description=("Thank you for opening an issue!\n\n[View it online]({issue['web_url']})"), + fields=[], + color="#00FFEE", + ) + await resp.send(embed=embed) + + +def setup(bot: Client) -> None: + """Add GitlabCog to JARVIS if Gitlab token exists.""" + if JarvisConfig.from_yaml().gitlab_token: + GitlabCog(bot) diff --git a/jarvis/cogs/image.py b/jarvis/cogs/image.py index 0b8a342..f44c9a7 100644 --- a/jarvis/cogs/image.py +++ b/jarvis/cogs/image.py @@ -1,106 +1,158 @@ -"""J.A.R.V.I.S. image processing cog.""" +"""JARVIS image processing cog.""" +import logging import re from io import BytesIO import aiohttp import cv2 import numpy as np -from discord import File -from discord.ext import commands +from jarvis_core.util import convert_bytesize, unconvert_bytesize +from naff import Client, Cog, InteractionContext +from naff.models.discord.embed import EmbedField +from naff.models.discord.file import File +from naff.models.discord.message import Attachment +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) -from jarvis.utils import build_embed, convert_bytesize, unconvert_bytesize -from jarvis.utils.field import Field +from jarvis.utils import build_embed + +MIN_ACCURACY = 0.80 -class ImageCog(commands.Cog): +class ImageCog(Cog): """ - Image processing functions for J.A.R.V.I.S. + Image processing functions for JARVIS May be categorized under util later """ - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) self._session = aiohttp.ClientSession() - self.tgt_match = re.compile(r"([0-9]*\.?[0-9]*?) ?([KMGTP]?B)", re.IGNORECASE) + self.tgt_match = re.compile(r"([0-9]*\.?[0-9]*?) ?([KMGTP]?B?)", re.IGNORECASE) def __del__(self): self._session.close() - async def _resize(self, ctx: commands.Context, target: str, url: str = None) -> None: - if not target: - await ctx.send("Missing target size, i.e. 200KB.") + @slash_command(name="resize", description="Resize an image") + @slash_option( + name="target", + description="Target size, i.e. 200KB", + opt_type=OptionTypes.STRING, + required=True, + ) + @slash_option( + name="attachment", + description="Image to resize", + opt_type=OptionTypes.ATTACHMENT, + required=False, + ) + @slash_option( + name="url", + description="URL to download and resize", + opt_type=OptionTypes.STRING, + required=False, + ) + async def _resize( + self, ctx: InteractionContext, target: str, attachment: Attachment = None, url: str = None + ) -> None: + await ctx.defer() + if not attachment and not url: + await ctx.send("A URL or attachment is required", ephemeral=True) + return + + if attachment and not attachment.content_type.startswith("image"): + await ctx.send("Attachment must be an image", ephemeral=True) return tgt = self.tgt_match.match(target) if not tgt: - await ctx.send(f"Invalid target format ({target}). Expected format like 200KB") + await ctx.send( + f"Invalid target format ({target}). Expected format like 200KB", ephemeral=True + ) + return + + try: + tgt_size = unconvert_bytesize(float(tgt.groups()[0]), tgt.groups()[1]) + except ValueError: + await ctx.send("Failed to read your target size. Try a more sane one", ephemeral=True) return - tgt_size = unconvert_bytesize(float(tgt.groups()[0]), tgt.groups()[1]) if tgt_size > unconvert_bytesize(8, "MB"): - await ctx.send("Target too large to send. Please make target < 8MB") + await ctx.send("Target too large to send. Please make target < 8MB", ephemeral=True) + return + if tgt_size < 1024: + await ctx.send("Sizes < 1KB are extremely unreliable and are disabled", ephemeral=True) return - file = None - filename = None - if ctx.message.attachments is not None and len(ctx.message.attachments) > 0: - file = await ctx.message.attachments[0].read() - filename = ctx.message.attachments[0].filename - elif url is not None: - async with self._session.get(url) as resp: - if resp.status == 200: - file = await resp.read() - filename = url.split("/")[-1] - else: - ctx.send("Missing file as either attachment or URL.") - size = len(file) + if attachment: + url = attachment.url + filename = attachment.filename + else: + filename = url.split("/")[-1] + + data = None + try: + async with self._session.get(url) as resp: + resp.raise_for_status() + if resp.content_type in ["image/jpeg", "image/png"]: + data = await resp.read() + else: + await ctx.send( + "Unsupported content type. Please send a URL to a JPEG or PNG", + ephemeral=True, + ) + return + except Exception: + await ctx.send("Failed to retrieve image. Please verify url", ephemeral=True) + return + + size = len(data) if size <= tgt_size: - await ctx.send("Image already meets target.") + await ctx.send("Image already meets target.", ephemeral=True) return ratio = max(tgt_size / size - 0.02, 0.50) accuracy = 0.0 - # TODO: Optimize to not run multiple times - while len(file) > tgt_size or (len(file) <= tgt_size and accuracy < 0.65): - old_file = file - buffer = np.frombuffer(file, dtype=np.uint8) + while len(data) > tgt_size or (len(data) <= tgt_size and accuracy < MIN_ACCURACY): + old_file = data + + buffer = np.frombuffer(data, dtype=np.uint8) img = cv2.imdecode(buffer, flags=-1) width = int(img.shape[1] * ratio) height = int(img.shape[0] * ratio) new_img = cv2.resize(img, (width, height)) - file = cv2.imencode(".png", new_img)[1].tobytes() - accuracy = (len(file) / tgt_size) * 100 + data = cv2.imencode(".png", new_img)[1].tobytes() + accuracy = (len(data) / tgt_size) * 100 if accuracy <= 0.50: - file = old_file + data = old_file ratio += 0.1 else: - ratio = max(tgt_size / len(file) - 0.02, 0.65) + ratio = max(tgt_size / len(data) - 0.02, 0.65) - bufio = BytesIO(file) - accuracy = (len(file) / tgt_size) * 100 + bufio = BytesIO(data) + accuracy = (len(data) / tgt_size) * 100 fields = [ - Field("Original Size", convert_bytesize(size), False), - Field("New Size", convert_bytesize(len(file)), False), - Field("Accuracy", f"{accuracy:.02f}%", False), + EmbedField("Original Size", convert_bytesize(size), False), + EmbedField("New Size", convert_bytesize(len(data)), False), + EmbedField("Accuracy", f"{accuracy:.02f}%", False), ] embed = build_embed(title=filename, description="", fields=fields) embed.set_image(url="attachment://resized.png") await ctx.send( embed=embed, - file=File(bufio, filename="resized.png"), + file=File(file=bufio, file_name="resized.png"), ) - @commands.command(name="resize", help="Resize an image") - @commands.cooldown(1, 60, commands.BucketType.user) - async def _resize_pref(self, ctx: commands.Context, target: str, url: str = None) -> None: - await self._resize(ctx, target, url) - -def setup(bot: commands.Bot) -> None: - """Add ImageCog to J.A.R.V.I.S.""" - bot.add_cog(ImageCog(bot)) +def setup(bot: Client) -> None: + """Add ImageCog to JARVIS""" + ImageCog(bot) diff --git a/jarvis/cogs/jokes.py b/jarvis/cogs/jokes.py deleted file mode 100644 index b298a18..0000000 --- a/jarvis/cogs/jokes.py +++ /dev/null @@ -1,114 +0,0 @@ -"""J.A.R.V.I.S. Jokes module.""" -import html -import re -import traceback -from datetime import datetime -from random import randint - -from discord.ext import commands -from discord_slash import SlashContext, cog_ext - -from jarvis.db.models import Joke -from jarvis.utils import build_embed -from jarvis.utils.field import Field - - -class JokeCog(commands.Cog): - """ - Joke library for J.A.R.V.I.S. - - May adapt over time to create jokes using machine learning - """ - - def __init__(self, bot: commands.Bot): - self.bot = bot - - # TODO: Make this a command group with subcommands - @cog_ext.cog_slash( - name="joke", - description="Hear a joke", - ) - @commands.cooldown(1, 10, commands.BucketType.channel) - async def _joke(self, ctx: SlashContext, id: str = None) -> None: - """Get a joke from the database.""" - try: - if randint(1, 100_000) == 5779 and id is None: # noqa: S311 - await ctx.send(f"<@{ctx.message.author.id}>") - return - # TODO: Add this as a parameter that can be passed in - threshold = 500 # Minimum score - result = None - if id: - result = Joke.objects(rid=id).first() - else: - pipeline = [ - {"$match": {"score": {"$gt": threshold}}}, - {"$sample": {"size": 1}}, - ] - result = Joke.objects().aggregate(pipeline).next() - while result["body"] in ["[removed]", "[deleted]"]: - result = Joke.objects().aggregate(pipeline).next() - - if result is None: - await ctx.send("Humor module failed. Please try again later.", hidden=True) - return - emotes = re.findall(r"(&#x[a-fA-F0-9]*;)", result["body"]) - for match in emotes: - result["body"] = result["body"].replace(match, html.unescape(match)) - emotes = re.findall(r"(&#x[a-fA-F0-9]*;)", result["title"]) - for match in emotes: - result["title"] = result["title"].replace(match, html.unescape(match)) - body_chunks = [] - - body = "" - for word in result["body"].split(" "): - if len(body) + 1 + len(word) > 1024: - body_chunks.append(Field("​", body, False)) - body = "" - if word == "\n" and body == "": - continue - elif word == "\n": - body += word - else: - body += " " + word - - desc = "" - title = result["title"] - if len(title) > 256: - new_title = "" - limit = False - for word in title.split(" "): - if len(new_title) + len(word) + 1 > 253 and not limit: - new_title += "..." - desc = "..." - limit = True - if not limit: - new_title += word + " " - else: - desc += word + " " - - body_chunks.append(Field("​", body, False)) - - fields = body_chunks - fields.append(Field("Score", result["score"])) - # Field( - # "Created At", - # str(datetime.fromtimestamp(result["created_utc"])), - # ), - fields.append(Field("ID", result["rid"])) - embed = build_embed( - title=title, - description=desc, - fields=fields, - url=f"https://reddit.com/r/jokes/comments/{result['rid']}", - timestamp=datetime.fromtimestamp(result["created_utc"]), - ) - await ctx.send(embed=embed) - except Exception: - await ctx.send("Encountered error:\n```\n" + traceback.format_exc() + "\n```") - # await ctx.send(f"**{result['title']}**\n\n{result['body']}") - - -def setup(bot: commands.Bot) -> None: - """Add JokeCog to J.A.R.V.I.S.""" - bot.add_cog(JokeCog(bot)) diff --git a/jarvis/cogs/modlog/__init__.py b/jarvis/cogs/modlog/__init__.py deleted file mode 100644 index 9e72344..0000000 --- a/jarvis/cogs/modlog/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""J.A.R.V.I.S. Modlog Cogs.""" -from discord.ext.commands import Bot - -from jarvis.cogs.modlog import command, member, message - - -def setup(bot: Bot) -> None: - """Add modlog cogs to J.A.R.V.I.S.""" - bot.add_cog(command.ModlogCommandCog(bot)) - bot.add_cog(member.ModlogMemberCog(bot)) - bot.add_cog(message.ModlogMessageCog(bot)) diff --git a/jarvis/cogs/modlog/command.py b/jarvis/cogs/modlog/command.py deleted file mode 100644 index e5ccd4b..0000000 --- a/jarvis/cogs/modlog/command.py +++ /dev/null @@ -1,49 +0,0 @@ -"""J.A.R.V.I.S. ModlogCommandCog.""" -from discord import DMChannel -from discord.ext import commands -from discord_slash import SlashContext - -from jarvis.db.models import Setting -from jarvis.utils import build_embed -from jarvis.utils.field import Field - - -class ModlogCommandCog(commands.Cog): - """J.A.R.V.I.S. ModlogCommandCog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.Cog.listener() - async def on_slash_command(self, ctx: SlashContext) -> None: - """Process on_slash_command events.""" - if not isinstance(ctx.channel, DMChannel) and ctx.name not in ["pw"]: - modlog = Setting.objects(guild=ctx.guild.id, setting="modlog").first() - if modlog: - channel = ctx.guild.get_channel(modlog.value) - fields = [ - Field("Command", ctx.name), - ] - if ctx.kwargs: - kwargs_string = " ".join(f"{k}: {str(ctx.kwargs[k])}" for k in ctx.kwargs) - fields.append( - Field( - "Keyword Args", - kwargs_string, - False, - ) - ) - if ctx.subcommand_name: - fields.insert(1, Field("Subcommand", ctx.subcommand_name)) - embed = build_embed( - title="Command Invoked", - description=f"{ctx.author.mention} invoked a command", - fields=fields, - color="#fc9e3f", - ) - embed.set_author( - name=ctx.author.name, - icon_url=ctx.author.avatar_url, - ) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") - await channel.send(embed=embed) diff --git a/jarvis/cogs/modlog/member.py b/jarvis/cogs/modlog/member.py deleted file mode 100644 index bd6cf20..0000000 --- a/jarvis/cogs/modlog/member.py +++ /dev/null @@ -1,333 +0,0 @@ -"""J.A.R.V.I.S. ModlogMemberCog.""" -import asyncio -from datetime import datetime, timedelta - -import discord -from discord.ext import commands -from discord.utils import find - -from jarvis.cogs.modlog.utils import get_latest_log, modlog_embed -from jarvis.config import get_config -from jarvis.db.models import Ban, Kick, Mute, Setting, Unban -from jarvis.utils import build_embed -from jarvis.utils.field import Field - - -class ModlogMemberCog(commands.Cog): - """J.A.R.V.I.S. ModlogMemberCog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.cache = [] - - @commands.Cog.listener() - async def on_member_ban(self, guild: discord.Guild, user: discord.User) -> None: - """Process on_member_ban events.""" - modlog = Setting.objects(guild=guild.id, setting="modlog").first() - if modlog: - channel = guild.get_channel(modlog.value) - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await guild.audit_logs( - limit=50, - action=discord.AuditLogAction.ban, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, user) - admin: discord.User = log.user - if admin.id == get_config().client_id: - await asyncio.sleep(3) - ban = ( - Ban.objects( - guild=guild.id, - user=user.id, - active=True, - ) - .order_by("-created_at") - .first() - ) - if ban: - admin = guild.get_member(ban.admin) - embed = modlog_embed( - user, - admin, - log, - "User banned", - f"{user.mention} was banned from {guild.name}", - ) - - await channel.send(embed=embed) - - @commands.Cog.listener() - async def on_member_unban(self, guild: discord.Guild, user: discord.User) -> None: - """Process on_member_unban events.""" - modlog = Setting.objects(guild=guild.id, setting="modlog").first() - if modlog: - channel = guild.get_channel(modlog.value) - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await guild.audit_logs( - limit=50, - action=discord.AuditLogAction.unban, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, user) - admin: discord.User = log.user - if admin.id == get_config().client_id: - await asyncio.sleep(3) - unban = ( - Unban.objects( - guild=guild.id, - user=user.id, - ) - .order_by("-created_at") - .first() - ) - admin = guild.get_member(unban.admin) - embed = modlog_embed( - user, - admin, - log, - "User unbanned", - f"{user.mention} was unbanned from {guild.name}", - ) - - await channel.send(embed=embed) - - @commands.Cog.listener() - async def on_member_remove(self, user: discord.Member) -> None: - """Process on_member_remove events.""" - modlog = Setting.objects(guild=user.guild.id, setting="modlog").first() - if modlog: - channel = user.guild.get_channel(modlog.value) - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await user.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.kick, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - count = 0 - log: discord.AuditLogEntry = get_latest_log(auditlog, user) - while not log: - if count == 30: - break - await asyncio.sleep(0.5) - log: discord.AuditLogEntry = get_latest_log(auditlog, user) - count += 1 - if not log: - return - admin: discord.User = log.user - if admin.id == get_config().client_id: - await asyncio.sleep(3) - kick = ( - Kick.objects( - guild=user.guild.id, - user=user.id, - ) - .order_by("-created_at") - .first() - ) - if kick: - admin = user.guild.get_member(kick.admin) - embed = modlog_embed( - user, - admin, - log, - "User Kicked", - f"{user.mention} was kicked from {user.guild.name}", - ) - - await channel.send(embed=embed) - - async def process_mute(self, before: discord.Member, after: discord.Member) -> discord.Embed: - """Process mute event.""" - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await before.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.member_role_update, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, before) - admin: discord.User = log.user - if admin.id == get_config().client_id: - await asyncio.sleep(3) - mute = ( - Mute.objects( - guild=before.guild.id, - user=before.id, - active=True, - ) - .order_by("-created_at") - .first() - ) - if mute: - admin = before.guild.get_member(mute.admin) - return modlog_embed( - member=before, - admin=admin, - log=log, - title="User Muted", - desc=f"{before.mention} was muted", - ) - - async def process_unmute(self, before: discord.Member, after: discord.Member) -> discord.Embed: - """Process unmute event.""" - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await before.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.member_role_update, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, before) - admin: discord.User = log.user - if admin.id == get_config().client_id: - await asyncio.sleep(3) - mute = ( - Mute.objects( - guild=before.guild.id, - user=before.id, - active=True, - ) - .order_by("-created_at") - .first() - ) - if mute: - admin = before.guild.get_member(mute.admin) - return modlog_embed( - member=before, - admin=admin, - log=log, - title="User Muted", - desc=f"{before.mention} was muted", - ) - - async def process_verify(self, before: discord.Member, after: discord.Member) -> discord.Embed: - """Process verification event.""" - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await before.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.member_role_update, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, before) - admin: discord.User = log.user - return modlog_embed( - member=before, - admin=admin, - log=log, - title="User Verified", - desc=f"{before.mention} was verified", - ) - - async def process_rolechange(self, before: discord.Member, after: discord.Member) -> discord.Embed: - """Process rolechange event.""" - await asyncio.sleep(0.5) # Need to wait for audit log - auditlog = await before.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.member_role_update, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, before) - admin: discord.User = log.user - role = None - title = "User Given Role" - verb = "was given" - if len(before.roles) > len(after.roles): - title = "User Forfeited Role" - verb = "forfeited" - role = find(lambda x: x not in after.roles, before.roles) - elif len(before.roles) < len(after.roles): - role = find(lambda x: x not in before.roles, after.roles) - role_text = role.mention if role else "||`[redacted]`||" - return modlog_embed( - member=before, - admin=admin, - log=log, - title=title, - desc=f"{before.mention} {verb} role {role_text}", - ) - - @commands.Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: - """Process on_member_update events. - - Caches events due to double-send bug - """ - h = hash(hash(before) * hash(after)) - if h not in self.cache: - self.cache.append(h) - else: - return - modlog = Setting.objects(guild=before.guild.id, setting="modlog").first() - if modlog: - channel = after.guild.get_channel(modlog.value) - await asyncio.sleep(0.5) # Need to wait for audit log - embed = None - mute = Setting.objects(guild=before.guild.id, setting="mute").first() - verified = Setting.objects(guild=before.guild.id, setting="verified").first() - mute_role = None - verified_role = None - if mute: - mute_role = before.guild.get_role(mute.value) - if verified: - verified_role = before.guild.get_role(verified.value) - if mute and mute_role in after.roles and mute_role not in before.roles: - embed = await self.process_mute(before, after) - elif mute and mute_role in before.roles and mute_role not in after.roles: - embed = await self.process_unmute(before, after) - elif verified and verified_role not in before.roles and verified_role in after.roles: - embed = await self.process_verify(before, after) - elif before.nick != after.nick: - auditlog = await before.guild.audit_logs( - limit=50, - action=discord.AuditLogAction.member_update, - after=datetime.utcnow() - timedelta(seconds=15), - oldest_first=False, - ).flatten() - log: discord.AuditLogEntry = get_latest_log(auditlog, before) - bname = before.nick if before.nick else before.name - aname = after.nick if after.nick else after.name - fields = [ - Field( - name="Before", - value=f"{bname} ({before.name}#{before.discriminator})", - ), - Field( - name="After", - value=f"{aname} ({after.name}#{after.discriminator})", - ), - ] - if log.user.id != before.id: - fields.append( - Field( - name="Moderator", - value=f"{log.user.mention} ({log.user.name}#{log.user.discriminator})", - ) - ) - if log.reason: - fields.append( - Field(name="Reason", value=log.reason, inline=False), - ) - embed = build_embed( - title="User Nick Changed", - description=f"{after.mention} changed their nickname", - color="#fc9e3f", - fields=fields, - timestamp=log.created_at, - ) - embed.set_author( - name=f"{after.name}", - icon_url=after.avatar_url, - ) - embed.set_footer(text=f"{after.name}#{after.discriminator} | {after.id}") - elif len(before.roles) != len(after.roles): - # TODO: User got a new role - embed = await self.process_rolechange(before, after) - if embed: - await channel.send(embed=embed) - self.cache.remove(h) diff --git a/jarvis/cogs/modlog/message.py b/jarvis/cogs/modlog/message.py deleted file mode 100644 index 8c779a6..0000000 --- a/jarvis/cogs/modlog/message.py +++ /dev/null @@ -1,104 +0,0 @@ -"""J.A.R.V.I.S. ModlogMessageCog.""" -import discord -from discord.ext import commands - -from jarvis.db.models import Setting -from jarvis.utils import build_embed -from jarvis.utils.field import Field - - -class ModlogMessageCog(commands.Cog): - """J.A.R.V.I.S. ModlogMessageCog.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @commands.Cog.listener() - async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None: - """Process on_message_edit events.""" - if not before.author.bot: - modlog = Setting.objects(guild=after.guild.id, setting="modlog").first() - if modlog: - if before.content == after.content or before.content is None: - return - channel = before.guild.get_channel(modlog.value) - fields = [ - Field( - "Original Message", - before.content if before.content else "N/A", - False, - ), - Field( - "New Message", - after.content if after.content else "N/A", - False, - ), - ] - embed = build_embed( - title="Message Edited", - description=f"{before.author.mention} edited a message", - fields=fields, - color="#fc9e3f", - timestamp=after.edited_at, - url=after.jump_url, - ) - embed.set_author( - name=before.author.name, - icon_url=before.author.avatar_url, - url=after.jump_url, - ) - embed.set_footer(text=f"{before.author.name}#{before.author.discriminator} | {before.author.id}") - await channel.send(embed=embed) - - @commands.Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: - """Process on_message_delete events.""" - modlog = Setting.objects(guild=message.guild.id, setting="modlog").first() - if modlog: - fields = [Field("Original Message", message.content or "N/A", False)] - - if message.attachments: - value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments]) - fields.append( - Field( - name="Attachments", - value=value, - inline=False, - ) - ) - - if message.stickers: - value = "\n".join([f"[{x.name}]({x.image_url})" for x in message.stickers]) - fields.append( - Field( - name="Stickers", - value=value, - inline=False, - ) - ) - - if message.embeds: - value = str(len(message.embeds)) + " embeds" - fields.append( - Field( - name="Embeds", - value=value, - inline=False, - ) - ) - - channel = message.guild.get_channel(modlog.value) - embed = build_embed( - title="Message Deleted", - description=f"{message.author.mention}'s message was deleted", - fields=fields, - color="#fc9e3f", - ) - - embed.set_author( - name=message.author.name, - icon_url=message.author.avatar_url, - url=message.jump_url, - ) - embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}") - await channel.send(embed=embed) diff --git a/jarvis/cogs/modlog/utils.py b/jarvis/cogs/modlog/utils.py deleted file mode 100644 index 8831016..0000000 --- a/jarvis/cogs/modlog/utils.py +++ /dev/null @@ -1,50 +0,0 @@ -"""J.A.R.V.I.S. Modlog Cog Utilities.""" -from datetime import datetime, timedelta -from typing import List - -import discord -from discord import AuditLogEntry, Member -from discord.utils import find - -from jarvis.utils import build_embed -from jarvis.utils.field import Field - - -def modlog_embed( - member: discord.Member, - admin: discord.Member, - log: discord.AuditLogEntry, - title: str, - desc: str, -) -> discord.Embed: - """Get modlog embed.""" - fields = [ - Field( - name="Moderator", - value=f"{admin.mention} ({admin.name}#{admin.discriminator})", - ), - ] - if log.reason: - fields.append(Field(name="Reason", value=log.reason, inline=False)) - embed = build_embed( - title=title, - description=desc, - color="#fc9e3f", - fields=fields, - timestamp=log.created_at, - ) - embed.set_author( - name=f"{member.name}", - icon_url=member.avatar_url, - ) - embed.set_footer(text=f"{member.name}#{member.discriminator} | {member.id}") - return embed - - -def get_latest_log(auditlog: List[AuditLogEntry], target: Member) -> AuditLogEntry: - """Filter AuditLog to get latest entry.""" - before = datetime.utcnow() - timedelta(seconds=10) - return find( - lambda x: x.target.id == target.id and x.created_at > before, - auditlog, - ) diff --git a/jarvis/cogs/owner.py b/jarvis/cogs/owner.py deleted file mode 100644 index 7979ad0..0000000 --- a/jarvis/cogs/owner.py +++ /dev/null @@ -1,246 +0,0 @@ -"""J.A.R.V.I.S. Owner Cog.""" -import os -import sys -import traceback -from inspect import getsource -from time import time -from typing import Any - -import discord -from discord import DMChannel, User -from discord.ext import commands - -import jarvis -from jarvis.config import reload_config -from jarvis.db.models import Config -from jarvis.utils import update -from jarvis.utils.permissions import user_is_bot_admin - - -class OwnerCog(commands.Cog): - """ - J.A.R.V.I.S. management cog. - - Used by admins to control core J.A.R.V.I.S. systems - """ - - def __init__(self, bot: commands.Cog): - self.bot = bot - self.admins = Config.objects(key="admins").first() - - @commands.command(name="load", hidden=True) - @user_is_bot_admin() - async def _load_cog(self, ctx: commands.Context, *, cog: str) -> None: - info = await self.bot.application_info() - if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value: - try: - if "jarvis.cogs." not in cog: - cog = "jarvis.cogs." + cog.split(".")[-1] - self.bot.load_extension(cog) - except commands.errors.ExtensionAlreadyLoaded: - await ctx.send(f"Cog `{cog}` already loaded") - except Exception as e: - await ctx.send(f"Failed to load new cog `{cog}`: {type(e).name} - {e}") - else: - await ctx.send(f"Successfully loaded new cog `{cog}`") - else: - await ctx.send("I'm afraid I can't let you do that") - - @commands.command(name="unload", hidden=True) - @user_is_bot_admin() - async def _unload_cog(self, ctx: commands.Context, *, cog: str) -> None: - if cog in ["jarvis.cogs.owner", "owner"]: - await ctx.send("Cannot unload `owner` cog") - return - info = await self.bot.application_info() - if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value: - try: - if "jarvis.cogs." not in cog: - cog = "jarvis.cogs." + cog.split(".")[-1] - self.bot.unload_extension(cog) - except commands.errors.ExtensionNotLoaded: - await ctx.send(f"Cog `{cog}` not loaded") - except Exception as e: - await ctx.send(f"Failed to unload cog `{cog}` {type(e).__name__} - {e}") - else: - await ctx.send(f"Successfully unloaded cog `{cog}`") - else: - await ctx.send("I'm afraid I can't let you do that") - - @commands.command(name="reload", hidden=True) - @user_is_bot_admin() - async def _cog_reload(self, ctx: commands.Context, *, cog: str) -> None: - if cog in ["jarvis.cogs.owner", "owner"]: - await ctx.send("Cannot reload `owner` cog") - return - info = await self.bot.application_info() - if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value: - try: - if "jarvis.cogs." not in cog: - cog = "jarvis.cogs." + cog.split(".")[-1] - try: - self.bot.load_extension(cog) - except commands.errors.ExtensionNotLoaded: - pass - self.bot.unload_extension(cog) - except Exception as e: - await ctx.send(f"Failed to reload cog `{cog}` {type(e).__name__} - {e}") - else: - await ctx.send(f"Successfully reloaded cog `{cog}`") - else: - await ctx.send("I'm afraid I can't let you do that") - - @commands.group(name="system", hidden=True, pass_context=True) - @user_is_bot_admin() - async def _system(self, ctx: commands.Context) -> None: - if ctx.invoked_subcommand is None: - await ctx.send("Usage: `system `\n" + "Subcommands: `restart`, `update`") - - @_system.command(name="restart", hidden=True) - @user_is_bot_admin() - async def _restart(self, ctx: commands.Context) -> None: - info = await self.bot.application_info() - if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value: - await ctx.send("Restarting core systems...") - if isinstance(ctx.channel, discord.channel.DMChannel): - jarvis.restart_ctx = { - "user": ctx.message.author.id, - "channel": ctx.channel.id, - } - else: - jarvis.restart_ctx = { - "guild": ctx.message.guild.id, - "channel": ctx.channel.id, - } - await self.bot.close() - else: - await ctx.send("I'm afraid I can't let you do that") - - @_system.command(name="update", hidden=True) - @user_is_bot_admin() - async def _update(self, ctx: commands.Context) -> None: - info = await self.bot.application_info() - if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value: - await ctx.send("Updating core systems...") - status = update() - if status == 0: - await ctx.send("Core systems updated. Restarting...") - if isinstance(ctx.channel, discord.channel.DMChannel): - jarvis.restart_ctx = { - "user": ctx.message.author.id, - "channel": ctx.channel.id, - } - else: - jarvis.restart_ctx = { - "guild": ctx.message.guild.id, - "channel": ctx.channel.id, - } - await self.bot.close() - elif status == 1: - await ctx.send("Core systems already up to date.") - elif status == 2: - await ctx.send("Core system update available, but core is dirty.") - else: - await ctx.send("I'm afraid I can't let you do that") - - @_system.command(name="refresh", hidden=True) - @user_is_bot_admin() - async def _refresh(self, ctx: commands.Context) -> None: - reload_config() - await ctx.send("System refreshed") - - @commands.group(name="admin", hidden=True, pass_context=True) - @commands.is_owner() - async def _admin(self, ctx: commands.Context) -> None: - if ctx.invoked_subcommand is None: - await ctx.send("Usage: `admin `\n" + "Subcommands: `add`, `remove`") - - @_admin.command(name="add", hidden=True) - @commands.is_owner() - async def _add(self, ctx: commands.Context, user: User) -> None: - if user.id in self.admins.value: - await ctx.send(f"{user.mention} is already an admin.") - return - self.admins.value.append(user.id) - self.admins.save() - reload_config() - await ctx.send(f"{user.mention} is now an admin. Use this power carefully.") - - @_admin.command(name="remove", hidden=True) - @commands.is_owner() - async def _remove(self, ctx: commands.Context, user: User) -> None: - if user.id not in self.admins.value: - await ctx.send(f"{user.mention} is not an admin.") - return - self.admins.value.remove(user.id) - self.admins.save() - reload_config() - await ctx.send(f"{user.mention} is no longer an admin.") - - def resolve_variable(self, variable: Any) -> Any: - """Resolve a variable from eval.""" - if hasattr(variable, "__iter__"): - var_length = len(list(variable)) - if (var_length > 100) and (not isinstance(variable, str)): - return f"" - elif not var_length: - return f"" - - if (not variable) and (not isinstance(variable, bool)): - return f"" - return ( - variable - if (len(f"{variable}") <= 1000) - else f"" - ) - - def prepare(self, string: str) -> str: - """Prepare string for eval.""" - arr = string.strip("```").replace("py\n", "").replace("python\n", "").split("\n") - if not arr[::-1][0].replace(" ", "").startswith("return"): - arr[len(arr) - 1] = "return " + arr[::-1][0] - return "".join(f"\n\t{i}" for i in arr) - - @commands.command(pass_context=True, aliases=["eval", "exec", "evaluate"]) - @user_is_bot_admin() - async def _eval(self, ctx: commands.Context, *, code: str) -> None: - if not isinstance(ctx.message.channel, DMChannel): - return - code = self.prepare(code) - args = { - "discord": discord, - "sauce": getsource, - "sys": sys, - "os": os, - "imp": __import__, - "this": self, - "ctx": ctx, - } - - try: - exec( # noqa: S102 - f"async def func():{code}", - globals().update(args), - locals(), - ) - a = time() - response = await eval("func()", globals().update(args), locals()) # noqa: S307 - if response is None or isinstance(response, discord.Message): - del args, code - return - - if isinstance(response, str): - response = response.replace("`", "") - - await ctx.send( - f"```py\n{self.resolve_variable(response)}```\n`{type(response).__name__} | {(time() - a) / 1000} ms`" - ) - except Exception: - await ctx.send(f"Error occurred:```\n{traceback.format_exc()}```") - - del args, code - - -def setup(bot: commands.Bot) -> None: - """Add OwnerCog to J.A.R.V.I.S.""" - bot.add_cog(OwnerCog(bot)) diff --git a/jarvis/cogs/reddit.py b/jarvis/cogs/reddit.py new file mode 100644 index 0000000..f292f78 --- /dev/null +++ b/jarvis/cogs/reddit.py @@ -0,0 +1,425 @@ +"""JARVIS Reddit cog.""" +import asyncio +import logging +from typing import List, Optional + +from asyncpraw import Reddit +from asyncpraw.models.reddit.submission import Submission +from asyncpraw.models.reddit.submission import Subreddit as Sub +from asyncprawcore.exceptions import Forbidden, NotFound, Redirect +from jarvis_core.db import q +from jarvis_core.db.models import Subreddit, SubredditFollow +from naff import Client, Cog, InteractionContext, Permissions +from naff.client.utils.misc_utils import get +from naff.models.discord.channel import ChannelTypes, GuildText +from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.embed import Embed, EmbedField +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + SlashCommandChoice, + slash_option, +) +from naff.models.naff.command import check + +from jarvis import const +from jarvis.config import JarvisConfig +from jarvis.utils import build_embed +from jarvis.utils.permissions import admin_or_permissions + +DEFAULT_USER_AGENT = f"python:JARVIS:{const.__version__} (by u/zevaryx)" + + +class RedditCog(Cog): + """JARVIS Reddit Cog.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + config = JarvisConfig.from_yaml() + config.reddit["user_agent"] = config.reddit.get("user_agent", DEFAULT_USER_AGENT) + self.api = Reddit(**config.reddit) + + async def post_embeds(self, sub: Sub, post: Submission) -> Optional[List[Embed]]: + """ + Build a post embeds. + + Args: + post: Post to build embeds + """ + url = "https://reddit.com" + post.permalink + await post.author.load() + author_url = f"https://reddit.com/u/{post.author.name}" + author_icon = post.author.icon_img + images = [] + title = f"{post.title}" + fields = [] + content = "" + og_post = None + if not post.is_self: + og_post = post # noqa: F841 + post = await self.api.submission(post.crosspost_parent_list[0]["id"]) + await post.load() + fields.append(EmbedField(name="Crossposted From", value=post.subreddit_name_prefixed)) + content = f"> **{post.title}**" + if "url" in vars(post): + if any(post.url.endswith(x) for x in ["jpeg", "jpg", "png", "gif"]): + images = [post.url] + if "media_metadata" in vars(post): + for k, v in post.media_metadata.items(): + if v["status"] != "valid" or v["m"] not in ["image/jpg", "image/png", "image/gif"]: + continue + ext = v["m"].split("/")[-1] + i_url = f"https://i.redd.it/{k}.{ext}" + images.append(i_url) + if len(images) == 4: + break + + if "selftext" in vars(post) and post.selftext: + content += "\n\n" + post.selftext + if len(content) > 900: + content = content[:900] + "..." + content += f"\n\n[View this post]({url})" + + if not images and not content: + self.logger.debug(f"Post {post.id} had neither content nor images?") + return None + + color = "#FF4500" + if "primary_color" in vars(sub): + color = sub.primary_color + base_embed = build_embed( + title=title, + description=content, + fields=fields, + timestamp=post.created_utc, + url=url, + color=color, + ) + base_embed.set_author( + name="u/" + post.author.name, url=author_url, icon_url=author_icon + ) + base_embed.set_footer( + text="Reddit", icon_url="https://www.redditinc.com/assets/images/site/reddit-logo.png" + ) + + embeds = [base_embed] + + if len(images) > 0: + embeds[0].set_image(url=images[0]) + for image in images[1:4]: + embed = Embed(url=url) + embed.set_image(url=image) + embeds.append(embed) + + return embeds + + reddit = SlashCommand(name="reddit", description="Manage Reddit follows") + + @reddit.subcommand(sub_cmd_name="follow", sub_cmd_description="Follow a Subreddit") + @slash_option( + name="name", + description="Subreddit display name", + opt_type=OptionTypes.STRING, + required=True, + ) + @slash_option( + name="channel", + description="Channel to post to", + opt_type=OptionTypes.CHANNEL, + channel_types=[ChannelTypes.GUILD_TEXT], + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _reddit_follow(self, ctx: InteractionContext, name: str, channel: GuildText) -> None: + name = name.replace("r/", "") + if len(name) > 20 or len(name) < 3: + await ctx.send("Invalid Subreddit name", ephemeral=True) + return + + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a text channel", ephemeral=True) + return + + try: + subreddit = await self.api.subreddit(name) + await subreddit.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} on add") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + + exists = await SubredditFollow.find_one( + q(display_name=subreddit.display_name, guild=ctx.guild.id) + ) + if exists: + await ctx.send("Subreddit already being followed in this guild", ephemeral=True) + return + + count = len([i async for i in SubredditFollow.find(q(guild=ctx.guild.id))]) + if count >= 12: + await ctx.send("Cannot follow more than 12 Subreddits", ephemeral=True) + return + + if subreddit.over18 and not channel.nsfw: + await ctx.send( + "Subreddit is nsfw, but channel is not. Mark the channel NSFW first.", + ephemeral=True, + ) + return + + sr = await Subreddit.find_one(q(display_name=subreddit.display_name)) + if not sr: + sr = Subreddit(display_name=subreddit.display_name, over18=subreddit.over18) + await sr.commit() + + srf = SubredditFollow( + display_name=subreddit.display_name, + channel=channel.id, + guild=ctx.guild.id, + admin=ctx.author.id, + ) + await srf.commit() + + await ctx.send(f"Now following `r/{name}` in {channel.mention}") + + @reddit.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Subreddits") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _subreddit_unfollow(self, ctx: InteractionContext) -> None: + subs = SubredditFollow.find(q(guild=ctx.guild.id)) + subreddits = [] + async for sub in subs: + subreddits.append(sub) + if not subreddits: + await ctx.send("You need to follow a Subreddit first", ephemeral=True) + return + + options = [] + names = [] + for idx, subreddit in enumerate(subreddits): + sub = await Subreddit.find_one(q(display_name=subreddit.display_name)) + names.append(sub.display_name) + option = SelectOption(label=sub.display_name, value=str(idx)) + options.append(option) + + select = Select( + options=options, custom_id="to_delete", min_values=1, max_values=len(subreddits) + ) + + components = [ActionRow(select)] + block = "\n".join(x for x in names) + message = await ctx.send( + content=f"You are following the following subreddits:\n```\n{block}\n```\n\n" + "Please choose subreddits to unfollow", + components=components, + ) + + try: + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, + messages=message, + timeout=60 * 5, + ) + for to_delete in context.context.values: + follow = get(subreddits, guild=ctx.guild.id, display_name=names[int(to_delete)]) + try: + await follow.delete() + except Exception: + self.logger.debug("Ignoring deletion error") + for row in components: + for component in row.components: + component.disabled = True + + block = "\n".join(names[int(x)] for x in context.context.values) + await context.context.edit_origin( + content=f"Unfollowed the following:\n```\n{block}\n```", components=components + ) + except asyncio.TimeoutError: + for row in components: + for component in row.components: + component.disabled = True + await message.edit(components=components) + + @reddit.subcommand(sub_cmd_name="hot", sub_cmd_description="Get the hot post of a subreddit") + @slash_option( + name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True + ) + async def _subreddit_hot(self, ctx: InteractionContext, name: str) -> None: + await ctx.defer() + name = name.replace("r/", "") + if len(name) > 20 or len(name) < 3: + await ctx.send("Invalid Subreddit name", ephemeral=True) + return + try: + subreddit = await self.api.subreddit(name) + await subreddit.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in hot") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + try: + post = [x async for x in subreddit.hot(limit=1)][0] + except Exception as e: + self.logger.error(f"Failed to get post from {name}", exc_info=e) + await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True) + return + + embeds = await self.post_embeds(subreddit, post) + if post.over_18 and not ctx.channel.nsfw: + try: + await ctx.author.send(embeds=embeds) + await ctx.send("Hey! Due to content, I had to DM the result to you") + except Exception: + await ctx.send("Hey! Due to content, I cannot share the result") + else: + await ctx.send(embeds=embeds) + + @reddit.subcommand(sub_cmd_name="top", sub_cmd_description="Get the top post of a subreddit") + @slash_option( + name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="time", + description="Top time", + opt_type=OptionTypes.STRING, + required=False, + choices=[ + SlashCommandChoice(name="All", value="all"), + SlashCommandChoice(name="Day", value="day"), + SlashCommandChoice(name="Hour", value="hour"), + SlashCommandChoice(name="Month", value="month"), + SlashCommandChoice(name="Week", value="week"), + SlashCommandChoice(name="Year", value="year"), + ], + ) + async def _subreddit_top(self, ctx: InteractionContext, name: str, time: str = "all") -> None: + await ctx.defer() + name = name.replace("r/", "") + if len(name) > 20 or len(name) < 3: + await ctx.send("Invalid Subreddit name", ephemeral=True) + return + try: + subreddit = await self.api.subreddit(name) + await subreddit.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in top") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + try: + post = [x async for x in subreddit.top(time_filter=time, limit=1)][0] + except Exception as e: + self.logger.error(f"Failed to get post from {name}", exc_info=e) + await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True) + return + + embeds = await self.post_embeds(subreddit, post) + if post.over_18 and not ctx.channel.nsfw: + try: + await ctx.author.send(embeds=embeds) + await ctx.send("Hey! Due to content, I had to DM the result to you") + except Exception: + await ctx.send("Hey! Due to content, I cannot share the result") + else: + await ctx.send(embeds=embeds) + + @reddit.subcommand( + sub_cmd_name="random", sub_cmd_description="Get a random post of a subreddit" + ) + @slash_option( + name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True + ) + async def _subreddit_random(self, ctx: InteractionContext, name: str) -> None: + await ctx.defer() + name = name.replace("r/", "") + if len(name) > 20 or len(name) < 3: + await ctx.send("Invalid Subreddit name", ephemeral=True) + return + try: + subreddit = await self.api.subreddit(name) + await subreddit.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in random") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + try: + post = await subreddit.random() + except Exception as e: + self.logger.error(f"Failed to get post from {name}", exc_info=e) + await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True) + return + + embeds = await self.post_embeds(subreddit, post) + if post.over_18 and not ctx.channel.nsfw: + try: + await ctx.author.send(embeds=embeds) + await ctx.send("Hey! Due to content, I had to DM the result to you") + except Exception: + await ctx.send("Hey! Due to content, I cannot share the result") + else: + await ctx.send(embeds=embeds) + + @reddit.subcommand( + sub_cmd_name="rising", sub_cmd_description="Get a rising post of a subreddit" + ) + @slash_option( + name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True + ) + async def _subreddit_rising(self, ctx: InteractionContext, name: str) -> None: + await ctx.defer() + name = name.replace("r/", "") + if len(name) > 20 or len(name) < 3: + await ctx.send("Invalid Subreddit name", ephemeral=True) + return + try: + subreddit = await self.api.subreddit(name) + await subreddit.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in rising") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + try: + post = [x async for x in subreddit.rising(limit=1)][0] + except Exception as e: + self.logger.error(f"Failed to get post from {name}", exc_info=e) + await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True) + return + + embeds = await self.post_embeds(subreddit, post) + if post.over_18 and not ctx.channel.nsfw: + try: + await ctx.author.send(embeds=embeds) + await ctx.send("Hey! Due to content, I had to DM the result to you") + except Exception: + await ctx.send("Hey! Due to content, I cannot share the result") + else: + await ctx.send(embeds=embeds) + + @reddit.subcommand(sub_cmd_name="post", sub_cmd_description="Get a specific submission") + @slash_option( + name="sid", description="Submission ID", opt_type=OptionTypes.STRING, required=True + ) + async def _reddit_post(self, ctx: InteractionContext, sid: str) -> None: + await ctx.defer() + try: + post = await self.api.submission(sid) + await post.load() + except (NotFound, Forbidden, Redirect) as e: + self.logger.debug(f"Submission {sid} raised {e.__class__.__name__} in post") + await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True) + return + + embeds = await self.post_embeds(post.subreddit, post) + if post.over_18 and not ctx.channel.nsfw: + try: + await ctx.author.send(embeds=embeds) + await ctx.send("Hey! Due to content, I had to DM the result to you") + except Exception: + await ctx.send("Hey! Due to content, I cannot share the result") + else: + await ctx.send(embeds=embeds) + + +def setup(bot: Client) -> None: + """Add RedditCog to JARVIS""" + if JarvisConfig.from_yaml().reddit: + RedditCog(bot) diff --git a/jarvis/cogs/remindme.py b/jarvis/cogs/remindme.py index f17748a..ef70d42 100644 --- a/jarvis/cogs/remindme.py +++ b/jarvis/cogs/remindme.py @@ -1,181 +1,195 @@ -"""J.A.R.V.I.S. Remind Me Cog.""" +"""JARVIS Remind Me Cog.""" import asyncio +import logging import re -from datetime import datetime, timedelta -from typing import List, Optional +from datetime import datetime, timezone +from typing import List from bson import ObjectId -from discord import Embed -from discord.ext.commands import Bot -from discord.ext.tasks import loop -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option -from discord_slash.utils.manage_components import ( - create_actionrow, - create_select, - create_select_option, - wait_for_component, +from dateparser import parse +from dateparser_data.settings import default_parsers +from jarvis_core.db import q +from jarvis_core.db.models import Reminder +from naff import Client, Cog, InteractionContext +from naff.client.utils.misc_utils import get +from naff.models.discord.channel import GuildChannel +from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.modal import InputText, Modal, TextStyles +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_command, + slash_option, ) -from jarvis.db.models import Reminder from jarvis.utils import build_embed -from jarvis.utils.cachecog import CacheCog -from jarvis.utils.field import Field -valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>,\u0080-\U000E0FFF]*") +valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>:,\u0080-\U000E0FFF]*") +time_pattern = re.compile(r"(\d+\.?\d?[s|m|h|d|w]{1})\s?", flags=re.IGNORECASE) invites = re.compile( - r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", + r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", # noqa: E501 flags=re.IGNORECASE, ) -class RemindmeCog(CacheCog): - """J.A.R.V.I.S. Remind Me Cog.""" +class RemindmeCog(Cog): + """JARVIS Remind Me Cog.""" - def __init__(self, bot: Bot): - super().__init__(bot) - self._remind.start() + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_slash( - name="remindme", - description="Set a reminder", - options=[ - create_option( - name="message", - description="What to remind you of", - option_type=3, - required=True, - ), - create_option( - name="weeks", - description="Number of weeks?", - option_type=4, - required=False, - ), - create_option( - name="days", - description="Number of days?", - option_type=4, - required=False, - ), - create_option( - name="hours", - description="Number of hours?", - option_type=4, - required=False, - ), - create_option( - name="minutes", - description="Number of minutes?", - option_type=4, - required=False, - ), - ], + @slash_command(name="remindme", description="Set a reminder") + @slash_option( + name="private", + description="Send as DM?", + opt_type=OptionTypes.BOOLEAN, + required=False, ) async def _remindme( self, - ctx: SlashContext, - message: Optional[str] = None, - weeks: Optional[int] = 0, - days: Optional[int] = 0, - hours: Optional[int] = 0, - minutes: Optional[int] = 0, + ctx: InteractionContext, + private: bool = False, ) -> None: - if len(message) > 100: - await ctx.send("Reminder cannot be > 100 characters.", hidden=True) - return - elif invites.search(message): - await ctx.send( - "Listen, don't use this to try and bypass the rules", - hidden=True, - ) - return - elif not valid.fullmatch(message): - await ctx.send("Hey, you should probably make this readable", hidden=True) - return - - if not any([weeks, days, hours, minutes]): - await ctx.send("At least one time period is required", hidden=True) - return - - weeks = abs(weeks) - days = abs(days) - hours = abs(hours) - minutes = abs(minutes) - - if weeks and weeks > 4: - await ctx.send("Cannot be farther than 4 weeks out!", hidden=True) - return - - elif days and days > 6: - await ctx.send("Use weeks instead of 7+ days, please.", hidden=True) - return - - elif hours and hours > 23: - await ctx.send("Use days instead of 24+ hours, please.", hidden=True) - return - - elif minutes and minutes > 59: - await ctx.send("Use hours instead of 59+ minutes, please.", hidden=True) - return - - reminders = Reminder.objects(user=ctx.author.id, active=True).count() + reminders = len([x async for x in Reminder.find(q(user=ctx.author.id, active=True))]) if reminders >= 5: await ctx.send( "You already have 5 (or more) active reminders. " "Please either remove an old one, or wait for one to pass", - hidden=True, + ephemeral=True, + ) + return + modal = Modal( + title="Set your reminder!", + components=[ + InputText( + label="What to remind you?", + placeholder="Reminder", + style=TextStyles.PARAGRAPH, + custom_id="message", + max_length=500, + ), + InputText( + label="When to remind you?", + placeholder="1h 30m | in 5 minutes | November 11, 4011", + style=TextStyles.SHORT, + custom_id="delay", + ), + ], + ) + + await ctx.send_modal(modal) + try: + response = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5) + message = response.responses.get("message") + delay = response.responses.get("delay") + except asyncio.TimeoutError: + return + if len(message) > 500: + await response.send("Reminder cannot be > 500 characters.", ephemeral=True) + return + elif invites.search(message): + await response.send( + "Listen, don't use this to try and bypass the rules", + ephemeral=True, + ) + return + elif not valid.fullmatch(message): + await response.send("Hey, you should probably make this readable", ephemeral=True) + return + + base_settings = { + "PREFER_DATES_FROM": "future", + "TIMEZONE": "UTC", + "RETURN_AS_TIMEZONE_AWARE": True, + } + rt_settings = base_settings.copy() + rt_settings["PARSERS"] = [ + x for x in default_parsers if x not in ["absolute-time", "timestamp"] + ] + + rt_remind_at = parse(delay, settings=rt_settings) + + at_settings = base_settings.copy() + at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"] + at_remind_at = parse(delay, settings=at_settings) + + if rt_remind_at: + remind_at = rt_remind_at + elif at_remind_at: + remind_at = at_remind_at + else: + self.logger.debug(f"Failed to parse delay: {delay}") + await response.send( + f"`{delay}` is not a parsable date, please try again", ephemeral=True ) return - remind_at = datetime.utcnow() + timedelta( - weeks=weeks, - days=days, - hours=hours, - minutes=minutes, - ) + if remind_at < datetime.now(tz=timezone.utc): + await response.send( + f"`{delay}` is in the past. Past reminders aren't allowed", ephemeral=True + ) + return - _ = Reminder( - user=ctx.author_id, + elif remind_at < datetime.now(tz=timezone.utc): + pass + + r = Reminder( + user=ctx.author.id, channel=ctx.channel.id, guild=ctx.guild.id, message=message, remind_at=remind_at, + private=private, active=True, - ).save() + ) + + await r.commit() embed = build_embed( title="Reminder Set", description=f"{ctx.author.mention} set a reminder", fields=[ - Field(name="Message", value=message), - Field( + EmbedField(name="Message", value=message), + EmbedField( name="When", - value=remind_at.strftime("%Y-%m-%d %H:%M UTC"), + value=f"", inline=False, ), ], ) embed.set_author( - name=ctx.author.name + "#" + ctx.author.discriminator, - icon_url=ctx.author.avatar_url, + name=ctx.author.username + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar.url, ) - embed.set_thumbnail(url=ctx.author.avatar_url) + embed.set_thumbnail(url=ctx.author.display_avatar.url) - await ctx.send(embed=embed) + await response.send(embed=embed, ephemeral=private) - async def get_reminders_embed(self, ctx: SlashContext, reminders: List[Reminder]) -> Embed: + async def get_reminders_embed( + self, ctx: InteractionContext, reminders: List[Reminder] + ) -> Embed: """Build embed for paginator.""" fields = [] for reminder in reminders: - fields.append( - Field( - name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), - value=f"{reminder.message}\n\u200b", - inline=False, + if reminder.private and isinstance(ctx.channel, GuildChannel): + fields.embed( + EmbedField( + name=f"", + value="Please DM me this command to view the content of this reminder", + inline=False, + ) + ) + else: + fields.append( + EmbedField( + name=f"", + value=f"{reminder.message}\n\u200b", + inline=False, + ) ) - ) embed = build_embed( title=f"{len(reminders)} Active Reminder(s)", @@ -184,57 +198,43 @@ class RemindmeCog(CacheCog): ) embed.set_author( - name=ctx.author.name + "#" + ctx.author.discriminator, - icon_url=ctx.author.avatar_url, + name=ctx.author.username + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar.url, ) - embed.set_thumbnail(url=ctx.author.avatar_url) + embed.set_thumbnail(url=ctx.author.display_avatar.url) return embed - @cog_ext.cog_subcommand( - base="reminders", - name="list", - description="List reminders for a user", - ) - async def _list(self, ctx: SlashContext) -> None: - exists = self.check_cache(ctx) - if exists: - await ctx.defer(hidden=True) - await ctx.send( - f"Please use existing interaction: {exists['paginator']._message.jump_url}", - hidden=True, - ) - return - reminders = Reminder.objects(user=ctx.author.id, active=True) + reminders = SlashCommand(name="reminders", description="Manage reminders") + + @reminders.subcommand(sub_cmd_name="list", sub_cmd_description="List reminders") + async def _list(self, ctx: InteractionContext) -> None: + reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None) if not reminders: - await ctx.send("You have no reminders set.", hidden=True) + await ctx.send("You have no reminders set.", ephemeral=True) return embed = await self.get_reminders_embed(ctx, reminders) await ctx.send(embed=embed) - @cog_ext.cog_subcommand( - base="reminders", - name="delete", - description="Delete a reminder", - ) - async def _delete(self, ctx: SlashContext) -> None: - reminders = Reminder.objects(user=ctx.author.id, active=True) + @reminders.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a reminder") + async def _delete(self, ctx: InteractionContext) -> None: + reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None) if not reminders: - await ctx.send("You have no reminders set", hidden=True) + await ctx.send("You have no reminders set", ephemeral=True) return options = [] for reminder in reminders: - option = create_select_option( - label=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), + option = SelectOption( + label=f"{reminder.remind_at}", value=str(reminder.id), emoji="⏰", ) options.append(option) - select = create_select( + select = Select( options=options, custom_id="to_delete", placeholder="Select reminders to delete", @@ -242,7 +242,7 @@ class RemindmeCog(CacheCog): max_values=len(reminders), ) - components = [create_actionrow(select)] + components = [ActionRow(select)] embed = await self.get_reminders_embed(ctx, reminders) message = await ctx.send( content=f"You have {len(reminders)} reminder(s) set:", @@ -251,28 +251,40 @@ class RemindmeCog(CacheCog): ) try: - context = await wait_for_component( - self.bot, - check=lambda x: ctx.author.id == x.author_id, + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, messages=message, timeout=60 * 5, ) - for to_delete in context.selected_options: - _ = Reminder.objects(user=ctx.author.id, id=ObjectId(to_delete)).delete() - - for row in components: - for component in row["components"]: - component["disabled"] = True fields = [] - for reminder in filter(lambda x: str(x.id) in context.selected_options, reminders): - fields.append( - Field( - name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), - value=reminder.message, - inline=False, + for to_delete in context.context.values: + reminder = get(reminders, user=ctx.author.id, id=ObjectId(to_delete)) + if reminder.private and isinstance(ctx.channel, GuildChannel): + fields.append( + EmbedField( + name=f"", + value="Private reminder", + inline=False, + ) ) - ) + else: + fields.append( + EmbedField( + name=f"", + value=reminder.message, + inline=False, + ) + ) + try: + await reminder.delete() + except Exception: + self.logger.debug("Ignoring deletion error") + + for row in components: + for component in row.components: + component.disabled = True + embed = build_embed( title="Deleted Reminder(s)", description="", @@ -280,52 +292,50 @@ class RemindmeCog(CacheCog): ) embed.set_author( - name=ctx.author.name + "#" + ctx.author.discriminator, - icon_url=ctx.author.avatar_url, + name=ctx.author.display_name + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar.url, ) - embed.set_thumbnail(url=ctx.author.avatar_url) + embed.set_thumbnail(url=ctx.author.display_avatar.url) - await context.edit_origin( - content=f"Deleted {len(context.selected_options)} reminder(s)", + await context.context.edit_origin( + content=f"Deleted {len(context.context.values)} reminder(s)", components=components, embed=embed, ) except asyncio.TimeoutError: for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True await message.edit(components=components) - @loop(seconds=15) - async def _remind(self) -> None: - reminders = Reminder.objects(remind_at__lte=datetime.utcnow() + timedelta(seconds=30)) - for reminder in reminders: - if reminder.remind_at <= datetime.utcnow(): - user = await self.bot.fetch_user(reminder.user) - if not user: - reminder.delete() - continue - embed = build_embed( - title="You have a reminder", - description=reminder.message, - fields=[], - ) - embed.set_author( - name=user.name + "#" + user.discriminator, - icon_url=user.avatar_url, - ) - embed.set_thumbnail(url=user.avatar_url) - try: - await user.send(embed=embed) - except Exception: - guild = self.bot.fetch_guild(reminder.guild) - channel = guild.get_channel(reminder.channel) if guild else None - if channel: - await channel.send(f"{user.mention}", embed=embed) - finally: - reminder.delete() + @reminders.subcommand( + sub_cmd_name="fetch", + sub_cmd_description="Fetch a reminder that failed to send", + ) + @slash_option( + name="id", description="ID of the reminder", opt_type=OptionTypes.STRING, required=True + ) + async def _fetch(self, ctx: InteractionContext, id: str) -> None: + reminder = await Reminder.find_one(q(id=id)) + if not reminder: + await ctx.send(f"Reminder `{id}` does not exist", ephemeral=True) + return + + embed = build_embed(title="You have a reminder!", description=reminder.message, fields=[]) + embed.set_author( + name=ctx.author.display_name + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar, + ) + + embed.set_thumbnail(url=ctx.author.display_avatar) + await ctx.send(embed=embed, ephemeral=reminder.private) + if reminder.remind_at <= datetime.now(tz=timezone.utc) and not reminder.active: + try: + await reminder.delete() + except Exception: + self.logger.debug("Ignoring deletion error") -def setup(bot: Bot) -> None: - """Add RemindmeCog to J.A.R.V.I.S.""" - bot.add_cog(RemindmeCog(bot)) +def setup(bot: Client) -> None: + """Add RemindmeCog to JARVIS""" + RemindmeCog(bot) diff --git a/jarvis/cogs/rolegiver.py b/jarvis/cogs/rolegiver.py index ac27d14..0200a6e 100644 --- a/jarvis/cogs/rolegiver.py +++ b/jarvis/cogs/rolegiver.py @@ -1,64 +1,75 @@ -"""J.A.R.V.I.S. Role Giver Cog.""" +"""JARVIS Role Giver Cog.""" import asyncio +import logging -from discord import Role -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option -from discord_slash.utils.manage_components import ( - create_actionrow, - create_select, - create_select_option, - wait_for_component, +from jarvis_core.db import q +from jarvis_core.db.models import Rolegiver +from naff import Client, Cog, InteractionContext, Permissions +from naff.client.utils.misc_utils import get +from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.embed import EmbedField +from naff.models.discord.role import Role +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, ) +from naff.models.naff.command import check, cooldown +from naff.models.naff.cooldowns import Buckets -from jarvis.db.models import Rolegiver from jarvis.utils import build_embed -from jarvis.utils.field import Field from jarvis.utils.permissions import admin_or_permissions -class RolegiverCog(commands.Cog): - """J.A.R.V.I.S. Role Giver Cog.""" +class RolegiverCog(Cog): + """JARVIS Role Giver Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_subcommand( - base="rolegiver", - name="add", - description="Add a role to rolegiver", - options=[ - create_option( - name="role", - description="Role to add", - option_type=8, - required=True, - ) - ], + rolegiver = SlashCommand(name="rolegiver", description="Allow users to choose their own roles") + + @rolegiver.subcommand( + sub_cmd_name="add", + sub_cmd_description="Add a role to rolegiver", ) - @admin_or_permissions(manage_guild=True) - async def _rolegiver_add(self, ctx: SlashContext, role: Role) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() - if setting and role.id in setting.roles: - await ctx.send("Role already in rolegiver", hidden=True) + @slash_option(name="role", description="Role to add", opt_type=OptionTypes.ROLE, required=True) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _rolegiver_add(self, ctx: InteractionContext, role: Role) -> None: + if role.id == ctx.guild.id: + await ctx.send("Cannot add `@everyone` to rolegiver", ephemeral=True) + return + + if role.bot_managed or not role.is_assignable: + await ctx.send( + "Cannot assign this role, try lowering it below my role or using a different role", + ephemeral=True, + ) + return + + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) + if setting and setting.roles and role.id in setting.roles: + await ctx.send("Role already in rolegiver", ephemeral=True) return if not setting: setting = Rolegiver(guild=ctx.guild.id, roles=[]) + setting.roles = setting.roles or [] + if len(setting.roles) >= 20: - await ctx.send("You can only have 20 roles in the rolegiver", hidden=True) + await ctx.send("You can only have 20 roles in the rolegiver", ephemeral=True) return setting.roles.append(role.id) - setting.save() + await setting.commit() roles = [] for role_id in setting.roles: if role_id == role.id: continue - e_role = ctx.guild.get_role(role_id) + e_role = await ctx.guild.fetch_role(role_id) if not e_role: continue roles.append(e_role) @@ -67,8 +78,8 @@ class RolegiverCog(commands.Cog): value = "\n".join([r.mention for r in roles]) if roles else "None" fields = [ - Field(name="New Role", value=f"{role.mention}"), - Field(name="Existing Role(s)", value=value), + EmbedField(name="New Role", value=f"{role.mention}"), + EmbedField(name="Existing Role(s)", value=value), ] embed = build_embed( @@ -77,61 +88,58 @@ class RolegiverCog(commands.Cog): fields=fields, ) - embed.set_thumbnail(url=ctx.guild.icon_url) - embed.set_author( - name=ctx.author.nick if ctx.author.nick else ctx.author.name, - icon_url=ctx.author.avatar_url, - ) + embed.set_thumbnail(url=ctx.guild.icon.url) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") + embed.set_footer(text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}") await ctx.send(embed=embed) - @cog_ext.cog_subcommand( - base="rolegiver", - name="remove", - description="Remove a role from rolegiver", - ) - @admin_or_permissions(manage_guild=True) - async def _rolegiver_remove(self, ctx: SlashContext) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() + @rolegiver.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role from rolegiver") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _rolegiver_remove(self, ctx: InteractionContext) -> None: + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) if not setting or (setting and not setting.roles): - await ctx.send("Rolegiver has no roles", hidden=True) + await ctx.send("Rolegiver has no roles", ephemeral=True) return options = [] for role in setting.roles: - role: Role = ctx.guild.get_role(role) - option = create_select_option(label=role.name, value=str(role.id)) + role: Role = await ctx.guild.fetch_role(role) + option = SelectOption(label=role.name, value=str(role.id)) options.append(option) - select = create_select( + select = Select( options=options, custom_id="to_delete", placeholder="Select roles to remove", min_values=1, max_values=len(options), ) - components = [create_actionrow(select)] + components = [ActionRow(select)] message = await ctx.send(content="\u200b", components=components) try: - context = await wait_for_component( - self.bot, - check=lambda x: ctx.author.id == x.author.id, - message=message, + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, + messages=message, timeout=60 * 1, ) - for to_delete in context.selected_options: + removed_roles = [] + for to_delete in context.context.values: + role = await ctx.guild.fetch_role(to_delete) + if role: + removed_roles.append(role) setting.roles.remove(int(to_delete)) - setting.save() + await setting.commit() + for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True roles = [] for role_id in setting.roles: - e_role = ctx.guild.get_role(role_id) + e_role = await ctx.guild.fetch_role(role_id) if not e_role: continue roles.append(e_role) @@ -140,9 +148,10 @@ class RolegiverCog(commands.Cog): roles.sort(key=lambda x: -x.position) value = "\n".join([r.mention for r in roles]) if roles else "None" + rvalue = "\n".join([r.mention for r in removed_roles]) if removed_roles else "None" fields = [ - Field(name="Removed Role", value=f"{role.mention}"), - Field(name="Remaining Role(s)", value=value), + EmbedField(name="Removed Role(s)", value=rvalue), + EmbedField(name="Remaining Role(s)", value=value), ] embed = build_embed( @@ -151,39 +160,34 @@ class RolegiverCog(commands.Cog): fields=fields, ) - embed.set_thumbnail(url=ctx.guild.icon_url) - embed.set_author( - name=ctx.author.nick if ctx.author.nick else ctx.author.name, - icon_url=ctx.author.avatar_url, + embed.set_thumbnail(url=ctx.guild.icon.url) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) + + embed.set_footer( + text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}" ) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") - - await context.edit_origin( - content=f"Removed {len(context.selected_options)} role(s)", + await context.context.edit_origin( + content=f"Removed {len(context.context.values)} role(s)", embed=embed, components=components, ) except asyncio.TimeoutError: for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True await message.edit(components=components) - @cog_ext.cog_subcommand( - base="rolegiver", - name="list", - description="List roles rolegiver", - ) - async def _rolegiver_list(self, ctx: SlashContext) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() + @rolegiver.subcommand(sub_cmd_name="list", sub_cmd_description="List rolegiver roles") + async def _rolegiver_list(self, ctx: InteractionContext) -> None: + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) if not setting or (setting and not setting.roles): - await ctx.send("Rolegiver has no roles", hidden=True) + await ctx.send("Rolegiver has no roles", ephemeral=True) return roles = [] for role_id in setting.roles: - e_role = ctx.guild.get_role(role_id) + e_role = await ctx.guild.fetch_role(role_id) if not e_role: continue roles.append(e_role) @@ -199,59 +203,54 @@ class RolegiverCog(commands.Cog): fields=[], ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) embed.set_author( - name=ctx.author.nick if ctx.author.nick else ctx.author.name, - icon_url=ctx.author.avatar_url, + name=ctx.author.display_name, + icon_url=ctx.author.display_avatar.url, ) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") + embed.set_footer(text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}") await ctx.send(embed=embed) - @cog_ext.cog_subcommand( - base="role", - name="get", - description="Get a role from rolegiver", - ) - @commands.cooldown(1, 10, commands.BucketType.user) - async def _role_get(self, ctx: SlashContext) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() + role = SlashCommand(name="role", description="Get/Remove Rolegiver roles") + + @role.subcommand(sub_cmd_name="get", sub_cmd_description="Get a role") + @cooldown(bucket=Buckets.USER, rate=1, interval=10) + async def _role_get(self, ctx: InteractionContext) -> None: + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) if not setting or (setting and not setting.roles): - await ctx.send("Rolegiver has no roles", hidden=True) + await ctx.send("Rolegiver has no roles", ephemeral=True) return options = [] for role in setting.roles: - role: Role = ctx.guild.get_role(role) - option = create_select_option(label=role.name, value=str(role.id)) + role: Role = await ctx.guild.fetch_role(role) + option = SelectOption(label=role.name, value=str(role.id)) options.append(option) - select = create_select( + select = Select( options=options, - custom_id="to_delete", placeholder="Select roles to add", min_values=1, max_values=len(options), ) - components = [create_actionrow(select)] + components = [ActionRow(select)] message = await ctx.send(content="\u200b", components=components) try: - - context = await wait_for_component( - self.bot, - check=lambda x: ctx.author.id == x.author.id, + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, messages=message, timeout=60 * 5, ) added_roles = [] - for role in context.selected_options: - role = ctx.guild.get_role(int(role)) + for role in context.context.values: + role = await ctx.guild.fetch_role(int(role)) added_roles.append(role) - await ctx.author.add_roles(role, reason="Rolegiver") + await ctx.author.add_role(role, reason="Rolegiver") roles = ctx.author.roles if roles: @@ -261,109 +260,133 @@ class RolegiverCog(commands.Cog): avalue = "\n".join([r.mention for r in added_roles]) if added_roles else "None" value = "\n".join([r.mention for r in roles]) if roles else "None" fields = [ - Field(name="Added Role(s)", value=avalue), - Field(name="Prior Role(s)", value=value), + EmbedField(name="Added Role(s)", value=avalue), + EmbedField(name="Prior Role(s)", value=value), ] embed = build_embed( title="User Given Role", - description=f"{role.mention} given to {ctx.author.mention}", + description=f"{len(added_roles)} role(s) given to {ctx.author.mention}", fields=fields, ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) embed.set_author( - name=ctx.author.nick if ctx.author.nick else ctx.author.name, - icon_url=ctx.author.avatar_url, + name=ctx.author.display_name, + icon_url=ctx.author.display_avatar.url, ) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") - for row in components: - for component in row["components"]: - component["disabled"] = True + embed.set_footer( + text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}" + ) - await message.edit_origin(embed=embed, content="\u200b", components=components) + for row in components: + for component in row.components: + component.disabled = True + + await context.context.edit_origin(embed=embed, content="\u200b", components=components) except asyncio.TimeoutError: for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True await message.edit(components=components) - @cog_ext.cog_subcommand( - base="role", - name="forfeit", - description="Have rolegiver take away role", - options=[ - create_option( - name="role", - description="Role to remove", - option_type=8, - required=True, - ) - ], - ) - @commands.cooldown(1, 10, commands.BucketType.user) - async def _role_forfeit(self, ctx: SlashContext, role: Role) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() + @role.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role") + @cooldown(bucket=Buckets.USER, rate=1, interval=10) + async def _role_remove(self, ctx: InteractionContext) -> None: + user_roles = ctx.author.roles + + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) if not setting or (setting and not setting.roles): - await ctx.send("Rolegiver has no roles", hidden=True) + await ctx.send("Rolegiver has no roles", ephemeral=True) return - elif role.id not in setting.roles: - await ctx.send("Role not in rolegiver", hidden=True) - return - elif role not in ctx.author.roles: - await ctx.send("You do not have that role", hidden=True) + elif not any(x.id in setting.roles for x in user_roles): + await ctx.send("You have no rolegiver roles", ephemeral=True) return - await ctx.author.remove_roles(role, reason="Rolegiver") + valid = list(filter(lambda x: x.id in setting.roles, user_roles)) + options = [] + for role in valid: + option = SelectOption(label=role.name, value=str(role.id)) + options.append(option) - roles = ctx.author.roles - if roles: - roles.sort(key=lambda x: -x.position) - _ = roles.pop(-1) - - value = "\n".join([r.mention for r in roles]) if roles else "None" - fields = [ - Field(name="Taken Role", value=f"{role.mention}"), - Field(name="Remaining Role(s)", value=value), - ] - - embed = build_embed( - title="User Forfeited Role", - description=f"{role.mention} taken from {ctx.author.mention}", - fields=fields, + select = Select( + options=options, + custom_id="to_remove", + placeholder="Select roles to remove", + min_values=1, + max_values=len(options), ) + components = [ActionRow(select)] - embed.set_thumbnail(url=ctx.guild.icon_url) - embed.set_author( - name=ctx.author.nick if ctx.author.nick else ctx.author.name, - icon_url=ctx.author.avatar_url, - ) + message = await ctx.send(content="\u200b", components=components) - embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") + try: + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, + messages=message, + timeout=60 * 5, + ) - await ctx.send(embed=embed) + removed_roles = [] + for to_remove in context.context.values: + role = get(user_roles, id=int(to_remove)) + await ctx.author.remove_role(role, reason="Rolegiver") + user_roles.remove(role) + removed_roles.append(role) - @cog_ext.cog_subcommand( - base="rolegiver", - name="cleanup", - description="Cleanup rolegiver roles", + user_roles.sort(key=lambda x: -x.position) + _ = user_roles.pop(-1) + + value = "\n".join([r.mention for r in user_roles]) if user_roles else "None" + rvalue = "\n".join([r.mention for r in removed_roles]) if removed_roles else "None" + fields = [ + EmbedField(name="Removed Role(s)", value=rvalue), + EmbedField(name="Remaining Role(s)", value=value), + ] + + embed = build_embed( + title="User Forfeited Role", + description=f"{len(removed_roles)} role(s) removed from {ctx.author.mention}", + fields=fields, + ) + + embed.set_thumbnail(url=ctx.guild.icon.url) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url) + + embed.set_footer( + text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}" + ) + + for row in components: + for component in row.components: + component.disabled = True + + await context.context.edit_origin(embed=embed, components=components, content="\u200b") + + except asyncio.TimeoutError: + for row in components: + for component in row.components: + component.disabled = True + await message.edit(components=components) + + @rolegiver.subcommand( + sub_cmd_name="cleanup", sub_cmd_description="Removed deleted roles from rolegiver" ) - @admin_or_permissions(manage_guild=True) - async def _rolegiver_cleanup(self, ctx: SlashContext) -> None: - setting = Rolegiver.objects(guild=ctx.guild.id).first() + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _rolegiver_cleanup(self, ctx: InteractionContext) -> None: + setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) if not setting or not setting.roles: - await ctx.send("Rolegiver has no roles", hidden=True) - guild_roles = await ctx.guild.fetch_roles() - guild_role_ids = [x.id for x in guild_roles] + await ctx.send("Rolegiver has no roles", ephemeral=True) + guild_role_ids = [r.id for r in ctx.guild.roles] for role_id in setting.roles: if role_id not in guild_role_ids: setting.roles.remove(role_id) - setting.save() + await setting.commit() await ctx.send("Rolegiver cleanup finished") -def setup(bot: commands.Bot) -> None: - """Add RolegiverCog to J.A.R.V.I.S.""" - bot.add_cog(RolegiverCog(bot)) +def setup(bot: Client) -> None: + """Add RolegiverCog to JARVIS""" + RolegiverCog(bot) diff --git a/jarvis/cogs/settings.py b/jarvis/cogs/settings.py index 622a1d7..db2672b 100644 --- a/jarvis/cogs/settings.py +++ b/jarvis/cogs/settings.py @@ -1,296 +1,287 @@ -"""J.A.R.V.I.S. Settings Management Cog.""" +"""JARVIS Settings Management Cog.""" +import asyncio +import logging from typing import Any -from discord import Role, TextChannel -from discord.ext import commands -from discord.utils import find -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_option +from jarvis_core.db import q +from jarvis_core.db.models import Setting +from naff import Client, Cog, InteractionContext +from naff.models.discord.channel import GuildText +from naff.models.discord.components import ActionRow, Button, ButtonStyles +from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import Permissions +from naff.models.discord.role import Role +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Setting from jarvis.utils import build_embed -from jarvis.utils.field import Field from jarvis.utils.permissions import admin_or_permissions -class SettingsCog(commands.Cog): - """J.A.R.V.I.S. Settings Management Cog.""" +class SettingsCog(Cog): + """JARVIS Settings Management Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - def update_settings(self, setting: str, value: Any, guild: int) -> bool: + async def update_settings(self, setting: str, value: Any, guild: int) -> bool: """Update a guild setting.""" - existing = Setting.objects(setting=setting, guild=guild).first() + existing = await Setting.find_one(q(setting=setting, guild=guild)) if not existing: existing = Setting(setting=setting, guild=guild, value=value) existing.value = value - updated = existing.save() + updated = await existing.commit() return updated is not None - def delete_settings(self, setting: str, guild: int) -> bool: + async def delete_settings(self, setting: str, guild: int) -> bool: """Delete a guild setting.""" - return Setting.objects(setting=setting, guild=guild).delete() + existing = await Setting.find_one(q(setting=setting, guild=guild)) + if existing: + return await existing.delete() + return False - @cog_ext.cog_subcommand( - base="settings", - base_desc="Settings management", - subcommand_group="set", - subcommand_group_description="Set a setting", - name="mute", - description="Set mute role", - options=[ - create_option( - name="role", - description="Mute role", - option_type=8, - required=True, - ) - ], - ) - @admin_or_permissions(manage_guild=True) - async def _set_mute(self, ctx: SlashContext, role: Role) -> None: - await ctx.defer() - self.update_settings("mute", role.id, ctx.guild.id) - await ctx.send(f"Settings applied. New mute role is `{role.name}`") + settings = SlashCommand(name="settings", description="Control guild settings") + set_ = settings.group(name="set", description="Set a setting") + unset = settings.group(name="unset", description="Unset a setting") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="modlog", - description="Set modlog channel", - options=[ - create_option( - name="channel", - description="Modlog channel", - option_type=7, - required=True, - ) - ], + @set_.subcommand( + sub_cmd_name="modlog", + sub_cmd_description="Set Moglod channel", ) - @admin_or_permissions(manage_guild=True) - async def _set_modlog(self, ctx: SlashContext, channel: TextChannel) -> None: - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a TextChannel", hidden=True) + @slash_option( + name="channel", description="ModLog Channel", opt_type=OptionTypes.CHANNEL, required=True + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_modlog(self, ctx: InteractionContext, channel: GuildText) -> None: + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a GuildText", ephemeral=True) return - self.update_settings("modlog", channel.id, ctx.guild.id) + await self.update_settings("modlog", channel.id, ctx.guild.id) await ctx.send(f"Settings applied. New modlog channel is {channel.mention}") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="userlog", - description="Set userlog channel", - options=[ - create_option( - name="channel", - description="Userlog channel", - option_type=7, - required=True, - ) - ], + @set_.subcommand( + sub_cmd_name="activitylog", + sub_cmd_description="Set Activitylog channel", ) - @admin_or_permissions(manage_guild=True) - async def _set_userlog(self, ctx: SlashContext, channel: TextChannel) -> None: - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a TextChannel", hidden=True) + @slash_option( + name="channel", + description="Activitylog Channel", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_activitylog(self, ctx: InteractionContext, channel: GuildText) -> None: + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a GuildText", ephemeral=True) return - self.update_settings("userlog", channel.id, ctx.guild.id) - await ctx.send(f"Settings applied. New userlog channel is {channel.mention}") + await self.update_settings("activitylog", channel.id, ctx.guild.id) + await ctx.send(f"Settings applied. New activitylog channel is {channel.mention}") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="massmention", - description="Set massmention amount", - options=[ - create_option( - name="amount", - description="Amount of mentions (0 to disable)", - option_type=4, - required=True, - ) - ], + @set_.subcommand(sub_cmd_name="massmention", sub_cmd_description="Set massmention output") + @slash_option( + name="amount", + description="Amount of mentions (0 to disable)", + opt_type=OptionTypes.INTEGER, + required=True, ) - @admin_or_permissions(manage_guild=True) - async def _set_massmention(self, ctx: SlashContext, amount: int) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_massmention(self, ctx: InteractionContext, amount: int) -> None: await ctx.defer() - self.update_settings("massmention", amount, ctx.guild.id) + await self.update_settings("massmention", amount, ctx.guild.id) await ctx.send(f"Settings applied. New massmention limit is {amount}") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="verified", - description="Set verified role", - options=[ - create_option( - name="role", - description="verified role", - option_type=8, - required=True, - ) - ], + @set_.subcommand(sub_cmd_name="verified", sub_cmd_description="Set verified role") + @slash_option( + name="role", description="Verified role", opt_type=OptionTypes.ROLE, required=True ) - @admin_or_permissions(manage_guild=True) - async def _set_verified(self, ctx: SlashContext, role: Role) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_verified(self, ctx: InteractionContext, role: Role) -> None: + if role.id == ctx.guild.id: + await ctx.send("Cannot set verified to `@everyone`", ephemeral=True) + return + if role.bot_managed or not role.is_assignable: + await ctx.send( + "Cannot assign this role, try lowering it below my role or using a different role", + ephemeral=True, + ) + return await ctx.defer() - self.update_settings("verified", role.id, ctx.guild.id) + await self.update_settings("verified", role.id, ctx.guild.id) await ctx.send(f"Settings applied. New verified role is `{role.name}`") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="unverified", - description="Set unverified role", - options=[ - create_option( - name="role", - description="Unverified role", - option_type=8, - required=True, - ) - ], + @set_.subcommand(sub_cmd_name="unverified", sub_cmd_description="Set unverified role") + @slash_option( + name="role", description="Unverified role", opt_type=OptionTypes.ROLE, required=True ) - @admin_or_permissions(manage_guild=True) - async def _set_unverified(self, ctx: SlashContext, role: Role) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_unverified(self, ctx: InteractionContext, role: Role) -> None: + if role.id == ctx.guild.id: + await ctx.send("Cannot set unverified to `@everyone`", ephemeral=True) + return + if role.bot_managed or not role.is_assignable: + await ctx.send( + "Cannot assign this role, try lowering it below my role or using a different role", + ephemeral=True, + ) + return await ctx.defer() - self.update_settings("unverified", role.id, ctx.guild.id) + await self.update_settings("unverified", role.id, ctx.guild.id) await ctx.send(f"Settings applied. New unverified role is `{role.name}`") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="set", - name="noinvite", - description="Set if invite deletion should happen", - options=[ - create_option( - name="active", - description="Active?", - option_type=4, - required=True, - ) - ], + @set_.subcommand( + sub_cmd_name="noinvite", sub_cmd_description="Set if invite deletion should happen" ) - @admin_or_permissions(manage_guild=True) - async def _set_invitedel(self, ctx: SlashContext, active: int) -> None: + @slash_option(name="active", description="Active?", opt_type=OptionTypes.BOOLEAN, required=True) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_invitedel(self, ctx: InteractionContext, active: bool) -> None: await ctx.defer() - self.update_settings("noinvite", bool(active), ctx.guild.id) - await ctx.send(f"Settings applied. Automatic invite active: {bool(active)}") + await self.update_settings("noinvite", active, ctx.guild.id) + await ctx.send(f"Settings applied. Automatic invite active: {active}") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - subcommand_group_description="Unset a setting", - name="mute", - description="Unset mute role", - ) - @admin_or_permissions(manage_guild=True) - async def _unset_mute(self, ctx: SlashContext) -> None: + @set_.subcommand(sub_cmd_name="notify", sub_cmd_description="Notify users of admin action?") + @slash_option(name="active", description="Notify?", opt_type=OptionTypes.BOOLEAN, required=True) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _set_notify(self, ctx: InteractionContext, active: bool) -> None: await ctx.defer() - self.delete_settings("mute", ctx.guild.id) - await ctx.send("Setting removed.") + await self.update_settings("notify", active, ctx.guild.id) + await ctx.send(f"Settings applied. Notifications active: {active}") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - name="modlog", - description="Unset modlog channel", + # Unset + @unset.subcommand( + sub_cmd_name="modlog", + sub_cmd_description="Unset Modlog channel", ) - @admin_or_permissions(manage_guild=True) - async def _unset_modlog(self, ctx: SlashContext) -> None: - self.delete_settings("modlog", ctx.guild.id) - await ctx.send("Setting removed.") - - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - name="userlog", - description="Unset userlog channel", - ) - @admin_or_permissions(manage_guild=True) - async def _unset_userlog(self, ctx: SlashContext) -> None: - self.delete_settings("userlog", ctx.guild.id) - await ctx.send("Setting removed.") - - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - name="massmention", - description="Unet massmention amount", - ) - @admin_or_permissions(manage_guild=True) - async def _massmention(self, ctx: SlashContext) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_modlog(self, ctx: InteractionContext) -> None: await ctx.defer() - self.delete_settings("massmention", ctx.guild.id) - await ctx.send("Setting removed.") + await self.delete_settings("modlog", ctx.guild.id) + await ctx.send("Setting `modlog` unset") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - name="verified", - description="Unset verified role", + @unset.subcommand( + sub_cmd_name="activitylog", + sub_cmd_description="Unset Activitylog channel", ) - @admin_or_permissions(manage_guild=True) - async def _verified(self, ctx: SlashContext) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_activitylog(self, ctx: InteractionContext) -> None: await ctx.defer() - self.delete_settings("verified", ctx.guild.id) - await ctx.send("Setting removed.") + await self.delete_settings("activitylog", ctx.guild.id) + await ctx.send("Setting `activitylog` unset") - @cog_ext.cog_subcommand( - base="settings", - subcommand_group="unset", - name="unverified", - description="Unset unverified role", + @unset.subcommand(sub_cmd_name="massmention", sub_cmd_description="Unset massmention output") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_massmention(self, ctx: InteractionContext) -> None: + await ctx.defer() + await self.delete_settings("massmention", ctx.guild.id) + await ctx.send("Setting `massmention` unset") + + @unset.subcommand(sub_cmd_name="verified", sub_cmd_description="Unset verified role") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_verified(self, ctx: InteractionContext) -> None: + await ctx.defer() + await self.delete_settings("verified", ctx.guild.id) + await ctx.send("Setting `verified` unset") + + @unset.subcommand(sub_cmd_name="unverified", sub_cmd_description="Unset unverified role") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_unverified(self, ctx: InteractionContext) -> None: + await ctx.defer() + await self.delete_settings("unverified", ctx.guild.id) + await ctx.send("Setting `unverified` unset") + + @unset.subcommand( + sub_cmd_name="noinvite", sub_cmd_description="Unset if invite deletion should happen" ) - @admin_or_permissions(manage_guild=True) - async def _unverified(self, ctx: SlashContext) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_invitedel(self, ctx: InteractionContext, active: bool) -> None: await ctx.defer() - self.delete_settings("unverified", ctx.guild.id) - await ctx.send("Setting removed.") + await self.delete_settings("noinvite", ctx.guild.id) + await ctx.send(f"Setting `{active}` unset") - @cog_ext.cog_subcommand(base="settings", name="view", description="View settings") - @admin_or_permissions(manage_guild=True) - async def _view(self, ctx: SlashContext) -> None: - settings = Setting.objects(guild=ctx.guild.id) + @unset.subcommand(sub_cmd_name="notify", sub_cmd_description="Unset admin action notifications") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _unset_notify(self, ctx: InteractionContext) -> None: + await ctx.defer() + await self.delete_settings("notify", ctx.guild.id) + await ctx.send("Setting `notify` unset") + + @settings.subcommand(sub_cmd_name="view", sub_cmd_description="View settings") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _view(self, ctx: InteractionContext) -> None: + settings = Setting.find(q(guild=ctx.guild.id)) fields = [] - for setting in settings: + async for setting in settings: value = setting.value if setting.setting in ["unverified", "verified", "mute"]: - value = find(lambda x: x.id == value, ctx.guild.roles) + try: + value = await ctx.guild.fetch_role(value) + except KeyError: + await setting.delete() + continue if value: value = value.mention else: value = "||`[redacted]`||" - elif setting.setting in ["userlog", "modlog"]: - value = find(lambda x: x.id == value, ctx.guild.text_channels) + elif setting.setting in ["activitylog", "modlog"]: + value = await ctx.guild.fetch_channel(value) if value: value = value.mention else: value = "||`[redacted]`||" elif setting.setting == "rolegiver": value = "" - for role in setting.value: - nvalue = find(lambda x: x.id == value, ctx.guild.roles) - if value: + for _role in setting.value: + nvalue = await ctx.guild.fetch_role(_role) + if nvalue: value += "\n" + nvalue.mention else: value += "\n||`[redacted]`||" - fields.append(Field(name=setting.setting, value=value or "N/A")) + fields.append(EmbedField(name=setting.setting, value=str(value) or "N/A", inline=False)) embed = build_embed(title="Current Settings", description="", fields=fields) await ctx.send(embed=embed) - @cog_ext.cog_subcommand(base="settings", name="clear", description="Clear all settings") - @admin_or_permissions(manage_guild=True) - async def _clear(self, ctx: SlashContext) -> None: - deleted = Setting.objects(guild=ctx.guild.id).delete() - await ctx.send(f"Guild settings cleared: `{deleted is not None}`") + @settings.subcommand(sub_cmd_name="clear", sub_cmd_description="Clear all settings") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _clear(self, ctx: InteractionContext) -> None: + components = [ + ActionRow( + Button(style=ButtonStyles.RED, emoji="✖️", custom_id="no"), + Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="yes"), + ) + ] + message = await ctx.send("***Are you sure?***", components=components) + try: + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, + messages=message, + timeout=60 * 5, + ) + content = "***Are you sure?***" + if context.context.custom_id == "yes": + async for setting in Setting.find(q(guild=ctx.guild.id)): + await setting.delete() + content = "Guild settings cleared" + else: + content = "Guild settings not cleared" + for row in components: + for component in row.components: + component.disabled = True + await context.context.edit_origin(content=content, components=components) + except asyncio.TimeoutError: + for row in components: + for component in row.components: + component.disabled = True + await message.edit(content="Guild settings not cleared", components=components) -def setup(bot: commands.Bot) -> None: - """Add SettingsCog to J.A.R.V.I.S.""" - bot.add_cog(SettingsCog(bot)) +def setup(bot: Client) -> None: + """Add SettingsCog to JARVIS""" + SettingsCog(bot) diff --git a/jarvis/cogs/starboard.py b/jarvis/cogs/starboard.py index df5d1d6..a87267c 100644 --- a/jarvis/cogs/starboard.py +++ b/jarvis/cogs/starboard.py @@ -1,19 +1,21 @@ -"""J.A.R.V.I.S. Starboard Cog.""" -from discord import TextChannel -from discord.ext import commands -from discord.utils import find -from discord_slash import SlashContext, cog_ext -from discord_slash.context import MenuContext -from discord_slash.model import ContextMenuType, SlashMessage -from discord_slash.utils.manage_commands import create_option -from discord_slash.utils.manage_components import ( - create_actionrow, - create_select, - create_select_option, - wait_for_component, -) +"""JARVIS Starboard Cog.""" +import logging + +from jarvis_core.db import q +from jarvis_core.db.models import Star, Starboard +from naff import Client, Cog, InteractionContext, Permissions +from naff.models.discord.channel import GuildText +from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.message import Message +from naff.models.naff.application_commands import ( + CommandTypes, + OptionTypes, + SlashCommand, + context_menu, + slash_option, +) +from naff.models.naff.command import check -from jarvis.db.models import Star, Starboard from jarvis.utils import build_embed from jarvis.utils.permissions import admin_or_permissions @@ -26,20 +28,22 @@ supported_images = [ ] -class StarboardCog(commands.Cog): - """J.A.R.V.I.S. Starboard Cog.""" +class StarboardCog(Cog): + """JARVIS Starboard Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_subcommand( - base="starboard", - name="list", - description="Lists all Starboards", + starboard = SlashCommand(name="starboard", description="Extra pins! Manage starboards") + + @starboard.subcommand( + sub_cmd_name="list", + sub_cmd_description="List all starboards", ) - @admin_or_permissions(manage_guild=True) - async def _list(self, ctx: SlashContext) -> None: - starboards = Starboard.objects(guild=ctx.guild.id) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _list(self, ctx: InteractionContext) -> None: + starboards = await Starboard.find(q(guild=ctx.guild.id)).to_list(None) if starboards != []: message = "Available Starboards:\n" for s in starboards: @@ -48,153 +52,144 @@ class StarboardCog(commands.Cog): else: await ctx.send("No Starboards available.") - @cog_ext.cog_subcommand( - base="starboard", - name="create", - description="Create a starboard", - options=[ - create_option( - name="channel", - description="Starboard channel", - option_type=7, - required=True, - ), - ], + @starboard.subcommand(sub_cmd_name="create", sub_cmd_description="Create a starboard") + @slash_option( + name="channel", + description="Starboard channel", + opt_type=OptionTypes.CHANNEL, + required=True, ) - @admin_or_permissions(manage_guild=True) - async def _create(self, ctx: SlashContext, channel: TextChannel) -> None: + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _create(self, ctx: InteractionContext, channel: GuildText) -> None: if channel not in ctx.guild.channels: await ctx.send( "Channel not in guild. Choose an existing channel.", - hidden=True, + ephemeral=True, ) return - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a TextChannel", hidden=True) + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a GuildText", ephemeral=True) return - exists = Starboard.objects(channel=channel.id, guild=ctx.guild.id).first() + exists = await Starboard.find_one(q(channel=channel.id, guild=ctx.guild.id)) if exists: - await ctx.send(f"Starboard already exists at {channel.mention}.", hidden=True) + await ctx.send(f"Starboard already exists at {channel.mention}.", ephemeral=True) return - count = Starboard.objects(guild=ctx.guild.id).count() + count = await Starboard.count_documents(q(guild=ctx.guild.id)) if count >= 25: - await ctx.send("25 starboard limit reached", hidden=True) + await ctx.send("25 starboard limit reached", ephemeral=True) return - _ = Starboard( + await Starboard( guild=ctx.guild.id, channel=channel.id, admin=ctx.author.id, - ).save() + ).commit() await ctx.send(f"Starboard created. Check it out at {channel.mention}.") - @cog_ext.cog_subcommand( - base="starboard", - name="delete", - description="Delete a starboard", - options=[ - create_option( - name="channel", - description="Starboard channel", - option_type=7, - required=True, - ), - ], + @starboard.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a starboard") + @slash_option( + name="channel", + description="Starboard channel", + opt_type=OptionTypes.CHANNEL, + required=True, ) - @admin_or_permissions(manage_guild=True) - async def _delete(self, ctx: SlashContext, channel: TextChannel) -> None: - deleted = Starboard.objects(channel=channel.id, guild=ctx.guild.id).delete() - if deleted: - _ = Star.objects(starboard=channel.id).delete() - await ctx.send(f"Starboard deleted from {channel.mention}.", hidden=True) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _delete(self, ctx: InteractionContext, channel: GuildText) -> None: + found = await Starboard.find_one(q(channel=channel.id, guild=ctx.guild.id)) + if found: + await found.delete() + await ctx.send(f"Starboard deleted from {channel.mention}.") else: - await ctx.send(f"Starboard not found in {channel.mention}.", hidden=True) + await ctx.send(f"Starboard not found in {channel.mention}.", ephemeral=True) - @cog_ext.cog_context_menu(name="Star Message", target=ContextMenuType.MESSAGE) - async def _star_message(self, ctx: MenuContext) -> None: - await self._star_add.invoke(ctx, ctx.target_message) - - @cog_ext.cog_subcommand( - base="star", - name="add", - description="Star a message", - options=[ - create_option( - name="message", - description="Message to star", - option_type=3, - required=True, - ), - create_option( - name="channel", - description="Channel that has the message, required if different than command message", - option_type=7, - required=False, - ), - ], - ) - @admin_or_permissions(manage_guild=True) async def _star_add( self, - ctx: SlashContext, + ctx: InteractionContext, message: str, - channel: TextChannel = None, + channel: GuildText = None, ) -> None: if not channel: channel = ctx.channel - starboards = Starboard.objects(guild=ctx.guild.id) + starboards = await Starboard.find(q(guild=ctx.guild.id)).to_list(None) if not starboards: - await ctx.send("No starboards exist.", hidden=True) + await ctx.send("No starboards exist.", ephemeral=True) return await ctx.defer() + + if not isinstance(message, Message): + if message.startswith("https://"): + message = message.split("/")[-1] + message = await channel.fetch_message(int(message)) + + if not message: + await ctx.send("Message not found", ephemeral=True) + return + channel_list = [] + to_delete = [] for starboard in starboards: - channel_list.append(find(lambda x: x.id == starboard.channel, ctx.guild.channels)) + c = await ctx.guild.fetch_channel(starboard.channel) + if c and isinstance(c, GuildText): + channel_list.append(c) + else: + self.logger.warning( + f"Starboard {starboard.channel} no longer valid in {ctx.guild.name}" + ) + to_delete.append(starboard) - select_channels = [create_select_option(label=x.name, value=str(idx)) for idx, x in enumerate(channel_list)] + for starboard in to_delete: + try: + await starboard.delete() + except Exception: + self.logger.debug("Ignoring deletion error") - select = create_select( + select_channels = [] + for idx, x in enumerate(channel_list): + if x: + select_channels.append(SelectOption(label=x.name, value=str(idx))) + + select_channels = [ + SelectOption(label=x.name, value=str(idx)) for idx, x in enumerate(channel_list) + ] + + select = Select( options=select_channels, min_values=1, max_values=1, ) - components = [create_actionrow(select)] + components = [ActionRow(select)] msg = await ctx.send(content="Choose a starboard", components=components) - com_ctx = await wait_for_component( - self.bot, + com_ctx = await self.bot.wait_for_component( messages=msg, components=components, - check=lambda x: x.author.id == ctx.author.id, + check=lambda x: ctx.author.id == x.context.author.id, ) - starboard = channel_list[int(com_ctx.selected_options[0])] + starboard = channel_list[int(com_ctx.context.values[0])] - if not isinstance(message, SlashMessage): - if message.startswith("https://"): - message = message.split("/")[-1] - message = await channel.fetch_message(message) - - exists = Star.objects( - message=message.id, - channel=message.channel.id, - guild=message.guild.id, - starboard=starboard.id, - ).first() + exists = await Star.find_one( + q( + message=message.id, + channel=channel.id, + guild=ctx.guild.id, + starboard=starboard.id, + ) + ) if exists: await ctx.send( f"Message already sent to Starboard {starboard.mention}", - hidden=True, + ephemeral=True, ) return - count = Star.objects(guild=message.guild.id, starboard=starboard.id).count() + count = await Star.count_documents(q(guild=ctx.guild.id, starboard=starboard.id)) content = message.content attachments = message.attachments @@ -215,91 +210,114 @@ class StarboardCog(commands.Cog): timestamp=message.created_at, ) embed.set_author( - name=message.author.name, + name=message.author.display_name, url=message.jump_url, - icon_url=message.author.avatar_url, + icon_url=message.author.avatar.url, ) - embed.set_footer(text=message.guild.name + " | " + message.channel.name) + embed.set_footer(text=ctx.guild.name + " | " + channel.name) if image_url: embed.set_image(url=image_url) star = await starboard.send(embed=embed) - _ = Star( + await Star( index=count, message=message.id, - channel=message.channel.id, - guild=message.guild.id, + channel=channel.id, + guild=ctx.guild.id, starboard=starboard.id, admin=ctx.author.id, star=star.id, active=True, - ).save() + ).commit() - components[0]["components"][0]["disabled"] = True + components[0].components[0].disabled = True - await com_ctx.edit_origin( + await com_ctx.context.edit_origin( content=f"Message saved to Starboard.\nSee it in {starboard.mention}", components=components, ) - @cog_ext.cog_subcommand( - base="star", - name="delete", - description="Delete a starred message", - options=[ - create_option( - name="id", - description="Star to delete", - option_type=4, - required=True, - ), - create_option( - name="starboard", - description="Starboard to delete star from", - option_type=7, - required=True, - ), - ], + @context_menu(name="Star Message", context_type=CommandTypes.MESSAGE) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _star_message(self, ctx: InteractionContext) -> None: + await self._star_add(ctx, message=str(ctx.target_id)) + + star = SlashCommand( + name="star", + description="Manage stars", ) - @admin_or_permissions(manage_guild=True) + + @star.subcommand( + sub_cmd_name="add", + sub_cmd_description="Star a message", + ) + @slash_option( + name="message", description="Message to star", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="channel", + description="Channel that has the message, not required if used in same channel", + opt_type=OptionTypes.CHANNEL, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _star_message_slash( + self, ctx: InteractionContext, message: str, channel: GuildText + ) -> None: + await self._star_add(ctx, message, channel) + + @star.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a starred message") + @slash_option( + name="id", description="Star ID to delete", opt_type=OptionTypes.INTEGER, required=True + ) + @slash_option( + name="starboard", + description="Starboard to delete star from", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) async def _star_delete( self, - ctx: SlashContext, + ctx: InteractionContext, id: int, - starboard: TextChannel, + starboard: GuildText, ) -> None: - if not isinstance(starboard, TextChannel): - await ctx.send("Channel must be a TextChannel", hidden=True) + if not isinstance(starboard, GuildText): + await ctx.send("Channel must be a GuildText channel", ephemeral=True) return - exists = Starboard.objects(channel=starboard.id, guild=ctx.guild.id).first() + + exists = await Starboard.find_one(q(channel=starboard.id, guild=ctx.guild.id)) if not exists: + # TODO: automagically create starboard await ctx.send( f"Starboard does not exist in {starboard.mention}. Please create it first", - hidden=True, + ephemeral=True, ) return - star = Star.objects( - starboard=starboard.id, - index=id, - guild=ctx.guild.id, - active=True, - ).first() + star = await Star.find_one( + q( + starboard=starboard.id, + index=id, + guild=ctx.guild.id, + active=True, + ) + ) if not star: - await ctx.send(f"No star exists with id {id}", hidden=True) + await ctx.send(f"No star exists with id {id}", ephemeral=True) return message = await starboard.fetch_message(star.star) if message: await message.delete() - star.active = False - star.save() + await star.delete() - await ctx.send(f"Star {id} deleted") + await ctx.send(f"Star {id} deleted from {starboard.mention}") -def setup(bot: commands.Bot) -> None: - """Add StarboardCog to J.A.R.V.I.S.""" - bot.add_cog(StarboardCog(bot)) +def setup(bot: Client) -> None: + """Add StarboardCog to JARVIS""" + StarboardCog(bot) diff --git a/jarvis/cogs/temprole.py b/jarvis/cogs/temprole.py new file mode 100644 index 0000000..c2a443e --- /dev/null +++ b/jarvis/cogs/temprole.py @@ -0,0 +1,126 @@ +"""JARVIS temporary role handler.""" +import logging +from datetime import datetime, timezone + +from dateparser import parse +from dateparser_data.settings import default_parsers +from jarvis_core.db.models import Temprole +from naff import Client, Cog, InteractionContext, Permissions +from naff.models.discord.embed import EmbedField +from naff.models.discord.role import Role +from naff.models.discord.user import Member +from naff.models.naff.application_commands import ( + OptionTypes, + slash_command, + slash_option, +) +from naff.models.naff.command import check + +from jarvis.utils import build_embed +from jarvis.utils.permissions import admin_or_permissions + + +class TemproleCog(Cog): + """JARVIS Temporary Role Cog.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + + @slash_command(name="temprole", description="Give a user a temporary role") + @slash_option( + name="user", description="User to grant role", opt_type=OptionTypes.USER, required=True + ) + @slash_option( + name="role", description="Role to grant", opt_type=OptionTypes.ROLE, required=True + ) + @slash_option( + name="duration", + description="Duration of temp role (i.e. 2 hours)", + opt_type=OptionTypes.STRING, + required=True, + ) + @slash_option( + name="reason", + description="Reason for temporary role", + opt_type=OptionTypes.STRING, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_ROLES)) + async def _temprole( + self, ctx: InteractionContext, user: Member, role: Role, duration: str, reason: str = None + ) -> None: + await ctx.defer() + if not isinstance(user, Member): + await ctx.send("User not in guild", ephemeral=True) + return + + if role.id == ctx.guild.id: + await ctx.send("Cannot add `@everyone` to users", ephemeral=True) + return + + if role.bot_managed or not role.is_assignable: + await ctx.send( + "Cannot assign this role, try lowering it below my role or using a different role", + ephemeral=True, + ) + return + + base_settings = { + "PREFER_DATES_FROM": "future", + "TIMEZONE": "UTC", + "RETURN_AS_TIMEZONE_AWARE": True, + } + rt_settings = base_settings.copy() + rt_settings["PARSERS"] = [ + x for x in default_parsers if x not in ["absolute-time", "timestamp"] + ] + + rt_duration = parse(duration, settings=rt_settings) + + at_settings = base_settings.copy() + at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"] + at_duration = parse(duration, settings=at_settings) + + if rt_duration: + duration = rt_duration + elif at_duration: + duration = at_duration + else: + self.logger.debug(f"Failed to parse duration: {duration}") + await ctx.send(f"`{duration}` is not a parsable date, please try again", ephemeral=True) + return + + if duration < datetime.now(tz=timezone.utc): + await ctx.send( + f"`{duration}` is in the past. Past durations aren't allowed", ephemeral=True + ) + return + + await user.add_role(role, reason=reason) + await Temprole( + guild=ctx.guild.id, user=user.id, role=role.id, admin=ctx.author.id, expires_at=duration + ).commit() + + ts = int(duration.timestamp()) + + fields = ( + EmbedField(name="Role", value=role.mention), + EmbedField(name="Valid Until", value=f" ()"), + ) + + embed = build_embed( + title="Role granted", + description=f"Role temporarily granted to {user.mention}", + fields=fields, + ) + embed.set_author( + name=f"{user.username}#{user.discriminator}", icon_url=user.display_avatar.url + ) + + await ctx.send(embed=embed) + + +def setup(bot: Client) -> None: + """Add TemproleCog to JARVIS""" + TemproleCog(bot) diff --git a/jarvis/cogs/twitter.py b/jarvis/cogs/twitter.py index c007585..16a650d 100644 --- a/jarvis/cogs/twitter.py +++ b/jarvis/cogs/twitter.py @@ -1,164 +1,141 @@ -"""J.A.R.V.I.S. Twitter Cog.""" +"""JARVIS Twitter Cog.""" import asyncio import logging import tweepy -from bson import ObjectId -from discord import TextChannel -from discord.ext import commands -from discord.ext.tasks import loop -from discord.utils import find -from discord_slash import SlashContext, cog_ext -from discord_slash.model import SlashCommandOptionType as COptionType -from discord_slash.utils.manage_commands import create_choice, create_option -from discord_slash.utils.manage_components import ( - create_actionrow, - create_select, - create_select_option, - wait_for_component, +from jarvis_core.db import q +from jarvis_core.db.models import TwitterAccount, TwitterFollow +from naff import Client, Cog, InteractionContext, Permissions +from naff.client.utils.misc_utils import get +from naff.models.discord.channel import GuildText +from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.naff.application_commands import ( + OptionTypes, + SlashCommand, + slash_option, ) +from naff.models.naff.command import check -from jarvis.config import get_config -from jarvis.db.models import Twitter +from jarvis.config import JarvisConfig from jarvis.utils.permissions import admin_or_permissions -logger = logging.getLogger("discord") +class TwitterCog(Cog): + """JARVIS Twitter Cog.""" -class TwitterCog(commands.Cog): - """J.A.R.V.I.S. Twitter Cog.""" - - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot - config = get_config() - auth = tweepy.AppAuthHandler(config.twitter["consumer_key"], config.twitter["consumer_secret"]) + self.logger = logging.getLogger(__name__) + config = JarvisConfig.from_yaml() + auth = tweepy.AppAuthHandler( + config.twitter["consumer_key"], config.twitter["consumer_secret"] + ) self.api = tweepy.API(auth) - self._tweets.start() self._guild_cache = {} self._channel_cache = {} - @loop(seconds=30) - async def _tweets(self) -> None: - twitters = Twitter.objects(active=True) - handles = Twitter.objects.distinct("handle") - twitter_data = {} - for handle in handles: - try: - twitter_data[handle] = self.api.user_timeline(screen_name=handle) - except Exception as e: - logger.error(f"Error with fetching: {e}") - for twitter in twitters: - try: - tweets = list(filter(lambda x: x.id > twitter.last_tweet, twitter_data[twitter.handle])) - if tweets: - tweets = sorted(tweets, key=lambda x: x.id) - if twitter.guild not in self._guild_cache: - self._guild_cache[twitter.guild] = await self.bot.fetch_guild(twitter.guild) - guild = self._guild_cache[twitter.guild] - if twitter.channel not in self._channel_cache: - channels = await guild.fetch_channels() - self._channel_cache[twitter.channel] = find(lambda x: x.id == twitter.channel, channels) - channel = self._channel_cache[twitter.channel] - for tweet in tweets: - retweet = "retweeted_status" in tweet.__dict__ - if retweet and not twitter.retweets: - continue - timestamp = int(tweet.created_at.timestamp()) - url = f"https://twitter.com/{twitter.handle}/status/{tweet.id}" - verb = "re" if retweet else "" - await channel.send(f"`@{twitter.handle}` {verb}tweeted this at : {url}") - newest = max(tweets, key=lambda x: x.id) - twitter.last_tweet = newest.id - twitter.save() - except Exception as e: - logger.error(f"Error with tweets: {e}") - - @cog_ext.cog_subcommand( - base="twitter", - base_description="Twitter commands", - name="follow", - description="Follow a Twitter account", - options=[ - create_option(name="handle", description="Twitter account", option_type=COptionType.STRING, required=True), - create_option( - name="channel", - description="Channel to post tweets into", - option_type=COptionType.CHANNEL, - required=True, - ), - create_option( - name="retweets", - description="Mirror re-tweets?", - option_type=COptionType.STRING, - required=False, - choices=[create_choice(name="Yes", value="Yes"), create_choice(name="No", value="No")], - ), - ], + twitter = SlashCommand( + name="twitter", + description="Manage Twitter follows", ) - @admin_or_permissions(manage_guild=True) + + @twitter.subcommand( + sub_cmd_name="follow", + sub_cmd_description="Follow a Twitter acount", + ) + @slash_option( + name="handle", description="Twitter account", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="channel", + description="Channel to post tweets to", + opt_type=OptionTypes.CHANNEL, + required=True, + ) + @slash_option( + name="retweets", + description="Mirror re-tweets?", + opt_type=OptionTypes.BOOLEAN, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) async def _twitter_follow( - self, ctx: SlashContext, handle: str, channel: TextChannel, retweets: str = "Yes" + self, ctx: InteractionContext, handle: str, channel: GuildText, retweets: bool = True ) -> None: - retweets = retweets == "Yes" - if len(handle) > 15: - await ctx.send("Invalid Twitter handle", hidden=True) + handle = handle.lower() + if len(handle) > 15 or len(handle) < 4: + await ctx.send("Invalid Twitter handle", ephemeral=True) return - if not isinstance(channel, TextChannel): - await ctx.send("Channel must be a text channel", hidden=True) + if not isinstance(channel, GuildText): + await ctx.send("Channel must be a text channel", ephemeral=True) return try: - latest_tweet = self.api.user_timeline(screen_name=handle, count=1)[0] + account = await asyncio.to_thread(self.api.get_user, screen_name=handle) + latest_tweet = (await asyncio.to_thread(self.api.user_timeline, screen_name=handle))[0] except Exception: - await ctx.send("Unable to get user timeline. Are you sure the handle is correct?", hidden=True) + await ctx.send( + "Unable to get user timeline. Are you sure the handle is correct?", ephemeral=True + ) return - count = Twitter.objects(guild=ctx.guild.id).count() - if count >= 12: - await ctx.send("Cannot follow more than 12 Twitter accounts", hidden=True) - return - - exists = Twitter.objects(handle=handle, guild=ctx.guild.id) + exists = await TwitterFollow.find_one(q(twitter_id=account.id, guild=ctx.guild.id)) if exists: - await ctx.send("Twitter handle already being followed in this guild", hidden=True) + await ctx.send("Twitter account already being followed in this guild", ephemeral=True) return - t = Twitter( - handle=handle, + count = len([i async for i in TwitterFollow.find(q(guild=ctx.guild.id))]) + if count >= 12: + await ctx.send("Cannot follow more than 12 Twitter accounts", ephemeral=True) + return + + ta = await TwitterAccount.find_one(q(twitter_id=account.id)) + if not ta: + ta = TwitterAccount( + handle=account.screen_name, + twitter_id=account.id, + last_tweet=latest_tweet.id, + ) + await ta.commit() + + tf = TwitterFollow( + twitter_id=account.id, guild=ctx.guild.id, channel=channel.id, admin=ctx.author.id, - last_tweet=latest_tweet.id, retweets=retweets, ) - t.save() + await tf.commit() await ctx.send(f"Now following `@{handle}` in {channel.mention}") - @cog_ext.cog_subcommand( - base="twitter", - name="unfollow", - description="Unfollow Twitter accounts", - ) - @admin_or_permissions(manage_guild=True) - async def _twitter_unfollow(self, ctx: SlashContext) -> None: - twitters = Twitter.objects(guild=ctx.guild.id) + @twitter.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Twitter accounts") + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _twitter_unfollow(self, ctx: InteractionContext) -> None: + t = TwitterFollow.find(q(guild=ctx.guild.id)) + twitters = [] + async for twitter in t: + twitters.append(twitter) if not twitters: - await ctx.send("You need to follow a Twitter account first", hidden=True) + await ctx.send("You need to follow a Twitter account first", ephemeral=True) return options = [] - handlemap = {str(x.id): x.handle for x in twitters} + handlemap = {} for twitter in twitters: - option = create_select_option(label=twitter.handle, value=str(twitter.id)) + account = await TwitterAccount.find_one(q(twitter_id=twitter.twitter_id)) + handlemap[str(twitter.twitter_id)] = account.handle + option = SelectOption(label=account.handle, value=str(twitter.twitter_id)) options.append(option) - select = create_select(options=options, custom_id="to_delete", min_values=1, max_values=len(twitters)) + select = Select( + options=options, custom_id="to_delete", min_values=1, max_values=len(twitters) + ) - components = [create_actionrow(select)] - block = "\n".join(x.handle for x in twitters) + components = [ActionRow(select)] + block = "\n".join(x for x in handlemap.values()) message = await ctx.send( content=f"You are following the following accounts:\n```\n{block}\n```\n\n" "Please choose accounts to unfollow", @@ -166,53 +143,65 @@ class TwitterCog(commands.Cog): ) try: - context = await wait_for_component( - self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5 + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.context.author.id, + messages=message, + timeout=60 * 5, ) - for to_delete in context.selected_options: - _ = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_delete)).delete() + for to_delete in context.context.values: + follow = get(twitters, guild=ctx.guild.id, twitter_id=int(to_delete)) + try: + await follow.delete() + except Exception: + self.logger.debug("Ignoring deletion error") for row in components: - for component in row["components"]: - component["disabled"] = True - block = "\n".join(handlemap[x] for x in context.selected_options) - await context.edit_origin(content=f"Unfollowed the following:\n```\n{block}\n```", components=components) + for component in row.components: + component.disabled = True + + block = "\n".join(handlemap[x] for x in context.context.values) + await context.context.edit_origin( + content=f"Unfollowed the following:\n```\n{block}\n```", components=components + ) except asyncio.TimeoutError: for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True await message.edit(components=components) - @cog_ext.cog_subcommand( - base="twitter", - name="retweets", - description="Modify followed Twitter accounts", - options=[ - create_option( - name="retweets", - description="Mirror re-tweets?", - option_type=COptionType.STRING, - required=True, - choices=[create_choice(name="Yes", value="Yes"), create_choice(name="No", value="No")], - ), - ], + @twitter.subcommand( + sub_cmd_name="retweets", + sub_cmd_description="Modify followed Twitter accounts", ) - @admin_or_permissions(manage_guild=True) - async def _twitter_modify(self, ctx: SlashContext, retweets: str) -> None: - retweets = retweets == "Yes" - twitters = Twitter.objects(guild=ctx.guild.id) + @slash_option( + name="retweets", + description="Mirror re-tweets?", + opt_type=OptionTypes.BOOLEAN, + required=False, + ) + @check(admin_or_permissions(Permissions.MANAGE_GUILD)) + async def _twitter_modify(self, ctx: InteractionContext, retweets: bool = True) -> None: + t = TwitterFollow.find(q(guild=ctx.guild.id)) + twitters = [] + async for twitter in t: + twitters.append(twitter) if not twitters: - await ctx.send("You need to follow a Twitter account first", hidden=True) + await ctx.send("You need to follow a Twitter account first", ephemeral=True) return options = [] + handlemap = {} for twitter in twitters: - option = create_select_option(label=twitter.handle, value=str(twitter.id)) + account = await TwitterAccount.find_one(q(twitter_id=twitter.id)) + handlemap[str(twitter.twitter_id)] = account.handle + option = SelectOption(label=account.handle, value=str(twitter.twitter_id)) options.append(option) - select = create_select(options=options, custom_id="to_update", min_values=1, max_values=len(twitters)) + select = Select( + options=options, custom_id="to_update", min_values=1, max_values=len(twitters) + ) - components = [create_actionrow(select)] - block = "\n".join(x.handle for x in twitters) + components = [ActionRow(select)] + block = "\n".join(x for x in handlemap.values()) message = await ctx.send( content=f"You are following the following accounts:\n```\n{block}\n```\n\n" f"Please choose which accounts to {'un' if not retweets else ''}follow retweets from", @@ -220,30 +209,41 @@ class TwitterCog(commands.Cog): ) try: - context = await wait_for_component( - self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5 + context = await self.bot.wait_for_component( + check=lambda x: ctx.author.id == x.author.id, + messages=message, + timeout=60 * 5, ) - handlemap = {str(x.id): x.handle for x in twitters} - for to_update in context.selected_options: - t = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_update)).first() - t.retweets = retweets - t.save() + + handlemap = {} + for to_update in context.context.values: + account = await TwitterAccount.find_one(q(twitter_id=int(to_update))) + handlemap[str(twitter.twitter_id)] = account.handle + t = get(twitters, guild=ctx.guild.id, twitter_id=int(to_update)) + t.update(q(retweets=True)) + await t.commit() + for row in components: - for component in row["components"]: - component["disabled"] = True - block = "\n".join(handlemap[x] for x in context.selected_options) - await context.edit_origin( - content=f"{'Unfollowed' if not retweets else 'Followed'} retweets from the following:" - f"\n```\n{block}\n```", + for component in row.components: + component.disabled = True + + block = "\n".join(handlemap[x] for x in context.context.values) + await context.context.edit_origin( + content=( + f"{'Unfollowed' if not retweets else 'Followed'} " + "retweets from the following:" + f"\n```\n{block}\n```" + ), components=components, ) except asyncio.TimeoutError: for row in components: - for component in row["components"]: - component["disabled"] = True + for component in row.components: + component.disabled = True await message.edit(components=components) -def setup(bot: commands.Bot) -> None: - """Add TwitterCog to J.A.R.V.I.S.""" - bot.add_cog(TwitterCog(bot)) +def setup(bot: Client) -> None: + """Add TwitterCog to JARVIS""" + if JarvisConfig.from_yaml().twitter: + TwitterCog(bot) diff --git a/jarvis/cogs/util.py b/jarvis/cogs/util.py index c37da98..e709479 100644 --- a/jarvis/cogs/util.py +++ b/jarvis/cogs/util.py @@ -1,165 +1,166 @@ -"""J.A.R.V.I.S. Utility Cog.""" +"""JARVIS Utility Cog.""" +import logging import re import secrets import string +from datetime import timezone from io import BytesIO -import discord -import discord_slash import numpy as np -from discord import File, Guild, Role, User -from discord.ext import commands -from discord_slash import SlashContext, cog_ext -from discord_slash.utils.manage_commands import create_choice, create_option +from dateparser import parse +from naff import Client, Cog, InteractionContext, const +from naff.models.discord.channel import GuildCategory, GuildText, GuildVoice +from naff.models.discord.embed import EmbedField +from naff.models.discord.file import File +from naff.models.discord.guild import Guild +from naff.models.discord.role import Role +from naff.models.discord.user import Member, User +from naff.models.naff.application_commands import ( + CommandTypes, + OptionTypes, + SlashCommandChoice, + context_menu, + slash_command, + slash_option, +) +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets from PIL import Image +from tzlocal import get_localzone -import jarvis -from jarvis import jarvis_self -from jarvis.config import get_config +from jarvis import const as jconst from jarvis.data import pigpen from jarvis.data.robotcamo import emotes, hk, names -from jarvis.utils import build_embed, convert_bytesize, get_repo_hash -from jarvis.utils.field import Field +from jarvis.utils import build_embed, get_repo_hash JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA") -class UtilCog(commands.Cog): +class UtilCog(Cog): """ - Utility functions for J.A.R.V.I.S. + Utility functions for JARVIS Mostly system utility functions, but may change over time """ - def __init__(self, bot: commands.Cog): + def __init__(self, bot: Client): self.bot = bot - self.config = get_config() + self.logger = logging.getLogger(__name__) - @cog_ext.cog_slash( - name="status", - description="Retrieve J.A.R.V.I.S. status", - ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _status(self, ctx: SlashContext) -> None: - title = "J.A.R.V.I.S. Status" - desc = "All systems online" - color = "#98CCDA" + @slash_command(name="status", description="Retrieve JARVIS status") + @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) + async def _status(self, ctx: InteractionContext) -> None: + title = "JARVIS Status" + desc = f"All systems online\nConnected to **{len(self.bot.guilds)}** guilds" + color = "#3498db" fields = [] - with jarvis_self.oneshot(): - fields.append(Field("CPU Usage", jarvis_self.cpu_percent())) - fields.append( - Field( - "RAM Usage", - convert_bytesize(jarvis_self.memory_info().rss), - ) - ) - fields.append(Field("PID", jarvis_self.pid)) - fields.append(Field("discord_slash", discord_slash.__version__)) - fields.append(Field("discord.py", discord.__version__)) - fields.append(Field("Version", jarvis.__version__, False)) - fields.append(Field("Git Hash", get_repo_hash()[:7], False)) - embed = build_embed(title=title, description=desc, fields=fields, color=color) - await ctx.send(embed=embed) + uptime = int(self.bot.start_time.timestamp()) - @cog_ext.cog_slash( + fields.append(EmbedField(name="Version", value=jconst.__version__, inline=True)) + fields.append(EmbedField(name="naff", value=const.__version__, inline=True)) + fields.append(EmbedField(name="Git Hash", value=get_repo_hash()[:7], inline=True)) + fields.append(EmbedField(name="Online Since", value=f"", inline=False)) + num_domains = len(self.bot.phishing_domains) + fields.append( + EmbedField( + name="Phishing Protection", value=f"Detecting {num_domains} phishing domains" + ) + ) + embed = build_embed(title=title, description=desc, fields=fields, color=color) + await ctx.send(embed=embed) + + @slash_command( name="logo", description="Get the current logo", ) - @commands.cooldown(1, 30, commands.BucketType.channel) - async def _logo(self, ctx: SlashContext) -> None: + @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) + async def _logo(self, ctx: InteractionContext) -> None: with BytesIO() as image_bytes: JARVIS_LOGO.save(image_bytes, "PNG") image_bytes.seek(0) - logo = File(image_bytes, filename="logo.png") - + logo = File(image_bytes, file_name="logo.png") await ctx.send(file=logo) - @cog_ext.cog_slash(name="rchk", description="Robot Camo HK416") - async def _rchk(self, ctx: SlashContext) -> None: + @slash_command(name="rchk", description="Robot Camo HK416") + async def _rchk(self, ctx: InteractionContext) -> None: await ctx.send(content=hk) - @cog_ext.cog_slash( + @slash_command( name="rcauto", description="Automates robot camo letters", - options=[ - create_option( - name="text", - description="Text to camo-ify", - option_type=3, - required=True, - ) - ], ) - async def _rcauto(self, ctx: SlashContext, text: str) -> None: + @slash_option( + name="text", + description="Text to camo-ify", + opt_type=OptionTypes.STRING, + required=True, + ) + async def _rcauto(self, ctx: InteractionContext, text: str) -> None: to_send = "" if len(text) == 1 and not re.match(r"^[A-Z0-9-()$@!?^'#. ]$", text.upper()): - await ctx.send("Please use ASCII characters.", hidden=True) + await ctx.send("Please use ASCII characters.", ephemeral=True) return for letter in text.upper(): if letter == " ": to_send += " " elif re.match(r"^[A-Z0-9-()$@!?^'#.]$", letter): id = emotes[letter] - if ctx.author.is_on_mobile(): - to_send += f":{names[id]}:" - else: - to_send += f"<:{names[id]}:{id}>" + to_send += f":{names[id]}:" if len(to_send) > 2000: - await ctx.send("Too long.", hidden=True) + await ctx.send("Too long.", ephemeral=True) + elif len(to_send) == 0: + await ctx.send("No valid text found", ephemeral=True) else: await ctx.send(to_send) - @cog_ext.cog_slash( - name="avatar", - description="Get a user avatar", - options=[ - create_option( - name="user", - description="User to view avatar of", - option_type=6, - required=False, - ) - ], + @slash_command(name="avatar", description="Get a user avatar") + @slash_option( + name="user", + description="User to view avatar of", + opt_type=OptionTypes.USER, + required=False, ) - @commands.cooldown(1, 5, commands.BucketType.user) - async def _avatar(self, ctx: SlashContext, user: User = None) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=5) + async def _avatar(self, ctx: InteractionContext, user: User = None) -> None: if not user: user = ctx.author - avatar = user.avatar_url + avatar = user.avatar.url + if isinstance(user, Member): + avatar = user.display_avatar.url + embed = build_embed(title="Avatar", description="", fields=[], color="#00FFEE") embed.set_image(url=avatar) - embed.set_author(name=f"{user.name}#{user.discriminator}", icon_url=avatar) + embed.set_author(name=f"{user.username}#{user.discriminator}", icon_url=avatar) await ctx.send(embed=embed) - @cog_ext.cog_slash( + @slash_command( name="roleinfo", description="Get role info", - options=[ - create_option( - name="role", - description="Role to get info of", - option_type=8, - required=True, - ) - ], ) - async def _roleinfo(self, ctx: SlashContext, role: Role) -> None: + @slash_option( + name="role", + description="Role to get info of", + opt_type=OptionTypes.ROLE, + required=True, + ) + async def _roleinfo(self, ctx: InteractionContext, role: Role) -> None: fields = [ - Field(name="ID", value=role.id), - Field(name="Name", value=role.name), - Field(name="Color", value=str(role.color)), - Field(name="Mention", value=f"`{role.mention}`"), - Field(name="Hoisted", value="Yes" if role.hoist else "No"), - Field(name="Position", value=str(role.position)), - Field(name="Mentionable", value="Yes" if role.mentionable else "No"), + EmbedField(name="ID", value=str(role.id), inline=True), + EmbedField(name="Name", value=role.mention, inline=True), + EmbedField(name="Color", value=str(role.color.hex), inline=True), + EmbedField(name="Mention", value=f"`{role.mention}`", inline=True), + EmbedField(name="Hoisted", value="Yes" if role.hoist else "No", inline=True), + EmbedField(name="Position", value=str(role.position), inline=True), + EmbedField(name="Mentionable", value="Yes" if role.mentionable else "No", inline=True), + EmbedField(name="Member Count", value=str(len(role.members)), inline=True), + EmbedField(name="Created At", value=f""), ] - embed = build_embed( title="", description="", fields=fields, - color=str(role.color), + color=role.color, timestamp=role.created_at, ) embed.set_footer(text="Role Created") @@ -170,46 +171,38 @@ class UtilCog(commands.Cog): fill = a > 0 - data[..., :-1][fill.T] = list(role.color.to_rgb()) + data[..., :-1][fill.T] = list(role.color.rgb) im = Image.fromarray(data) with BytesIO() as image_bytes: im.save(image_bytes, "PNG") image_bytes.seek(0) - color_show = File(image_bytes, filename="color_show.png") + color_show = File(image_bytes, file_name="color_show.png") await ctx.send(embed=embed, file=color_show) - @cog_ext.cog_slash( - name="userinfo", - description="Get user info", - options=[ - create_option( - name="user", - description="User to get info of", - option_type=6, - required=False, - ) - ], - ) - async def _userinfo(self, ctx: SlashContext, user: User = None) -> None: + async def _userinfo(self, ctx: InteractionContext, user: User = None) -> None: + await ctx.defer() if not user: user = ctx.author + if not await ctx.guild.fetch_member(user.id): + await ctx.send("That user isn't in this guild.", ephemeral=True) + return user_roles = user.roles if user_roles: user_roles = sorted(user.roles, key=lambda x: -x.position) - _ = user_roles.pop(-1) + fields = [ - Field( + EmbedField( name="Joined", - value=user.joined_at.strftime("%a, %b %-d, %Y %-I:%M %p"), + value=f"", ), - Field( + EmbedField( name="Registered", - value=user.created_at.strftime("%a, %b %-d, %Y %-I:%M %p"), + value=f"", ), - Field( + EmbedField( name=f"Roles [{len(user_roles)}]", value=" ".join([x.mention for x in user_roles]) if user_roles else "None", inline=False, @@ -220,81 +213,102 @@ class UtilCog(commands.Cog): title="", description=user.mention, fields=fields, - color=str(user_roles[0].color) if user_roles else "#FF0000", + color=str(user_roles[0].color) if user_roles else "#3498db", ) - embed.set_author(name=f"{user.name}#{user.discriminator}", icon_url=user.avatar_url) - embed.set_thumbnail(url=user.avatar_url) + embed.set_author( + name=f"{user.display_name}#{user.discriminator}", icon_url=user.display_avatar.url + ) + embed.set_thumbnail(url=user.display_avatar.url) embed.set_footer(text=f"ID: {user.id}") await ctx.send(embed=embed) - @cog_ext.cog_slash(name="serverinfo", description="Get server info") - async def _server_info(self, ctx: SlashContext) -> None: + @slash_command( + name="userinfo", + description="Get user info", + ) + @slash_option( + name="user", + description="User to get info of", + opt_type=OptionTypes.USER, + required=False, + ) + async def _userinfo_slsh(self, ctx: InteractionContext, user: User = None) -> None: + await self._userinfo(ctx, user) + + @context_menu(name="User Info", context_type=CommandTypes.USER) + async def _userinfo_menu(self, ctx: InteractionContext) -> None: + await self._userinfo(ctx, ctx.target) + + @slash_command(name="serverinfo", description="Get server info") + async def _server_info(self, ctx: InteractionContext) -> None: guild: Guild = ctx.guild - owner = f"{guild.owner.name}#{guild.owner.discriminator}" if guild.owner else "||`[redacted]`||" + owner = await guild.fetch_owner() - region = guild.region - categories = len(guild.categories) - text_channels = len(guild.text_channels) - voice_channels = len(guild.voice_channels) + owner = f"{owner.username}#{owner.discriminator}" if owner else "||`[redacted]`||" + + categories = len([x for x in guild.channels if isinstance(x, GuildCategory)]) + text_channels = len([x for x in guild.channels if isinstance(x, GuildText)]) + voice_channels = len([x for x in guild.channels if isinstance(x, GuildVoice)]) + threads = len(guild.threads) members = guild.member_count roles = len(guild.roles) - role_list = ", ".join(role.name for role in guild.roles) + role_list = sorted(guild.roles, key=lambda x: x.position, reverse=True) + role_list = ", ".join(role.mention for role in role_list) fields = [ - Field(name="Owner", value=owner), - Field(name="Region", value=region), - Field(name="Channel Categories", value=categories), - Field(name="Text Channels", value=text_channels), - Field(name="Voice Channels", value=voice_channels), - Field(name="Members", value=members), - Field(name="Roles", value=roles), + EmbedField(name="Owner", value=owner, inline=True), + EmbedField(name="Channel Categories", value=str(categories), inline=True), + EmbedField(name="Text Channels", value=str(text_channels), inline=True), + EmbedField(name="Voice Channels", value=str(voice_channels), inline=True), + EmbedField(name="Threads", value=str(threads), inline=True), + EmbedField(name="Members", value=str(members), inline=True), + EmbedField(name="Roles", value=str(roles), inline=True), + EmbedField(name="Created At", value=f""), ] if len(role_list) < 1024: - fields.append(Field(name="Role List", value=role_list, inline=False)) + fields.append(EmbedField(name="Role List", value=role_list, inline=False)) embed = build_embed(title="", description="", fields=fields, timestamp=guild.created_at) - embed.set_author(name=guild.name, icon_url=guild.icon_url) - embed.set_thumbnail(url=guild.icon_url) + embed.set_author(name=guild.name, icon_url=guild.icon.url) + embed.set_thumbnail(url=guild.icon.url) embed.set_footer(text=f"ID: {guild.id} | Server Created") await ctx.send(embed=embed) - @cog_ext.cog_subcommand( - base="pw", - name="gen", - base_desc="Password utilites", + @slash_command( + name="pw", + sub_cmd_name="gen", description="Generate a secure password", - guild_ids=[862402786116763668], - options=[ - create_option( - name="length", - description="Password length (default 32)", - option_type=4, - required=False, - ), - create_option( - name="chars", - description="Characters to include (default last option)", - option_type=4, - required=False, - choices=[ - create_choice(name="A-Za-z", value=0), - create_choice(name="A-Fa-f0-9", value=1), - create_choice(name="A-Za-z0-9", value=2), - create_choice(name="A-Za-z0-9!@#$%^&*", value=3), - ], - ), + scopes=[862402786116763668], + ) + @slash_option( + name="length", + description="Password length (default 32)", + opt_type=OptionTypes.INTEGER, + required=False, + ) + @slash_option( + name="chars", + description="Characters to include (default last option)", + opt_type=OptionTypes.INTEGER, + required=False, + choices=[ + SlashCommandChoice(name="A-Za-z", value=0), + SlashCommandChoice(name="A-Fa-f0-9", value=1), + SlashCommandChoice(name="A-Za-z0-9", value=2), + SlashCommandChoice(name="A-Za-z0-9!@#$%^&*", value=3), ], ) - @commands.cooldown(1, 15, type=commands.BucketType.user) - async def _pw_gen(self, ctx: SlashContext, length: int = 32, chars: int = 3) -> None: + @cooldown(bucket=Buckets.USER, rate=1, interval=15) + async def _pw_gen(self, ctx: InteractionContext, length: int = 32, chars: int = 3) -> None: if length > 256: - await ctx.send("Please limit password to 256 characters", hidden=True) + await ctx.send("Please limit password to 256 characters", ephemeral=True) return + choices = [ string.ascii_letters, string.hexdigits, @@ -307,15 +321,14 @@ class UtilCog(commands.Cog): f"Generated password:\n`{pw}`\n\n" '**WARNING: Once you press "Dismiss Message", ' "*the password is lost forever***", - hidden=True, + ephemeral=True, ) - @cog_ext.cog_slash( - name="pigpen", - description="Encode a string into pigpen", - options=[create_option(name="text", description="Text to encode", option_type=3, required=True)], + @slash_command(name="pigpen", description="Encode a string into pigpen") + @slash_option( + name="text", description="Text to encode", opt_type=OptionTypes.STRING, required=True ) - async def _pigpen(self, ctx: SlashContext, text: str) -> None: + async def _pigpen(self, ctx: InteractionContext, text: str) -> None: outp = "`" for c in text: c = c.lower() @@ -329,7 +342,39 @@ class UtilCog(commands.Cog): outp += "`" await ctx.send(outp[:2000]) + @slash_command( + name="timestamp", description="Convert a datetime or timestamp into it's counterpart" + ) + @slash_option( + name="string", description="String to convert", opt_type=OptionTypes.STRING, required=True + ) + @slash_option( + name="private", description="Respond quietly?", opt_type=OptionTypes.BOOLEAN, required=False + ) + async def _timestamp(self, ctx: InteractionContext, string: str, private: bool = False) -> None: + timestamp = parse(string) + if not timestamp: + await ctx.send("Valid time not found, try again", ephemeral=True) + return -def setup(bot: commands.Bot) -> None: - """Add UtilCog to J.A.R.V.I.S.""" - bot.add_cog(UtilCog(bot)) + if not timestamp.tzinfo: + timestamp = timestamp.replace(tzinfo=get_localzone()).astimezone(tz=timezone.utc) + + timestamp_utc = timestamp.astimezone(tz=timezone.utc) + + ts = int(timestamp.timestamp()) + ts_utc = int(timestamp_utc.timestamp()) + fields = [ + EmbedField(name="Unix Epoch", value=f"`{ts}`"), + EmbedField(name="Unix Epoch (UTC)", value=f"`{ts_utc}`"), + EmbedField(name="Absolute Time", value=f"\n``"), + EmbedField(name="Relative Time", value=f"\n``"), + EmbedField(name="ISO8601", value=timestamp.isoformat()), + ] + embed = build_embed(title="Converted Time", description=f"`{string}`", fields=fields) + await ctx.send(embed=embed, ephemeral=private) + + +def setup(bot: Client) -> None: + """Add UtilCog to JARVIS""" + UtilCog(bot) diff --git a/jarvis/cogs/verify.py b/jarvis/cogs/verify.py index 540e65c..d921a60 100644 --- a/jarvis/cogs/verify.py +++ b/jarvis/cogs/verify.py @@ -1,12 +1,15 @@ -"""J.A.R.V.I.S. Verify Cog.""" +"""JARVIS Verify Cog.""" +import asyncio +import logging from random import randint -from discord.ext import commands -from discord_slash import ComponentContext, SlashContext, cog_ext -from discord_slash.model import ButtonStyle -from discord_slash.utils import manage_components - -from jarvis.db.models import Setting +from jarvis_core.db import q +from jarvis_core.db.models import Setting +from naff import Client, Cog, InteractionContext +from naff.models.discord.components import Button, ButtonStyles, spread_to_rows +from naff.models.naff.application_commands import slash_command +from naff.models.naff.command import cooldown +from naff.models.naff.cooldowns import Buckets def create_layout() -> list: @@ -16,77 +19,95 @@ def create_layout() -> list: for i in range(3): label = "YES" if i == yes else "NO" id = f"no_{i}" if not i == yes else "yes" - color = ButtonStyle.green if i == yes else ButtonStyle.red + color = ButtonStyles.GREEN if i == yes else ButtonStyles.RED buttons.append( - manage_components.create_button( + Button( style=color, label=label, custom_id=f"verify_button||{id}", ) ) - action_row = manage_components.spread_to_rows(*buttons, max_in_row=3) - return action_row + return spread_to_rows(*buttons, max_in_row=3) -class VerifyCog(commands.Cog): - """J.A.R.V.I.S. Verify Cog.""" +class VerifyCog(Cog): + """JARVIS Verify Cog.""" - def __init__(self, bot: commands.Bot): + def __init__(self, bot: Client): self.bot = bot + self.logger = logging.getLogger(__name__) - @cog_ext.cog_slash( - name="verify", - description="Verify that you've read the rules", - ) - @commands.cooldown(1, 15, commands.BucketType.user) - async def _verify(self, ctx: SlashContext) -> None: + @slash_command(name="verify", description="Verify that you've read the rules") + @cooldown(bucket=Buckets.USER, rate=1, interval=30) + async def _verify(self, ctx: InteractionContext) -> None: await ctx.defer() - role = Setting.objects(guild=ctx.guild.id, setting="verified").first() + role = await Setting.find_one(q(guild=ctx.guild.id, setting="verified")) if not role: - await ctx.send("This guild has not enabled verification", delete_after=5) + message = await ctx.send("This guild has not enabled verification", ephemeral=True) return - if ctx.guild.get_role(role.value) in ctx.author.roles: - await ctx.send("You are already verified.", delete_after=5) + verified_role = await ctx.guild.fetch_role(role.value) + if not verified_role: + await ctx.send("This guild has not enabled verification", ephemeral=True) + await role.delete() + return + + if verified_role in ctx.author.roles: + await ctx.send("You are already verified.", ephemeral=True) return components = create_layout() message = await ctx.send( content=f"{ctx.author.mention}, please press the button that says `YES`.", components=components, ) - await message.delete(delay=15) - @cog_ext.cog_component(components=create_layout()) - async def _process(self, ctx: ComponentContext) -> None: - await ctx.defer(edit_origin=True) try: - if ctx.author.id != ctx.origin_message.mentions[0].id: - return - except Exception: - return - correct = ctx.custom_id.split("||")[-1] == "yes" - if correct: - components = ctx.origin_message.components - for c in components: - for c2 in c["components"]: - c2["disabled"] = True - setting = Setting.objects(guild=ctx.guild.id, setting="verified").first() - role = ctx.guild.get_role(setting.value) - await ctx.author.add_roles(role, reason="Verification passed") - setting = Setting.objects(guild=ctx.guild.id, setting="unverified").first() - if setting: - role = ctx.guild.get_role(setting.value) - await ctx.author.remove_roles(role, reason="Verification passed") - await ctx.edit_origin( - content=f"Welcome, {ctx.author.mention}. Please enjoy your stay.", - components=manage_components.spread_to_rows(*components, max_in_row=5), - ) - await ctx.origin_message.delete(delay=5) - else: - await ctx.edit_origin( - content=f"{ctx.author.mention}, incorrect. Please press the button that says `YES`", - ) + verified = False + while not verified: + response = await self.bot.wait_for_component( + messages=message, + check=lambda x: ctx.author.id == x.context.author.id, + timeout=30, + ) + + correct = response.context.custom_id.split("||")[-1] == "yes" + if correct: + for row in components: + for component in row.components: + component.disabled = True + setting = await Setting.find_one(q(guild=ctx.guild.id, setting="verified")) + try: + role = await ctx.guild.fetch_role(setting.value) + await ctx.author.add_role(role, reason="Verification passed") + except AttributeError: + self.logger.warning("Verified role deleted before verification finished") + setting = await Setting.find_one(q(guild=ctx.guild.id, setting="unverified")) + if setting: + try: + role = await ctx.guild.fetch_role(setting.value) + await ctx.author.remove_role(role, reason="Verification passed") + except AttributeError: + self.logger.warning( + "Unverified role deleted before verification finished" + ) + + await response.context.edit_origin( + content=f"Welcome, {ctx.author.mention}. Please enjoy your stay.", + components=components, + ) + await response.context.message.delete(delay=5) + self.logger.debug(f"User {ctx.author.id} verified successfully") + else: + await response.context.edit_origin( + content=( + f"{ctx.author.mention}, incorrect. " + "Please press the button that says `YES`" + ) + ) + except asyncio.TimeoutError: + await message.delete(delay=2) + self.logger.debug(f"User {ctx.author.id} failed to verify before timeout") -def setup(bot: commands.Bot) -> None: - """Add VerifyCog to J.A.R.V.I.S.""" - bot.add_cog(VerifyCog(bot)) +def setup(bot: Client) -> None: + """Add VerifyCog to JARVIS""" + VerifyCog(bot) diff --git a/jarvis/config.py b/jarvis/config.py index 0338b29..5159b7c 100644 --- a/jarvis/config.py +++ b/jarvis/config.py @@ -1,83 +1,17 @@ -"""Load the config for J.A.R.V.I.S.""" -from pymongo import MongoClient -from yaml import load - -try: - from yaml import CLoader as Loader -except ImportError: - from yaml import Loader +"""Load the config for JARVIS""" +from jarvis_core.config import Config as CConfig -class Config(object): - """Config singleton object for J.A.R.V.I.S.""" - - def __new__(cls, *args: list, **kwargs: dict): - """Get the singleton config, or creates a new one.""" - it = cls.__dict__.get("it") - if it is not None: - return it - cls.__it__ = it = object.__new__(cls) - it.init(*args, **kwargs) - return it - - def init( - self, - token: str, - client_id: str, - logo: str, - mongo: dict, - urls: dict, - log_level: str = "WARNING", - cogs: list = None, - events: bool = True, - gitlab_token: str = None, - max_messages: int = 1000, - twitter: dict = None, - ) -> None: - """Initialize the config object.""" - self.token = token - self.client_id = client_id - self.logo = logo - self.mongo = mongo - self.urls = urls - self.log_level = log_level - self.cogs = cogs - self.events = events - self.max_messages = max_messages - self.gitlab_token = gitlab_token - self.twitter = twitter - self.__db_loaded = False - self.__mongo = MongoClient(**self.mongo["connect"]) - - def get_db_config(self) -> None: - """Load the database config objects.""" - if not self.__db_loaded: - db = self.__mongo[self.mongo["database"]] - items = db.config.find() - for item in items: - setattr(self, item["key"], item["value"]) - self.__db_loaded = True - - @classmethod - def from_yaml(cls, y: dict) -> "Config": - """Load the yaml config file.""" - instance = cls(**y) - return instance - - -def get_config(path: str = "config.yaml") -> Config: - """Get the config from the specified yaml file.""" - if Config.__dict__.get("it"): - return Config() - with open(path) as f: - raw = f.read() - y = load(raw, Loader=Loader) - config = Config.from_yaml(y) - config.get_db_config() - return config - - -def reload_config() -> None: - """Force reload of the config singleton on next call.""" - if "it" in Config.__dict__: - Config.__dict__.pop("it") +class JarvisConfig(CConfig): + REQUIRED = ("token", "mongo", "urls", "redis") + OPTIONAL = { + "sync": False, + "log_level": "WARNING", + "cogs": None, + "events": True, + "gitlab_token": None, + "max_messages": 1000, + "twitter": None, + "reddit": None, + "rook_token": None, + } diff --git a/jarvis/const.py b/jarvis/const.py new file mode 100644 index 0000000..d1f2869 --- /dev/null +++ b/jarvis/const.py @@ -0,0 +1,7 @@ +"""JARVIS constants.""" +from importlib.metadata import version as _v + +try: + __version__ = _v("jarvis") +except Exception: + __version__ = "0.0.0" diff --git a/jarvis/data/robotcamo.py b/jarvis/data/robotcamo.py index 3c1647e..3577833 100644 --- a/jarvis/data/robotcamo.py +++ b/jarvis/data/robotcamo.py @@ -50,32 +50,32 @@ emotes = { } names = { - 852317928572715038: "rcA", - 852317954975727679: "rcB", - 852317972424818688: "rcC", - 852317990238421003: "rcD", - 852318044503539732: "rcE", - 852318058353786880: "rcF", - 852318073994477579: "rcG", - 852318105832259614: "rcH", - 852318122278125580: "rcI", - 852318145074167818: "rcJ", - 852318159952412732: "rcK", - 852318179358408704: "rcL", - 852318241555873832: "rcM", - 852318311115128882: "rcN", - 852318329951223848: "rcO", - 852318344643477535: "rcP", - 852318358920757248: "rcQ", - 852318385638211594: "rcR", - 852318401166311504: "rcS", - 852318421524938773: "rcT", - 852318435181854742: "rcU", - 852318453204647956: "rcV", - 852318470267731978: "rcW", - 852318484749877278: "rcX", - 852318504564555796: "rcY", - 852318519449092176: "rcZ", + 852317928572715038: "rcLetterA", + 852317954975727679: "rcLetterB", + 852317972424818688: "rcLetterC", + 852317990238421003: "rcLetterD", + 852318044503539732: "rcLetterE", + 852318058353786880: "rcLetterF", + 852318073994477579: "rcLetterG", + 852318105832259614: "rcLetterH", + 852318122278125580: "rcLetterI", + 852318145074167818: "rcLetterJ", + 852318159952412732: "rcLetterK", + 852318179358408704: "rcLetterL", + 852318241555873832: "rcLetterM", + 852318311115128882: "rcLetterN", + 852318329951223848: "rcLetterO", + 852318344643477535: "rcLetterP", + 852318358920757248: "rcLetterQ", + 852318385638211594: "rcLetterR", + 852318401166311504: "rcLetterS", + 852318421524938773: "rcLetterT", + 852318435181854742: "rcLetterU", + 852318453204647956: "rcLetterV", + 852318470267731978: "rcLetterW", + 852318484749877278: "rcLetterX", + 852318504564555796: "rcLetterY", + 852318519449092176: "rcLetterZ", 860663352740151316: "rc1", 860662785243348992: "rc2", 860662950011469854: "rc3", diff --git a/jarvis/db/models.py b/jarvis/db/models.py deleted file mode 100644 index 2449012..0000000 --- a/jarvis/db/models.py +++ /dev/null @@ -1,261 +0,0 @@ -"""J.A.R.V.I.S. database object for mongoengine.""" -from datetime import datetime - -from mongoengine import Document -from mongoengine.fields import ( - BooleanField, - DateTimeField, - DictField, - DynamicField, - IntField, - ListField, - LongField, - StringField, -) - - -class SnowflakeField(LongField): - """Snowflake LongField Override.""" - - pass - - -class Autopurge(Document): - """Autopurge database object.""" - - guild = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - delay = IntField(min_value=1, max_value=300, default=30) - admin = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Autoreact(Document): - """Autoreact database object.""" - - guild = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - reactions = ListField(field=StringField()) - admin = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Ban(Document): - """Ban database object.""" - - active = BooleanField(default=True) - admin = SnowflakeField(required=True) - user = SnowflakeField(required=True) - username = StringField(required=True) - discrim = IntField(min_value=1, max_value=9999, required=True) - duration = IntField(min_value=1, max_value=744, required=False) - guild = SnowflakeField(required=True) - type = StringField(default="perm", max_length=4, required=True) - reason = StringField(max_length=100, required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Config(Document): - """Config database object.""" - - key = StringField(required=True) - value = DynamicField(required=True) - - meta = {"db_alias": "main"} - - -class Guess(Document): - """Guess database object.""" - - correct = BooleanField(default=False) - guess = StringField(max_length=800, required=True) - user = SnowflakeField(required=True) - - meta = {"db_alias": "ctc2"} - - -class Joke(Document): - """Joke database object.""" - - rid = StringField() - body = StringField() - title = StringField() - created_utc = DateTimeField() - over_18 = BooleanField() - score = IntField() - - meta = {"db_alias": "main"} - - -class Kick(Document): - """Kick database object.""" - - admin = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - reason = StringField(max_length=100, required=True) - user = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Lock(Document): - """Lock database object.""" - - active = BooleanField(default=True) - admin = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - duration = IntField(min_value=1, max_value=300, default=10) - guild = SnowflakeField(required=True) - reason = StringField(max_length=100, required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Mute(Document): - """Mute database object.""" - - active = BooleanField(default=True) - user = SnowflakeField(required=True) - admin = SnowflakeField(required=True) - duration = IntField(min_value=-1, max_value=300, default=10) - guild = SnowflakeField(required=True) - reason = StringField(max_length=100, required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Purge(Document): - """Purge database object.""" - - admin = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - count = IntField(min_value=1, default=10) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Reminder(Document): - """Reminder database object.""" - - active = BooleanField(default=True) - user = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - message = StringField(max_length=100, required=True) - remind_at = DateTimeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Rolegiver(Document): - """Rolegiver database object.""" - - guild = SnowflakeField(required=True) - roles = ListField(field=SnowflakeField()) - - meta = {"db_alias": "main"} - - -class Roleping(Document): - """Roleping database object.""" - - active = BooleanField(default=True) - role = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - admin = SnowflakeField(required=True) - bypass = DictField() - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Setting(Document): - """Setting database object.""" - - guild = SnowflakeField(required=True) - setting = StringField(required=True) - value = DynamicField() - - meta = {"db_alias": "main"} - - -class Star(Document): - """Star database object.""" - - active = BooleanField(default=True) - index = IntField(required=True) - message = SnowflakeField(required=True) - channel = SnowflakeField(required=True) - starboard = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - admin = SnowflakeField(required=True) - star = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Starboard(Document): - """Starboard database object.""" - - channel = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - admin = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Twitter(Document): - """Twitter Follow object.""" - - active = BooleanField(default=True) - handle = StringField(required=True) - channel = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - last_tweet = SnowflakeField(required=True) - retweets = BooleanField(default=True) - admin = SnowflakeField(required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Unban(Document): - """Unban database object.""" - - user = SnowflakeField(required=True) - username = StringField(required=True) - discrim = IntField(min_value=1, max_value=9999, required=True) - guild = SnowflakeField(required=True) - admin = SnowflakeField(required=True) - reason = StringField(max_length=100, required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} - - -class Warning(Document): - """Warning database object.""" - - active = BooleanField(default=True) - admin = SnowflakeField(required=True) - user = SnowflakeField(required=True) - guild = SnowflakeField(required=True) - duration = IntField(min_value=1, max_value=120, default=24) - reason = StringField(max_length=100, required=True) - created_at = DateTimeField(default=datetime.utcnow) - - meta = {"db_alias": "main"} diff --git a/jarvis/events/guild.py b/jarvis/events/guild.py deleted file mode 100644 index 5f7dc62..0000000 --- a/jarvis/events/guild.py +++ /dev/null @@ -1,36 +0,0 @@ -"""J.A.R.V.I.S. guild event handler.""" -import asyncio - -from discord import Guild -from discord.ext.commands import Bot -from discord.utils import find - -from jarvis.db.models import Setting - - -class GuildEventHandler(object): - """J.A.R.V.I.S. guild event handler.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.add_listener(self.on_guild_join) - - async def on_guild_join(self, guild: Guild) -> None: - """Handle on_guild_join event.""" - general = find(lambda x: x.name == "general", guild.channels) - if general and general.permissions_for(guild.me).send_messages: - user = self.bot.user - await general.send( - f"Allow me to introduce myself. I am {user.mention}, a virtual " - "artificial intelligence, and I'm here to assist you with a " - "variety of tasks as best I can, " - "24 hours a day, seven days a week." - ) - await asyncio.sleep(1) - await general.send("Importing all preferences from home interface...") - - # Set some default settings - _ = Setting(guild=guild.id, setting="massmention", value=5).save() - _ = Setting(guild=guild.id, setting="noinvite", value=True).save() - - await general.send("Systems are now fully operational") diff --git a/jarvis/events/member.py b/jarvis/events/member.py deleted file mode 100644 index 8dec15e..0000000 --- a/jarvis/events/member.py +++ /dev/null @@ -1,27 +0,0 @@ -"""J.A.R.V.I.S. Member event handler.""" -from discord import Member -from discord.ext.commands import Bot - -from jarvis.db.models import Mute, Setting - - -class MemberEventHandler(object): - """J.A.R.V.I.S. Member event handler.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.add_listener(self.on_member_join) - - async def on_member_join(self, user: Member) -> None: - """Handle on_member_join event.""" - guild = user.guild - mute = Mute.objects(guild=guild.id, user=user.id, active=True).first() - if mute: - mute_role = Setting.objects(guild=guild.id, setting="mute").first() - role = guild.get_role(mute_role.value) - await user.add_roles(role, reason="User is still muted from prior mute") - unverified = Setting.objects(guild=guild.id, setting="unverified").first() - if unverified: - role = guild.get_role(unverified.value) - if role not in user.roles: - await user.add_roles(role, reason="User just joined and is unverified") diff --git a/jarvis/events/message.py b/jarvis/events/message.py deleted file mode 100644 index 2cfea81..0000000 --- a/jarvis/events/message.py +++ /dev/null @@ -1,210 +0,0 @@ -"""J.A.R.V.I.S. Message event handler.""" -import re - -from discord import DMChannel, Message -from discord.ext.commands import Bot -from discord.utils import find - -from jarvis.config import get_config -from jarvis.db.models import Autopurge, Autoreact, Roleping, Setting, Warning -from jarvis.utils import build_embed -from jarvis.utils.field import Field - -invites = re.compile( - r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", - flags=re.IGNORECASE, -) - - -class MessageEventHandler(object): - """J.A.R.V.I.S. Message event handler.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.bot.add_listener(self.on_message) - self.bot.add_listener(self.on_message_edit) - - async def autopurge(self, message: Message) -> None: - """Handle autopurge events.""" - autopurge = Autopurge.objects(guild=message.guild.id, channel=message.channel.id).first() - if autopurge: - await message.delete(delay=autopurge.delay) - - async def autoreact(self, message: Message) -> None: - """Handle autoreact events.""" - autoreact = Autoreact.objects( - guild=message.guild.id, - channel=message.channel.id, - ).first() - if autoreact: - for reaction in autoreact.reactions: - await message.add_reaction(reaction) - - async def checks(self, message: Message) -> None: - """Other message checks.""" - # #tech - channel = find(lambda x: x.id == 599068193339736096, message.channel_mentions) - if channel and message.author.id == 293795462752894976: - await channel.send( - content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif" - ) - content = re.sub(r"\s+", "", message.content) - match = invites.search(content) - setting = Setting.objects(guild=message.guild.id, setting="noinvite").first() - if not setting: - setting = Setting(guild=message.guild.id, setting="noinvite", value=True) - setting.save() - if match: - guild_invites = await message.guild.invites() - allowed = [x.code for x in guild_invites] + [ - "dbrand", - "VtgZntXcnZ", - "gPfYGbvTCE", - ] - if match.group(1) not in allowed and setting.value: - await message.delete() - _ = Warning( - active=True, - admin=get_config().client_id, - duration=24, - guild=message.guild.id, - reason="Sent an invite link", - user=message.author.id, - ).save() - fields = [ - Field( - "Reason", - "Sent an invite link", - False, - ) - ] - embed = build_embed( - title="Warning", - description=f"{message.author.mention} has been warned", - fields=fields, - ) - embed.set_author( - name=message.author.nick if message.author.nick else message.author.name, - icon_url=message.author.avatar_url, - ) - embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}") - await message.channel.send(embed=embed) - - async def massmention(self, message: Message) -> None: - """Handle massmention events.""" - massmention = Setting.objects( - guild=message.guild.id, - setting="massmention", - ).first() - if ( - massmention - and massmention.value > 0 # noqa: W503 - and len(message.mentions) - (1 if message.author in message.mentions else 0) # noqa: W503 - > massmention.value # noqa: W503 - ): - _ = Warning( - active=True, - admin=get_config().client_id, - duration=24, - guild=message.guild.id, - reason="Mass Mention", - user=message.author.id, - ).save() - fields = [Field("Reason", "Mass Mention", False)] - embed = build_embed( - title="Warning", - description=f"{message.author.mention} has been warned", - fields=fields, - ) - embed.set_author( - name=message.author.nick if message.author.nick else message.author.name, - icon_url=message.author.avatar_url, - ) - embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}") - await message.channel.send(embed=embed) - - async def roleping(self, message: Message) -> None: - """Handle roleping events.""" - rolepings = Roleping.objects(guild=message.guild.id, active=True) - - if not rolepings: - return - - # Get all role IDs involved with message - roles = [] - for mention in message.role_mentions: - roles.append(mention.id) - for mention in message.mentions: - for role in mention.roles: - roles.append(role.id) - - if not roles: - return - - # Get all roles that are rolepinged - roleping_ids = [r.role for r in rolepings] - - # Get roles in rolepings - role_in_rolepings = list(filter(lambda x: x in roleping_ids, roles)) - - # Check if the user has the role, so they are allowed to ping it - user_missing_role = any(x.id not in roleping_ids for x in message.author.roles) - - # Admins can ping whoever - user_is_admin = message.author.guild_permissions.administrator - - # Check if user in a bypass list - user_has_bypass = False - for roleping in rolepings: - if message.author.id in roleping.bypass["users"]: - user_has_bypass = True - break - if any(role.id in roleping.bypass["roles"] for role in message.author.roles): - user_has_bypass = True - break - - if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass: - _ = Warning( - active=True, - admin=get_config().client_id, - duration=24, - guild=message.guild.id, - reason="Pinged a blocked role/user with a blocked role", - user=message.author.id, - ).save() - fields = [ - Field( - "Reason", - "Pinged a blocked role/user with a blocked role", - False, - ) - ] - embed = build_embed( - title="Warning", - description=f"{message.author.mention} has been warned", - fields=fields, - ) - embed.set_author( - name=message.author.nick if message.author.nick else message.author.name, - icon_url=message.author.avatar_url, - ) - embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}") - await message.channel.send(embed=embed) - - async def on_message(self, message: Message) -> None: - """Handle on_message event. Calls other event handlers.""" - if not isinstance(message.channel, DMChannel) and not message.author.bot: - await self.autoreact(message) - await self.massmention(message) - await self.roleping(message) - await self.autopurge(message) - await self.checks(message) - - async def on_message_edit(self, before: Message, after: Message) -> None: - """Handle on_message_edit event. Calls other event handlers.""" - if not isinstance(after.channel, DMChannel) and not after.author.bot: - await self.massmention(after) - await self.roleping(after) - await self.checks(after) - await self.roleping(after) - await self.checks(after) diff --git a/jarvis/logo.py b/jarvis/logo.py deleted file mode 100644 index 79e550c..0000000 --- a/jarvis/logo.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Logos for J.A.R.V.I.S.""" - -logo_doom = r""" - ___ ___ ______ _ _ _____ _____ - |_ | / _ \ | ___ \ | | | | |_ _| / ___| - | | / /_\ \ | |_/ / | | | | | | \ `--. - | | | _ | | / | | | | | | `--. \ -/\__/ / _ | | | | _ | |\ \ _ \ \_/ / _ _| |_ _ /\__/ / _ -\____/ (_)\_| |_/(_)\_| \_|(_) \___/ (_) \___/ (_)\____/ (_) - -""" - -logo_epic = r""" -_________ _______ _______ _________ _______ -\__ _/ ( ___ ) ( ____ ) |\ /| \__ __/ ( ____ \ - ) ( | ( ) | | ( )| | ) ( | ) ( | ( \/ - | | | (___) | | (____)| | | | | | | | (_____ - | | | ___ | | __) ( ( ) ) | | (_____ ) - | | | ( ) | | (\ ( \ \_/ / | | ) | -|\_) ) _ | ) ( | _ | ) \ \__ _ \ / _ ___) (___ _ /\____) | _ -(____/ (_)|/ \|(_)|/ \__/(_) \_/ (_)\_______/(_)\_______)(_) - -""" - -logo_ivrit = r""" - _ _ ____ __ __ ___ ____ - | | / \ | _ \ \ \ / / |_ _| / ___| - _ | | / _ \ | |_) | \ \ / / | | \___ \ - | |_| | _ / ___ \ _ | _ < _ \ V / _ | | _ ___) | _ - \___/ (_) /_/ \_\ (_) |_| \_\ (_) \_/ (_) |___| (_) |____/ (_) - -""" - -logo_kban = r""" - - '||' . | . '||''|. . '||' '|' . '||' . .|'''.| . - || ||| || || '|. .' || ||.. ' - || | || ||''|' || | || ''|||. - || .''''|. || |. ||| || . '|| -|| .|' .|. .||. .||. '|' | .||. |'....|' - ''' - -""" - -logo_larry3d = r""" - - _____ ______ ____ __ __ ______ ____ -/\___ \ /\ _ \ /\ _`\ /\ \/\ \ /\__ _\ /\ _`\ -\/__/\ \ \ \ \L\ \ \ \ \L\ \ \ \ \ \ \ \/_/\ \/ \ \,\L\_\ - _\ \ \ \ \ __ \ \ \ , / \ \ \ \ \ \ \ \ \/_\__ \ - /\ \_\ \ __ \ \ \/\ \ __ \ \ \\ \ __ \ \ \_/ \ __ \_\ \__ __ /\ \L\ \ __ - \ \____//\_\ \ \_\ \_\/\_\ \ \_\ \_\/\_\ \ `\___//\_\ /\_____\/\_\ \ `\____\/\_\ - \/___/ \/_/ \/_/\/_/\/_/ \/_/\/ /\/_/ `\/__/ \/_/ \/_____/\/_/ \/_____/\/_/ - -""" - -logo_slane = r""" - - __ ___ ____ _ __ ____ _____ - / / / | / __ \ | | / / / _/ / ___/ - __ / / / /| | / /_/ / | | / / / / \__ \ -/ /_/ / _ / ___ | _ / _, _/ _ | |/ / _ _/ / _ ___/ / _ -\____/ (_)/_/ |_|(_)/_/ |_| (_)|___/ (_)/___/ (_)/____/ (_) - -""" - -logo_standard = r""" - - _ _ ____ __ __ ___ ____ - | | / \ | _ \ \ \ / / |_ _| / ___| - _ | | / _ \ | |_) | \ \ / / | | \___ \ - | |_| | _ / ___ \ _ | _ < _ \ V / _ | | _ ___) | _ - \___/ (_) /_/ \_\ (_) |_| \_\ (_) \_/ (_) |___| (_) |____/ (_) - -""" - -logo_alligator = r""" - - ::::::::::: ::: ::::::::: ::: ::: ::::::::::: :::::::: - :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: - +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ - +#+ +#++:++#++: +#++:++#: +#+ +:+ +#+ +#++:++#++ - +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ -#+# #+# #+# #+# #+# #+# #+# #+# #+# #+#+#+# #+# #+# #+# #+# #+# #+# -##### ### ### ### ### ### ### ### ### ### ########### ### ######## ### - -""" # noqa: E501 - -logo_alligator2 = r""" - -::::::::::: ::: ::::::::: ::: ::: ::::::::::: :::::::: - :+: :+: :+: :+: :+: :+: :+: :+: :+: :+: - +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ - +#+ +#++:++#++: +#++:++#: +#+ +:+ +#+ +#++:++#++ - +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ -#+# #+# #+# #+# #+# #+# #+# #+# #+# #+#+#+# #+# #+# #+# #+# #+# #+# - ##### ### ### ### ### ### ### ### ### ### ########### ### ######## ### - -""" - - -def get_logo(lo: str) -> str: - """Get a logo.""" - if "logo_" not in lo: - lo = "logo_" + lo - return globals()[lo] if lo in globals() else logo_alligator2 diff --git a/jarvis/tasks/__init__.py b/jarvis/tasks/__init__.py deleted file mode 100644 index 3da9656..0000000 --- a/jarvis/tasks/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""J.A.R.V.I.S. background task handlers.""" -from jarvis.tasks import unban, unlock, unmute, unwarn - - -def init() -> None: - """Start the background task handlers.""" - unban.unban.start() - unlock.unlock.start() - unmute.unmute.start() - unwarn.unwarn.start() diff --git a/jarvis/tasks/unban.py b/jarvis/tasks/unban.py deleted file mode 100644 index 45c0a67..0000000 --- a/jarvis/tasks/unban.py +++ /dev/null @@ -1,37 +0,0 @@ -"""J.A.R.V.I.S. unban background task handler.""" -from datetime import datetime, timedelta - -from discord.ext.tasks import loop - -import jarvis -from jarvis.config import get_config -from jarvis.db.models import Ban, Unban - -jarvis_id = get_config().client_id - - -@loop(minutes=10) -async def unban() -> None: - """J.A.R.V.I.S. unban background task.""" - bans = Ban.objects(type="temp", active=True) - unbans = [] - for ban in bans: - if ban.created_at + timedelta(hours=ban.duration) < datetime.utcnow() + timedelta(minutes=10): - guild = await jarvis.jarvis.fetch_guild(ban.guild) - user = await jarvis.jarvis.fetch_user(ban.user) - if user: - guild.unban(user) - ban.active = False - ban.save() - unbans.append( - Unban( - user=user.id, - guild=guild.id, - username=user.name, - discrim=user.discriminator, - admin=jarvis_id, - reason="Ban expired", - ) - ) - if unbans: - Ban.objects().insert(unbans) diff --git a/jarvis/tasks/unlock.py b/jarvis/tasks/unlock.py deleted file mode 100644 index 19cf83b..0000000 --- a/jarvis/tasks/unlock.py +++ /dev/null @@ -1,25 +0,0 @@ -"""J.A.R.V.I.S. unlock background task handler.""" -from datetime import datetime, timedelta - -from discord.ext.tasks import loop - -import jarvis -from jarvis.db.models import Lock - - -@loop(minutes=1) -async def unlock() -> None: - """J.A.R.V.I.S. unlock background task.""" - locks = Lock.objects(active=True) - for lock in locks: - if lock.created_at + timedelta(minutes=lock.duration) < datetime.utcnow(): - guild = await jarvis.jarvis.fetch_guild(lock.guild) - channel = await jarvis.jarvis.fetch_channel(lock.channel) - if channel: - roles = await guild.fetch_roles() - for role in roles: - overrides = channel.overwrites_for(role) - overrides.send_messages = None - await channel.set_permissions(role, overwrite=overrides, reason="Lock expired") - lock.active = False - lock.save() diff --git a/jarvis/tasks/unmute.py b/jarvis/tasks/unmute.py deleted file mode 100644 index 6b24741..0000000 --- a/jarvis/tasks/unmute.py +++ /dev/null @@ -1,27 +0,0 @@ -"""J.A.R.V.I.S. unmute background task handler.""" -from datetime import datetime, timedelta - -from discord.ext.tasks import loop - -import jarvis -from jarvis.db.models import Mute, Setting - - -@loop(minutes=1) -async def unmute() -> None: - """J.A.R.V.I.S. unmute background task.""" - mutes = Mute.objects(duration__gt=0, active=True) - mute_roles = Setting.objects(setting="mute") - for mute in mutes: - if mute.created_at + timedelta(minutes=mute.duration) < datetime.utcnow(): - mute_role = [x.value for x in mute_roles if x.guild == mute.guild][0] - guild = await jarvis.jarvis.fetch_guild(mute.guild) - role = guild.get_role(mute_role) - user = await guild.fetch_member(mute.user) - if user: - if role in user.roles: - await user.remove_roles(role, reason="Mute expired") - - # Objects can't handle bulk_write, so handle it via raw methods - mute.active = False - mute.save diff --git a/jarvis/tasks/unwarn.py b/jarvis/tasks/unwarn.py deleted file mode 100644 index 6b0dd91..0000000 --- a/jarvis/tasks/unwarn.py +++ /dev/null @@ -1,16 +0,0 @@ -"""J.A.R.V.I.S. unwarn background task handler.""" -from datetime import datetime, timedelta - -from discord.ext.tasks import loop - -from jarvis.db.models import Warning - - -@loop(hours=1) -async def unwarn() -> None: - """J.A.R.V.I.S. unwarn background task.""" - warns = Warning.objects(active=True) - for warn in warns: - if warn.created_at + timedelta(hours=warn.duration) < datetime.utcnow(): - warn.active = False - warn.save() diff --git a/jarvis/utils/__init__.py b/jarvis/utils/__init__.py index 2887a0d..2bece3a 100644 --- a/jarvis/utils/__init__.py +++ b/jarvis/utils/__init__.py @@ -1,60 +1,13 @@ -"""J.A.R.V.I.S. Utility Functions.""" -from datetime import datetime +"""JARVIS Utility Functions.""" +from datetime import datetime, timezone from pkgutil import iter_modules import git -from discord import Color, Embed, Message -from discord.ext import commands +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.guild import AuditLogEntry +from naff.models.discord.user import Member -import jarvis.cogs -import jarvis.db -from jarvis.config import get_config - -__all__ = ["field", "db", "cachecog", "permissions"] - - -def convert_bytesize(b: int) -> str: - """Convert bytes amount to human readable.""" - b = float(b) - sizes = ["B", "KB", "MB", "GB", "TB", "PB"] - size = 0 - while b >= 1024 and size < len(sizes) - 1: - b = b / 1024 - size += 1 - return "{:0.3f} {}".format(b, sizes[size]) - - -def unconvert_bytesize(size: int, ending: str) -> int: - """Convert human readable to bytes.""" - ending = ending.upper() - sizes = ["B", "KB", "MB", "GB", "TB", "PB"] - if ending == "B": - return size - # Rounding is only because bytes cannot be partial - return round(size * (1024 ** sizes.index(ending))) - - -def get_prefix(bot: commands.Bot, message: Message) -> list: - """Get bot prefixes.""" - prefixes = ["!", "-", "%"] - # if not message.guild: - # return "?" - - return commands.when_mentioned_or(*prefixes)(bot, message) - - -def get_extensions(path: str = jarvis.cogs.__path__) -> list: - """Get J.A.R.V.I.S. cogs.""" - config = get_config() - vals = config.cogs or [x.name for x in iter_modules(path)] - return ["jarvis.cogs.{}".format(x) for x in vals] - - -def parse_color_hex(hex: str) -> Color: - """Convert a hex color to a d.py Color.""" - hex = hex.lstrip("#") - rgb = tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)) # noqa: E203 - return Color.from_rgb(*rgb) +from jarvis.config import JarvisConfig def build_embed( @@ -67,11 +20,11 @@ def build_embed( ) -> Embed: """Embed builder utility function.""" if not timestamp: - timestamp = datetime.utcnow() + timestamp = datetime.now(tz=timezone.utc) embed = Embed( title=title, description=description, - color=parse_color_hex(color), + color=color, timestamp=timestamp, **kwargs, ) @@ -80,8 +33,43 @@ def build_embed( return embed +def modlog_embed( + member: Member, + admin: Member, + log: AuditLogEntry, + title: str, + desc: str, +) -> Embed: + """Get modlog embed.""" + fields = [ + EmbedField( + name="Moderator", + value=f"{admin.mention} ({admin.username}#{admin.discriminator})", + ), + ] + if log.reason: + fields.append(EmbedField(name="Reason", value=log.reason, inline=False)) + embed = build_embed( + title=title, + description=desc, + color="#fc9e3f", + fields=fields, + timestamp=log.created_at, + ) + embed.set_author(name=f"{member.username}", icon_url=member.display_avatar.url) + embed.set_footer(text=f"{member.username}#{member.discriminator} | {member.id}") + return embed + + +def get_extensions(path: str) -> list: + """Get JARVIS cogs.""" + config = JarvisConfig.from_yaml() + vals = config.cogs or [x.name for x in iter_modules(path)] + return [f"jarvis.cogs.{x}" for x in vals] + + def update() -> int: - """J.A.R.V.I.S. update utility.""" + """JARVIS update utility.""" repo = git.Repo(".") dirty = repo.is_dirty() current_hash = repo.head.object.hexsha @@ -96,6 +84,6 @@ def update() -> int: def get_repo_hash() -> str: - """J.A.R.V.I.S. current branch hash.""" + """JARVIS current branch hash.""" repo = git.Repo(".") return repo.head.object.hexsha diff --git a/jarvis/utils/cachecog.py b/jarvis/utils/cachecog.py deleted file mode 100644 index 12c043d..0000000 --- a/jarvis/utils/cachecog.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Cog wrapper for command caching.""" -from datetime import datetime, timedelta - -from discord.ext import commands -from discord.ext.tasks import loop -from discord.utils import find -from discord_slash import SlashContext - - -class CacheCog(commands.Cog): - """Cog wrapper for command caching.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.cache = {} - self._expire_interaction.start() - - def check_cache(self, ctx: SlashContext, **kwargs: dict) -> dict: - """Check the cache.""" - if not kwargs: - kwargs = {} - return find( - lambda x: x["command"] == ctx.subcommand_name # noqa: W503 - and x["user"] == ctx.author.id # noqa: W503 - and x["guild"] == ctx.guild.id # noqa: W503 - and all(x[k] == v for k, v in kwargs.items()), # noqa: W503 - self.cache.values(), - ) - - @loop(minutes=1) - async def _expire_interaction(self) -> None: - keys = list(self.cache.keys()) - for key in keys: - if self.cache[key]["timeout"] <= datetime.utcnow() + timedelta(minutes=1): - del self.cache[key] diff --git a/jarvis/utils/cogs.py b/jarvis/utils/cogs.py new file mode 100644 index 0000000..c18ac38 --- /dev/null +++ b/jarvis/utils/cogs.py @@ -0,0 +1,119 @@ +"""Cog wrapper for command caching.""" +import logging +from datetime import timedelta + +from jarvis_core.db import q +from jarvis_core.db.models import Action, Ban, Kick, Modlog, Mute, Setting, Warning +from naff import Client, Cog, InteractionContext +from naff.models.discord.components import ActionRow, Button, ButtonStyles +from naff.models.discord.embed import EmbedField + +from jarvis.utils import build_embed + +MODLOG_LOOKUP = {"Ban": Ban, "Kick": Kick, "Mute": Mute, "Warning": Warning} +IGNORE_COMMANDS = {"Ban": ["bans"], "Kick": [], "Mute": ["unmute"], "Warning": ["warnings"]} + + +class ModcaseCog(Cog): + """Cog wrapper for moderation case logging.""" + + def __init__(self, bot: Client): + self.bot = bot + self.logger = logging.getLogger(__name__) + self.add_cog_postrun(self.log) + + async def log(self, ctx: InteractionContext, *_args: list, **kwargs: dict) -> None: + """ + Log a moderation activity in a moderation case. + + Args: + ctx: Command context + """ + name = self.__name__.replace("Cog", "") + + if name in MODLOG_LOOKUP and ctx.invoke_target not in IGNORE_COMMANDS[name]: + user = kwargs.pop("user", None) + if not user and not ctx.target_id: + self.logger.warning("Admin action %s missing user, exiting", name) + return + if ctx.target_id: + user = ctx.target + coll = MODLOG_LOOKUP.get(name, None) + if not coll: + self.logger.warning("Unsupported action %s, exiting", name) + return + + action = await coll.find_one( + q(user=user.id, guild=ctx.guild_id, active=True), sort=[("_id", -1)] + ) + if not action: + self.logger.warning("Missing action %s, exiting", name) + return + + notify = await Setting.find_one(q(guild=ctx.guild.id, setting="notify", value=True)) + if notify and name not in ("Kick", "Ban"): # Ignore Kick and Ban, as these are unique + fields = ( + EmbedField(name="Action Type", value=name, inline=False), + EmbedField( + name="Reason", value=kwargs.get("reason", None) or "N/A", inline=False + ), + ) + embed = build_embed( + title="Admin action taken", + description=f"Admin action has been taken against you in {ctx.guild.name}", + fields=fields, + ) + guild_url = f"https://discord.com/channels/{ctx.guild.id}" + embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url, url=guild_url) + embed.set_thumbnail(url=ctx.guild.icon.url) + try: + await user.send(embed=embed) + except Exception: + self.logger.debug("User not warned of action due to closed DMs") + + modlog = await Modlog.find_one(q(user=user.id, guild=ctx.guild.id, open=True)) + if modlog: + m_action = Action(action_type=name.lower(), parent=action.id) + modlog.actions.append(m_action) + await modlog.commit() + return + + lookup_key = f"{user.id}|{ctx.guild.id}" + + async with self.bot.redis.lock("lock|" + lookup_key): + if await self.bot.redis.get(lookup_key): + self.logger.debug(f"User {user.id} in {ctx.guild.id} already has pending case") + return + + modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="modlog")) + if not modlog: + return + + channel = await ctx.guild.fetch_channel(modlog.value) + if not channel: + self.logger.warn( + f"Guild {ctx.guild.id} modlog channel no longer exists, deleting" + ) + await modlog.delete() + return + + embed = build_embed( + title="Recent Action Taken", + description=f"Would you like to open a moderation case for {user.mention}?", + fields=[], + ) + embed.set_author( + name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url + ) + components = [ + ActionRow( + Button(style=ButtonStyles.RED, emoji="✖️", custom_id="modcase|no"), + Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="modcase|yes"), + ) + ] + message = await channel.send(embed=embed, components=components) + + await self.bot.redis.set( + lookup_key, f"{name.lower()}|{action.id}", ex=timedelta(days=7) + ) + await self.bot.redis.set(f"msg|{message.id}", user.id, ex=timedelta(days=7)) diff --git a/jarvis/utils/embeds.py b/jarvis/utils/embeds.py new file mode 100644 index 0000000..3d9e929 --- /dev/null +++ b/jarvis/utils/embeds.py @@ -0,0 +1,22 @@ +"""JARVIS bot-specific embeds.""" +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.user import Member + +from jarvis.utils import build_embed + + +def warning_embed(user: Member, reason: str) -> Embed: + """ + Generate a warning embed. + + Args: + user: User to warn + reason: Warning reason + """ + fields = (EmbedField(name="Reason", value=reason, inline=False),) + embed = build_embed( + title="Warning", description=f"{user.mention} has been warned", fields=fields + ) + embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) + embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") + return embed diff --git a/jarvis/utils/field.py b/jarvis/utils/field.py deleted file mode 100644 index 8339f65..0000000 --- a/jarvis/utils/field.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Embed field helper.""" -from dataclasses import dataclass -from typing import Any - - -@dataclass -class Field: - """Embed Field.""" - - name: Any - value: Any - inline: bool = True - - def to_dict(self) -> dict: - """Convert Field to d.py field dict.""" - return {"name": self.name, "value": self.value, "inline": self.inline} diff --git a/jarvis/utils/permissions.py b/jarvis/utils/permissions.py index fc1c86b..cac2ff2 100644 --- a/jarvis/utils/permissions.py +++ b/jarvis/utils/permissions.py @@ -1,28 +1,30 @@ """Permissions wrappers.""" -from discord.ext import commands +from naff import InteractionContext, Permissions -from jarvis.config import get_config +from jarvis.config import JarvisConfig def user_is_bot_admin() -> bool: - """Check if a user is a J.A.R.V.I.S. admin.""" + """Check if a user is a JARVIS admin.""" - def predicate(ctx: commands.Context) -> bool: + async def predicate(ctx: InteractionContext) -> bool: """Command check predicate.""" - if getattr(get_config(), "admins", None): - return ctx.author.id in get_config().admins + cfg = JarvisConfig.from_yaml() + if getattr(cfg, "admins", None): + return ctx.author.id in cfg.admins else: return False - return commands.check(predicate) + return predicate -def admin_or_permissions(**perms: dict) -> bool: +def admin_or_permissions(*perms: list) -> bool: """Check if a user is an admin or has other perms.""" - original = commands.has_permissions(**perms).predicate - async def extended_check(ctx: commands.Context) -> bool: + async def predicate(ctx: InteractionContext) -> bool: """Extended check predicate.""" # noqa: D401 - return await commands.has_permissions(administrator=True).predicate(ctx) or await original(ctx) + is_admin = ctx.author.has_permission(Permissions.ADMINISTRATOR) + has_other = any(ctx.author.has_permission(perm) for perm in perms) + return is_admin or has_other - return commands.check(extended_check) + return predicate diff --git a/jarvis/utils/updates.py b/jarvis/utils/updates.py new file mode 100644 index 0000000..3dd52f5 --- /dev/null +++ b/jarvis/utils/updates.py @@ -0,0 +1,217 @@ +"""JARVIS update handler.""" +import asyncio +import logging +from dataclasses import dataclass +from importlib import import_module +from inspect import getmembers, isclass +from pkgutil import iter_modules +from types import FunctionType, ModuleType +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional + +import git +from naff.client.errors import ExtensionNotFound +from naff.client.utils.misc_utils import find, find_all +from naff.models.naff.application_commands import SlashCommand +from naff.models.naff.cog import Cog +from rich.table import Table + +import jarvis.cogs + +if TYPE_CHECKING: + from naff.client.client import Client + +_logger = logging.getLogger(__name__) + + +@dataclass +class UpdateResult: + """JARVIS update result.""" + + old_hash: str + new_hash: str + table: Table + added: List[str] + removed: List[str] + changed: List[str] + lines: Dict[str, int] + + +def get_all_commands(module: ModuleType = jarvis.cogs) -> Dict[str, Callable]: + """Get all SlashCommands from a specified module.""" + commands = {} + + def validate_ires(entry: Any) -> bool: + return isclass(entry) and issubclass(entry, Cog) and entry is not Cog + + def validate_cog(cog: FunctionType) -> bool: + return isinstance(cog, SlashCommand) + + for item in iter_modules(module.__path__): + new_module = import_module(f"{module.__name__}.{item.name}") + if item.ispkg: + if cmds := get_all_commands(new_module): + commands.update(cmds) + else: + inspect_result = getmembers(new_module) + cogs = [] + for _, val in inspect_result: + if validate_ires(val): + cogs.append(val) + for cog in cogs: + values = cog.__dict__.values() + commands[cog.__module__] = find_all(lambda x: isinstance(x, SlashCommand), values) + return {k: v for k, v in commands.items() if v} + + +def get_git_changes(repo: git.Repo) -> dict: + """Get all Git changes""" + logger = _logger + logger.debug("Getting all git changes") + current_hash = repo.head.ref.object.hexsha + tracking = repo.head.ref.tracking_branch() + + file_changes = {} + for commit in tracking.commit.iter_items(repo, f"{repo.head.ref.path}..{tracking.path}"): + if commit.hexsha == current_hash: + break + files = commit.stats.files + file_changes.update( + {key: {"insertions": 0, "deletions": 0, "lines": 0} for key in files.keys()} + ) + for file, stats in files.items(): + if file not in file_changes: + file_changes[file] = {"insertions": 0, "deletions": 0, "lines": 0} + for key, val in stats.items(): + file_changes[file][key] += val + logger.debug("Found %i changed files", len(file_changes)) + + table = Table(title="File Changes") + + table.add_column("File", justify="left", style="white", no_wrap=True) + table.add_column("Insertions", justify="center", style="green") + table.add_column("Deletions", justify="center", style="red") + table.add_column("Lines", justify="center", style="magenta") + + i_total = 0 + d_total = 0 + l_total = 0 + for file, stats in file_changes.items(): + i_total += stats["insertions"] + d_total += stats["deletions"] + l_total += stats["lines"] + table.add_row( + file, + str(stats["insertions"]), + str(stats["deletions"]), + str(stats["lines"]), + ) + logger.debug("%i insertions, %i deletions, %i total", i_total, d_total, l_total) + + table.add_row("Total", str(i_total), str(d_total), str(l_total)) + return { + "table": table, + "lines": {"inserted_lines": i_total, "deleted_lines": d_total, "total_lines": l_total}, + } + + +async def update(bot: "Client") -> Optional[UpdateResult]: + """ + Update JARVIS and return an UpdateResult. + + Args: + bot: Bot instance + + Returns: + UpdateResult object + """ + logger = _logger + repo = git.Repo(".") + current_hash = repo.head.object.hexsha + origin = repo.remotes.origin + origin.fetch() + remote_hash = origin.refs[repo.active_branch.name].object.hexsha + + if current_hash != remote_hash: + logger.info("Updating from %s to %s", current_hash, remote_hash) + current_commands = get_all_commands() + changes = get_git_changes(repo) + + origin.pull() + await asyncio.sleep(3) + + new_commands = get_all_commands() + + logger.info("Checking if any modules need reloaded...") + + reloaded = [] + loaded = [] + unloaded = [] + + logger.debug("Checking for removed cogs") + for module in current_commands.keys(): + if module not in new_commands: + logger.debug("Module %s removed after update", module) + bot.drop_cog(module) + unloaded.append(module) + + logger.debug("Checking for new/modified commands") + for module, commands in new_commands.items(): + logger.debug("Processing %s", module) + if module not in current_commands: + bot.load_cog(module) + loaded.append(module) + elif len(current_commands[module]) != len(commands): + try: + bot.reload_cog(module) + except ExtensionNotFound: + bot.load_cog(module) + reloaded.append(module) + else: + for command in commands: + old_command = find( + lambda x: x.resolved_name == command.resolved_name, current_commands[module] + ) + + # Extract useful info + old_args = old_command.options + if old_args: + old_arg_names = [x.name for x in old_args] + new_args = command.options + if new_args: + new_arg_names = [x.name for x in new_args] + + # No changes + if not old_args and not new_args: + continue + + # Check if number arguments have changed + if len(old_args) != len(new_args): + try: + bot.reload_cog(module) + except ExtensionNotFound: + bot.load_cog(module) + reloaded.append(module) + elif any(x not in old_arg_names for x in new_arg_names) or any( + x not in new_arg_names for x in old_arg_names + ): + try: + bot.reload_cog(module) + except ExtensionNotFound: + bot.load_cog(module) + reloaded.append(module) + elif any(new_args[idx].type != x.type for idx, x in enumerate(old_args)): + try: + bot.reload_cog(module) + except ExtensionNotFound: + bot.load_cog(module) + reloaded.append(module) + + return UpdateResult( + old_hash=current_hash, + new_hash=remote_hash, + added=loaded, + removed=unloaded, + changed=reloaded, + **changes, + ) + return None diff --git a/jarvis_small.png b/jarvis_small.png index b2610b3..26d4bd9 100644 Binary files a/jarvis_small.png and b/jarvis_small.png differ diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2697614 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1859 @@ +[[package]] +name = "aiofile" +version = "3.7.4" +description = "Asynchronous file operations." +category = "main" +optional = false +python-versions = ">3.4.*, <4" + +[package.dependencies] +caio = ">=0.9.0,<0.10.0" + +[package.extras] +develop = ["aiomisc", "asynctest", "pytest", "pytest-cov"] + +[[package]] +name = "aiofiles" +version = "0.6.0" +description = "File support for asyncio." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "aiohttp" +version = "3.8.1" +description = "Async http client/server framework (asyncio)" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aioredis" +version = "2.0.1" +description = "asyncio (PEP 3156) Redis support" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = "*" +typing-extensions = "*" + +[package.extras] +hiredis = ["hiredis (>=1.0)"] + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "aiosqlite" +version = "0.17.0" +description = "asyncio bridge to the standard sqlite3 module" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing_extensions = ">=3.7.2" + +[[package]] +name = "ansicon" +version = "1.89.0" +description = "Python wrapper for loading Jason Hood's ANSICON" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "async-generator" +version = "1.10" +description = "Async generators and context managers for Python 3.5+" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "asyncio-extras" +version = "1.3.2" +description = "Asynchronous generators, context managers and more for asyncio" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +async-generator = ">=1.3" + +[package.extras] +doc = ["sphinx-autodoc-typehints"] +test = ["pytest", "pytest-asyncio", "pytest-cov"] + +[[package]] +name = "asyncpraw" +version = "7.5.0" +description = "Async PRAW, an abbreviation for `Asynchronous Python Reddit API Wrapper`, is a python package that allows for simple access to reddit's API." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiofiles = "<=0.6.0" +aiosqlite = "<=0.17.0" +asyncio-extras = "<=1.3.2" +asyncprawcore = ">=2.1,<3" +update-checker = ">=0.18" + +[package.extras] +ci = ["coveralls"] +dev = ["packaging", "pre-commit", "sphinx", "sphinx-rtd-theme", "sphinxcontrib-trio", "asynctest (>=0.13.0)", "mock (>=0.8)", "pytest (>=2.7.3)", "pytest-asyncio", "pytest-vcr", "testfixtures (>4.13.2,<7)", "vcrpy (==4.1.1)"] +lint = ["pre-commit", "sphinx", "sphinx-rtd-theme", "sphinxcontrib-trio"] +readthedocs = ["sphinx", "sphinx-rtd-theme", "sphinxcontrib-trio"] +test = ["asynctest (>=0.13.0)", "mock (>=0.8)", "pytest (>=2.7.3)", "pytest-asyncio", "pytest-vcr", "testfixtures (>4.13.2,<7)", "vcrpy (==4.1.1)"] + +[[package]] +name = "asyncprawcore" +version = "2.3.0" +description = "Low-level asynchronous communication layer for Async PRAW 7+." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +aiohttp = "*" +yarl = "*" + +[package.extras] +ci = ["coveralls"] +dev = ["black", "flake8", "flynt", "pre-commit", "pydocstyle", "asynctest (>=0.13.0)", "mock (>=0.8)", "pytest", "pytest-vcr", "testfixtures (>4.13.2,<7)", "vcrpy (==4.0.2)"] +lint = ["black", "flake8", "flynt", "pre-commit", "pydocstyle"] +test = ["asynctest (>=0.13.0)", "mock (>=0.8)", "pytest", "pytest-vcr", "testfixtures (>4.13.2,<7)", "vcrpy (==4.0.2)"] + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "blessed" +version = "1.19.1" +description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." +category = "main" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +six = ">=1.9.0" +wcwidth = ">=0.1.4" + +[[package]] +name = "caio" +version = "0.9.5" +description = "Asynchronous file IO for Linux Posix and Windows." +category = "main" +optional = false +python-versions = ">=3.5.*, <4" + +[package.extras] +develop = ["aiomisc", "pytest", "pytest-cov"] + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "charset-normalizer" +version = "2.0.12" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "codefind" +version = "0.1.3" +description = "Find code objects and their referents" +category = "main" +optional = false +python-versions = ">=3.8,<4.0" + +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "dateparser" +version = "1.1.1" +description = "Date parsing library designed to parse dates from HTML pages" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +python-dateutil = "*" +pytz = "*" +regex = "<2019.02.19 || >2019.02.19,<2021.8.27 || >2021.8.27,<2022.3.15" +tzlocal = "*" + +[package.extras] +calendars = ["convertdate", "hijri-converter", "convertdate"] +fasttext = ["fasttext"] +langdetect = ["langdetect"] + +[[package]] +name = "distro" +version = "1.7.0" +description = "Distro - an OS platform information API" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "frozenlist" +version = "1.3.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "funcsigs" +version = "1.0.2" +description = "Python function signatures from PEP362 for Python 2.6, 2.7 and 3.2+" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "gitdb" +version = "4.0.9" +description = "Git Object Database" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +smmap = ">=3.0.1,<6" + +[[package]] +name = "gitpython" +version = "3.1.27" +description = "GitPython is a python library used to interact with Git repositories" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +gitdb = ">=4.0.1,<5" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "jarvis-core" +version = "0.9.2" +description = "JARVIS core" +category = "main" +optional = false +python-versions = "^3.10" +develop = false + +[package.dependencies] +aiohttp = "^3.8.1" +motor = "^2.5.1" +nanoid = "^2.0.0" +orjson = "^3.6.6" +pytz = "^2022.1" +PyYAML = "^6.0" +rich = "^12.3.0" +umongo = "^3.1.0" + +[package.source] +type = "git" +url = "https://git.zevaryx.com/stark-industries/jarvis/jarvis-core.git" +reference = "main" +resolved_reference = "83117c1b3c5540acadeac3005f4d8e69cbf743fc" + +[[package]] +name = "jinxed" +version = "1.1.0" +description = "Jinxed Terminal Library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ansicon = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "jurigged" +version = "0.5.0" +description = "Live update of Python functions" +category = "main" +optional = false +python-versions = ">=3.8,<4.0" + +[package.dependencies] +blessed = ">=1.17.12,<2.0.0" +codefind = ">=0.1.3,<0.2.0" +ovld = ">=0.3.1,<0.4.0" +watchdog = ">=1.0.2,<2.0.0" + +[package.extras] +develoop = ["giving (>=0.3.6,<0.4.0)", "rich (>=10.13.0,<11.0.0)", "hrepr (>=0.4.0,<0.5.0)"] + +[[package]] +name = "marshmallow" +version = "3.15.0" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +packaging = "*" + +[package.extras] +dev = ["pytest", "pytz", "simplejson", "mypy (==0.940)", "flake8 (==4.0.1)", "flake8-bugbear (==22.1.11)", "pre-commit (>=2.4,<3.0)", "tox"] +docs = ["sphinx (==4.4.0)", "sphinx-issues (==3.0.1)", "alabaster (==0.7.12)", "sphinx-version-warning (==1.1.2)", "autodocsumm (==0.2.7)"] +lint = ["mypy (==0.940)", "flake8 (==4.0.1)", "flake8-bugbear (==22.1.11)", "pre-commit (>=2.4,<3.0)"] +tests = ["pytest", "pytz", "simplejson"] + +[[package]] +name = "mongoengine" +version = "0.23.1" +description = "MongoEngine is a Python Object-Document Mapper for working with MongoDB." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pymongo = ">=3.4,<4.0" + +[[package]] +name = "motor" +version = "2.5.1" +description = "Non-blocking MongoDB driver for Tornado or asyncio" +category = "main" +optional = false +python-versions = ">=3.5.2" + +[package.dependencies] +pymongo = ">=3.12,<4" + +[package.extras] +encryption = ["pymongo[encryption] (>=3.12,<4)"] + +[[package]] +name = "multidict" +version = "6.0.2" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "numpy" +version = "1.22.3" +description = "NumPy is the fundamental package for array computing with Python." +category = "main" +optional = false +python-versions = ">=3.8" + +[[package]] +name = "oauthlib" +version = "3.2.0" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "opencv-python" +version = "4.5.5.64" +description = "Wrapper package for OpenCV python bindings." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +numpy = [ + {version = ">=1.21.2", markers = "python_version >= \"3.10\" or python_version >= \"3.6\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, + {version = ">=1.19.3", markers = "python_version >= \"3.6\" and platform_system == \"Linux\" and platform_machine == \"aarch64\" or python_version >= \"3.9\""}, + {version = ">=1.14.5", markers = "python_version >= \"3.7\""}, + {version = ">=1.17.3", markers = "python_version >= \"3.8\""}, +] + +[[package]] +name = "orjson" +version = "3.6.8" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "ovld" +version = "0.3.2" +description = "Overloading Python functions" +category = "main" +optional = false +python-versions = ">=3.6,<4.0" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pastypy" +version = "1.0.2" +description = "Pasty API wrapper" +category = "main" +optional = false +python-versions = ">=3.10" + +[package.dependencies] +aiohttp = {version = "3.8.1", markers = "python_version >= \"3.6\""} +aiosignal = {version = "1.2.0", markers = "python_version >= \"3.6\""} +async-timeout = {version = "4.0.2", markers = "python_version >= \"3.6\""} +attrs = {version = "21.4.0", markers = "python_version >= \"3.6\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.5.0\" and python_version >= \"3.6\""} +certifi = {version = "2021.10.8", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\""} +charset-normalizer = {version = "2.0.12", markers = "python_full_version >= \"3.6.0\" and python_version >= \"3.6\""} +frozenlist = {version = "1.3.0", markers = "python_version >= \"3.7\""} +idna = {version = "3.3", markers = "python_version >= \"3.6\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\" and python_version >= \"3.6\""} +multidict = {version = "6.0.2", markers = "python_version >= \"3.7\""} +pycryptodome = {version = "3.14.1", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.5.0\""} +requests = {version = "2.27.1", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\""} +urllib3 = {version = "1.26.8", markers = "python_version >= \"2.7\" and python_full_version < \"3.0.0\" or python_full_version >= \"3.6.0\" and python_version < \"4\""} +yarl = {version = "1.7.2", markers = "python_version >= \"3.6\""} + +[[package]] +name = "pillow" +version = "9.1.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "protobuf" +version = "3.20.1" +description = "Protocol Buffers" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "psutil" +version = "5.9.0" +description = "Cross-platform lib for process and system monitoring in Python." +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] + +[[package]] +name = "pycryptodome" +version = "3.14.1" +description = "Cryptographic library for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pygments" +version = "2.12.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pymongo" +version = "3.12.3" +description = "Python driver for MongoDB " +category = "main" +optional = false +python-versions = "*" + +[package.extras] +aws = ["pymongo-auth-aws (<2.0.0)"] +encryption = ["pymongocrypt (>=1.1.0,<2.0.0)"] +gssapi = ["pykerberos"] +ocsp = ["pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)", "certifi"] +snappy = ["python-snappy"] +srv = ["dnspython (>=1.16.0,<1.17.0)"] +tls = ["ipaddress"] +zstd = ["zstandard"] + +[[package]] +name = "pyparsing" +version = "3.0.8" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "main" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-gitlab" +version = "3.4.0" +description = "Interact with GitLab API" +category = "main" +optional = false +python-versions = ">=3.7.0" + +[package.dependencies] +requests = ">=2.25.0" +requests-toolbelt = ">=0.9.1" + +[package.extras] +autocompletion = ["argcomplete (>=1.10.0,<3)"] +yaml = ["PyYaml (>=5.2)"] + +[[package]] +name = "pytz" +version = "2022.1" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pytz-deprecation-shim" +version = "0.1.0.post0" +description = "Shims to make deprecation of pytz easier" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +tzdata = {version = "*", markers = "python_version >= \"3.6\""} + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "regex" +version = "2022.3.2" +description = "Alternative regular expression module, to replace re." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +description = "OAuthlib authentication support for Requests." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "requests-toolbelt" +version = "0.9.1" +description = "A utility belt for advanced users of python-requests" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rich" +version = "12.3.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.6.3,<4.0.0" + +[package.dependencies] +commonmark = ">=0.9.0,<0.10.0" +pygments = ">=2.6.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] + +[[package]] +name = "rook" +version = "0.1.171" +description = "Rook is a Python package for on the fly debugging and data extraction for application in production" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +certifi = "*" +distro = "*" +funcsigs = "*" +protobuf = {version = ">=3.7.1,<=4.0.0", markers = "python_version > \"3.0\""} +psutil = ">=5.8.0" +six = ">=1.13" +websocket-client = ">=0.56,<0.58 || >0.58,<0.59 || >0.59,<1.0 || >1.0,<1.1 || >1.1" + +[package.extras] +ssl_backport = ["backports.ssl", "backports.ssl-match-hostname", "pyopenssl"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "smmap" +version = "5.0.0" +description = "A pure Python implementation of a sliding window memory map manager" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "tweepy" +version = "4.8.0" +description = "Twitter library for Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +oauthlib = ">=3.2.0,<4" +requests = ">=2.27.0,<3" +requests-oauthlib = ">=1.2.0,<2" + +[package.extras] +async = ["aiohttp (>=3.7.3,<4)"] +dev = ["coveralls (>=2.1.0)", "tox (>=3.14.0)"] +socks = ["requests[socks] (>=2.27.0,<3)"] +test = ["vcrpy (>=1.10.3)"] + +[[package]] +name = "typing-extensions" +version = "4.2.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tzdata" +version = "2022.1" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" + +[[package]] +name = "tzlocal" +version = "4.2" +description = "tzinfo object for the local timezone" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz-deprecation-shim = "*" +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"] +test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"] + +[[package]] +name = "ulid-py" +version = "1.1.0" +description = "Universally Unique Lexicographically Sortable Identifier" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "umongo" +version = "3.1.0" +description = "sync/async MongoDB ODM, yes." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +marshmallow = ">=3.10.0" +pymongo = ">=3.7.0" + +[package.extras] +mongomock = ["mongomock"] +motor = ["motor (>=2.0,<3.0)"] +txmongo = ["txmongo (>=19.2.0)"] + +[[package]] +name = "update-checker" +version = "0.18.0" +description = "A python module that will check for package updates." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.3.0" + +[package.extras] +dev = ["black", "flake8", "pytest (>=2.7.3)"] +lint = ["black", "flake8"] +test = ["pytest (>=2.7.3)"] + +[[package]] +name = "urllib3" +version = "1.26.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "watchdog" +version = "1.0.2" +description = "Filesystem events monitoring" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "websocket-client" +version = "1.3.2" +description = "WebSocket client for Python with low level API options" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] +optional = ["python-socks", "wsaccel"] +test = ["websockets"] + +[[package]] +name = "yarl" +version = "1.7.2" +description = "Yet another URL library" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "8bb2b59de1ccb8f5e5588ae3ac600e7fb6d7f638224c9cc24228f79e666aec63" + +[metadata.files] +aiofile = [ + {file = "aiofile-3.7.4-py3-none-any.whl", hash = "sha256:0e2a524e4714efda47ce8964b13d4da94cf553411f9f6da813df615a4cd73d95"}, + {file = "aiofile-3.7.4.tar.gz", hash = "sha256:0aefa1d91d000d3a20a515d153db2ebf713076c7c94edf2fca85d3d83316abc5"}, +] +aiofiles = [ + {file = "aiofiles-0.6.0-py3-none-any.whl", hash = "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27"}, + {file = "aiofiles-0.6.0.tar.gz", hash = "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092"}, +] +aiohttp = [ + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"}, + {file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"}, + {file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"}, + {file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"}, + {file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"}, + {file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"}, + {file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"}, + {file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"}, + {file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"}, + {file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"}, + {file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"}, + {file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"}, + {file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"}, + {file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"}, + {file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"}, + {file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"}, + {file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"}, + {file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"}, + {file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"}, + {file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"}, + {file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"}, + {file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"}, + {file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"}, + {file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"}, + {file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"}, +] +aioredis = [ + {file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"}, + {file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"}, +] +aiosignal = [ + {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, + {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, +] +aiosqlite = [ + {file = "aiosqlite-0.17.0-py3-none-any.whl", hash = "sha256:6c49dc6d3405929b1d08eeccc72306d3677503cc5e5e43771efc1e00232e8231"}, + {file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"}, +] +ansicon = [ + {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, + {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, +] +async-generator = [ + {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, + {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, +] +async-timeout = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, +] +asyncio-extras = [ + {file = "asyncio_extras-1.3.2-py3-none-any.whl", hash = "sha256:839568ba07c3470c9aa2c441aa2417c108f7d3755862bc2bd39d69b524303993"}, + {file = "asyncio_extras-1.3.2.tar.gz", hash = "sha256:084b62bebc19c6ba106d438a274bbb5566941c469128cd4af1a85f00a2c81f8d"}, +] +asyncpraw = [ + {file = "asyncpraw-7.5.0-py3-none-any.whl", hash = "sha256:b40f3db3464077a7a7e30a89181ba15ba4c5bc550dc2642e815b235f42ad8eb2"}, + {file = "asyncpraw-7.5.0.tar.gz", hash = "sha256:61aabf05052472d8b29e0f0500a6ec8b483129374d36dad286d94e4b6864572d"}, +] +asyncprawcore = [ + {file = "asyncprawcore-2.3.0-py3-none-any.whl", hash = "sha256:46c52e6cfe91801a8c9490a0ee29a85cbc6713ccc535d5c704d448aee9729e5b"}, + {file = "asyncprawcore-2.3.0.tar.gz", hash = "sha256:2a4a2d1ca7f78c8fa7d4903e6bd18cfe96742ad1f167b59473f64be0e7060d5d"}, +] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +blessed = [ + {file = "blessed-1.19.1-py2.py3-none-any.whl", hash = "sha256:63b8554ae2e0e7f43749b6715c734cc8f3883010a809bf16790102563e6cf25b"}, + {file = "blessed-1.19.1.tar.gz", hash = "sha256:9a0d099695bf621d4680dd6c73f6ad547f6a3442fbdbe80c4b1daa1edbc492fc"}, +] +caio = [ + {file = "caio-0.9.5-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:cd1c20aab04c18f0534b3f0b59103a94dede3c7d7b43c9cc525df3980b4c7c54"}, + {file = "caio-0.9.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dd316270757d77f384c97e336588267e7942c1f1492a3a2e07b9a80dca027538"}, + {file = "caio-0.9.5-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:013aa374158c5074b3c65a0da6b9c6b20a987d85fb317dd077b045e84e2478e1"}, + {file = "caio-0.9.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d767faf537a9ea774e8408ba15a0f1dc734f06857c2d28bdf4258a63b5885f42"}, + {file = "caio-0.9.5-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:97d9a10522a8a25798229fc1113cfaba3832b1cd0c1a3648b009b9740ef5e054"}, + {file = "caio-0.9.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:4fe9eff5cf7a2d6f3f418aeeccd11ce9a38329e07527b6f52da085edb44bc2fd"}, + {file = "caio-0.9.5-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b8b6be369139edd678817dc0a313392d710f66fb521c275dce0a9067089b346b"}, + {file = "caio-0.9.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3ffc6259239e03962f9e14829e02795ca9d196eedf32fe61688ba6ed33da46c8"}, + {file = "caio-0.9.5-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7a19dfdec6736affb645da233a6007c2590678490d2a1e0f1fb82a696c0a1ddf"}, + {file = "caio-0.9.5-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:15f70d27e1009d279e4f9ff86290aad00b0511ce82a1879c40745244f0a9ec92"}, + {file = "caio-0.9.5-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:0427a58c1814a091bfbb84318d344fdb9a68f3d49adc74e5fdc7bc9478e1e4fe"}, + {file = "caio-0.9.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7f48fa58e5f699b428f1fd85e394ecec05be4048fcaf1fdf1981b748cd1e03a6"}, + {file = "caio-0.9.5-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:01061288391020f28e1ab8b0437420f7fe1e0ecc29b4107f7a8dcf7789f33b22"}, + {file = "caio-0.9.5-py3-none-any.whl", hash = "sha256:3c74d84dff2bec5f93685cf2f32eb22e4cc5663434a9be5f4a759247229b69b3"}, + {file = "caio-0.9.5.tar.gz", hash = "sha256:167d9342a807bae441b2e88f9ecb62da2f236b319939a8679f68f510a0194c40"}, +] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, +] +codefind = [ + {file = "codefind-0.1.3-py3-none-any.whl", hash = "sha256:3ffe85b74595b5c9f82391a11171ce7d68f1f555485720ab922f3b86f9bf30ec"}, + {file = "codefind-0.1.3.tar.gz", hash = "sha256:5667050361bf601a253031b2437d16b7d82cb0fa0e756d93e548c7b35ce6f910"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +dateparser = [ + {file = "dateparser-1.1.1-py2.py3-none-any.whl", hash = "sha256:9600874312ff28a41f96ec7ccdc73be1d1c44435719da47fea3339d55ff5a628"}, + {file = "dateparser-1.1.1.tar.gz", hash = "sha256:038196b1f12c7397e38aad3d61588833257f6f552baa63a1499e6987fa8d42d9"}, +] +distro = [ + {file = "distro-1.7.0-py3-none-any.whl", hash = "sha256:d596311d707e692c2160c37807f83e3820c5d539d5a83e87cfb6babd8ba3a06b"}, + {file = "distro-1.7.0.tar.gz", hash = "sha256:151aeccf60c216402932b52e40ee477a939f8d58898927378a02abbe852c1c39"}, +] +frozenlist = [ + {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"}, + {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"}, + {file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"}, + {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"}, + {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"}, + {file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"}, + {file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"}, + {file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"}, + {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"}, + {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"}, + {file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"}, + {file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"}, + {file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"}, + {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"}, + {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"}, + {file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"}, + {file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"}, + {file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"}, + {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"}, + {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"}, + {file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"}, + {file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"}, + {file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"}, +] +funcsigs = [ + {file = "funcsigs-1.0.2-py2.py3-none-any.whl", hash = "sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca"}, + {file = "funcsigs-1.0.2.tar.gz", hash = "sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50"}, +] +gitdb = [ + {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, + {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, +] +gitpython = [ + {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"}, + {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"}, +] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +jarvis-core = [] +jinxed = [ + {file = "jinxed-1.1.0-py2.py3-none-any.whl", hash = "sha256:6a61ccf963c16aa885304f27e6e5693783676897cea0c7f223270c8b8e78baf8"}, + {file = "jinxed-1.1.0.tar.gz", hash = "sha256:d8f1731f134e9e6b04d95095845ae6c10eb15cb223a5f0cabdea87d4a279c305"}, +] +jurigged = [ + {file = "jurigged-0.5.0-py3-none-any.whl", hash = "sha256:28d86ca6d97669bc183773f7537e59f50fdd36e7637092fc2451b91bcc935d62"}, + {file = "jurigged-0.5.0.tar.gz", hash = "sha256:f23c3536b1654d2618d6e6b34f0752acf377c1b35283889d3a28663a7b1f72cb"}, +] +marshmallow = [ + {file = "marshmallow-3.15.0-py3-none-any.whl", hash = "sha256:ff79885ed43b579782f48c251d262e062bce49c65c52412458769a4fb57ac30f"}, + {file = "marshmallow-3.15.0.tar.gz", hash = "sha256:2aaaab4f01ef4f5a011a21319af9fce17ab13bf28a026d1252adab0e035648d5"}, +] +mongoengine = [ + {file = "mongoengine-0.23.1-py3-none-any.whl", hash = "sha256:3d1c8b9f5d43144bd726a3f01e58d2831c6fb112960a4a60b3a26fa85e026ab3"}, + {file = "mongoengine-0.23.1.tar.gz", hash = "sha256:de275e70cd58891dc46eef43369c522ce450dccb6d6f1979cbc9b93e6bdaf6cb"}, +] +motor = [ + {file = "motor-2.5.1-py3-none-any.whl", hash = "sha256:961fdceacaae2c7236c939166f66415be81be8bbb762da528386738de3a0f509"}, + {file = "motor-2.5.1.tar.gz", hash = "sha256:663473f4498f955d35db7b6f25651cb165514c247136f368b84419cb7635f6b8"}, +] +multidict = [ + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"}, + {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"}, + {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"}, + {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"}, + {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"}, + {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"}, + {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"}, + {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"}, + {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"}, + {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, + {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, +] +nanoid = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] +numpy = [ + {file = "numpy-1.22.3-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75"}, + {file = "numpy-1.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab"}, + {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e"}, + {file = "numpy-1.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4"}, + {file = "numpy-1.22.3-cp310-cp310-win32.whl", hash = "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430"}, + {file = "numpy-1.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4"}, + {file = "numpy-1.22.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce"}, + {file = "numpy-1.22.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe"}, + {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5"}, + {file = "numpy-1.22.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1"}, + {file = "numpy-1.22.3-cp38-cp38-win32.whl", hash = "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62"}, + {file = "numpy-1.22.3-cp38-cp38-win_amd64.whl", hash = "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676"}, + {file = "numpy-1.22.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123"}, + {file = "numpy-1.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802"}, + {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d"}, + {file = "numpy-1.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168"}, + {file = "numpy-1.22.3-cp39-cp39-win32.whl", hash = "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa"}, + {file = "numpy-1.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a"}, + {file = "numpy-1.22.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f"}, + {file = "numpy-1.22.3.zip", hash = "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18"}, +] +oauthlib = [ + {file = "oauthlib-3.2.0-py3-none-any.whl", hash = "sha256:6db33440354787f9b7f3a6dbd4febf5d0f93758354060e802f6c06cb493022fe"}, + {file = "oauthlib-3.2.0.tar.gz", hash = "sha256:23a8208d75b902797ea29fd31fa80a15ed9dc2c6c16fe73f5d346f83f6fa27a2"}, +] +opencv-python = [ + {file = "opencv-python-4.5.5.64.tar.gz", hash = "sha256:f65de0446a330c3b773cd04ba10345d8ce1b15dcac3f49770204e37602d0b3f7"}, + {file = "opencv_python-4.5.5.64-cp36-abi3-macosx_10_15_x86_64.whl", hash = "sha256:a512a0c59b6fec0fac3844b2f47d6ecb1a9d18d235e6c5491ce8dbbe0663eae8"}, + {file = "opencv_python-4.5.5.64-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca6138b6903910e384067d001763d40f97656875487381aed32993b076f44375"}, + {file = "opencv_python-4.5.5.64-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b293ced62f4360d9f11cf72ae7e9df95320ff7bf5b834d87546f844e838c0c35"}, + {file = "opencv_python-4.5.5.64-cp36-abi3-win32.whl", hash = "sha256:6247e584813c00c3b9ed69a795da40d2c153dc923d0182e957e1c2f00a554ac2"}, + {file = "opencv_python-4.5.5.64-cp36-abi3-win_amd64.whl", hash = "sha256:408d5332550287aa797fd06bef47b2dfed163c6787668cc82ef9123a9484b56a"}, + {file = "opencv_python-4.5.5.64-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:7787bb017ae93d5f9bb1b817ac8e13e45dd193743cb648498fcab21d00cf20a3"}, +] +orjson = [ + {file = "orjson-3.6.8-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:3a287a650458de2211db03681b71c3e5cb2212b62f17a39df8ad99fc54855d0f"}, + {file = "orjson-3.6.8-cp310-cp310-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:5204e25c12cea58e524fc82f7c27ed0586f592f777b33075a92ab7b3eb3687c2"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77e8386393add64f959c044e0fb682364fd0e611a6f477aa13f0e6a733bd6a28"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:279f2d2af393fdf8601020744cb206b91b54ad60fb8401e0761819c7bda1f4e4"}, + {file = "orjson-3.6.8-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:c31c9f389be7906f978ed4192eb58a4b74a37ad60556a0b88ddc47c576697770"}, + {file = "orjson-3.6.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0db5c5a0c5b89f092d52f6e5a3701660a9d6ffa9e2968b3ce17c2bc4f5eb0414"}, + {file = "orjson-3.6.8-cp310-none-win_amd64.whl", hash = "sha256:eb22485847b9a0c4bbedc668df860126ac931edbed1d456cf41a59f3cb961ed8"}, + {file = "orjson-3.6.8-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:1a5fe569310bc819279bd4d5f2c349910b104ed3207936246dd5d5e0b085e74a"}, + {file = "orjson-3.6.8-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:ccb356a47ab1067cd3549847e9db1d279a63fe0482d315b3ffd6e7abef35ef77"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab29c069c222248ce302a25855b4e1664f9436e8ae5a131fb0859daf31676d2b"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d2b5e4cba9e774ac011071d9d27760f97f4b8cd46003e971d122e712f971345"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:c311ec504414d22834d5b972a209619925b48263856a11a14d90230f9682d49c"}, + {file = "orjson-3.6.8-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:a3dfec7950b90fb8d143743503ee53fa06b32e6068bdea792fc866284da3d71d"}, + {file = "orjson-3.6.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b890dbbada2cbb26eb29bd43a848426f007f094bb0758df10dfe7a438e1cb4b4"}, + {file = "orjson-3.6.8-cp37-none-win_amd64.whl", hash = "sha256:9143ae2c52771525be9ad11a7a8cc8e7fd75391b107e7e644a9e0050496f6b4f"}, + {file = "orjson-3.6.8-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:33a82199fd42f6436f833e210ae5129c922a5c355629356ca7a8e82964da7285"}, + {file = "orjson-3.6.8-cp38-cp38-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:90159ea8b9a5a2a98fa33dc7b421cfac4d2ae91ba5e1058f5909e7f059f6b467"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:656fbe15d9ef0733e740d9def78f4fdb4153102f4836ee774a05123499005931"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7be3be6153843e0f01351b1313a5ad4723595427680dac2dfff22a37e652ce02"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:dd24f66b6697ee7424f7da575ec6cbffc8ede441114d53470949cda4d97c6e56"}, + {file = "orjson-3.6.8-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:b07c780f7345ecf5901356dc21dee0669defc489c38ce7b9ab0f5e008cc0385c"}, + {file = "orjson-3.6.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ea32015a5d8a4ce00d348a0de5dc7040e0ad58f970a8fcbb5713a1eac129e493"}, + {file = "orjson-3.6.8-cp38-none-win_amd64.whl", hash = "sha256:c5a3e382194c838988ec128a26b08aa92044e5e055491cc4056142af0c1c54d7"}, + {file = "orjson-3.6.8-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:83a8424e857ae1bf53530e88b4eb2f16ca2b489073b924e655f1575cacd7f52a"}, + {file = "orjson-3.6.8-cp39-cp39-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:81e1a6a2d67f15007dadacbf9ba5d3d79237e5e33786c028557fe5a2b72f1c9a"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:137b539881c77866eba86ff6a11df910daf2eb9ab8f1acae62f879e83d7c38af"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cbd358f3b3ad539a27e36900e8e7d172d0e1b72ad9dd7d69544dcbc0f067ee7"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:6ab94701542d40b90903ecfc339333f458884979a01cb9268bc662cc67a5f6d8"}, + {file = "orjson-3.6.8-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:32b6f26593a9eb606b40775826beb0dac152e3d224ea393688fced036045a821"}, + {file = "orjson-3.6.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:afd9e329ebd3418cac3cd747769b1d52daa25fa672bbf414ab59f0e0881b32b9"}, + {file = "orjson-3.6.8-cp39-none-win_amd64.whl", hash = "sha256:0c89b419914d3d1f65a1b0883f377abe42a6e44f6624ba1c63e8846cbfc2fa60"}, + {file = "orjson-3.6.8.tar.gz", hash = "sha256:e19d23741c5de13689bb316abfccea15a19c264e3ec8eb332a5319a583595ace"}, +] +ovld = [ + {file = "ovld-0.3.2-py3-none-any.whl", hash = "sha256:3a5f08f66573198b490fc69dcf93a2ad9b4d90fd1fef885cf7a8dbe565f17837"}, + {file = "ovld-0.3.2.tar.gz", hash = "sha256:f8918636c240a2935175406801944d4314823710b3afbd5a8db3e79cd9391c42"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pastypy = [ + {file = "pastypy-1.0.2-py3-none-any.whl", hash = "sha256:4476e47b5e52600a4d69c58cbbba2c5d42458f552ccfc2854d5fe97a119dcc20"}, + {file = "pastypy-1.0.2.tar.gz", hash = "sha256:81e0c4a65ec40c85d62685627b64d26397304ac91d68ddc80f833974504c13b8"}, +] +pillow = [ + {file = "Pillow-9.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:af79d3fde1fc2e33561166d62e3b63f0cc3e47b5a3a2e5fea40d4917754734ea"}, + {file = "Pillow-9.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:55dd1cf09a1fd7c7b78425967aacae9b0d70125f7d3ab973fadc7b5abc3de652"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66822d01e82506a19407d1afc104c3fcea3b81d5eb11485e593ad6b8492f995a"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5eaf3b42df2bcda61c53a742ee2c6e63f777d0e085bbc6b2ab7ed57deb13db7"}, + {file = "Pillow-9.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ce45deec9df310cbbee11104bae1a2a43308dd9c317f99235b6d3080ddd66e"}, + {file = "Pillow-9.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aea7ce61328e15943d7b9eaca87e81f7c62ff90f669116f857262e9da4057ba3"}, + {file = "Pillow-9.1.0-cp310-cp310-win32.whl", hash = "sha256:7a053bd4d65a3294b153bdd7724dce864a1d548416a5ef61f6d03bf149205160"}, + {file = "Pillow-9.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:97bda660702a856c2c9e12ec26fc6d187631ddfd896ff685814ab21ef0597033"}, + {file = "Pillow-9.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:21dee8466b42912335151d24c1665fcf44dc2ee47e021d233a40c3ca5adae59c"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b6d4050b208c8ff886fd3db6690bf04f9a48749d78b41b7a5bf24c236ab0165"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5cfca31ab4c13552a0f354c87fbd7f162a4fafd25e6b521bba93a57fe6a3700a"}, + {file = "Pillow-9.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed742214068efa95e9844c2d9129e209ed63f61baa4d54dbf4cf8b5e2d30ccf2"}, + {file = "Pillow-9.1.0-cp37-cp37m-win32.whl", hash = "sha256:c9efef876c21788366ea1f50ecb39d5d6f65febe25ad1d4c0b8dff98843ac244"}, + {file = "Pillow-9.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:de344bcf6e2463bb25179d74d6e7989e375f906bcec8cb86edb8b12acbc7dfef"}, + {file = "Pillow-9.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:17869489de2fce6c36690a0c721bd3db176194af5f39249c1ac56d0bb0fcc512"}, + {file = "Pillow-9.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:25023a6209a4d7c42154073144608c9a71d3512b648a2f5d4465182cb93d3477"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8782189c796eff29dbb37dd87afa4ad4d40fc90b2742704f94812851b725964b"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:463acf531f5d0925ca55904fa668bb3461c3ef6bc779e1d6d8a488092bdee378"}, + {file = "Pillow-9.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f42364485bfdab19c1373b5cd62f7c5ab7cc052e19644862ec8f15bb8af289e"}, + {file = "Pillow-9.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3fddcdb619ba04491e8f771636583a7cc5a5051cd193ff1aa1ee8616d2a692c5"}, + {file = "Pillow-9.1.0-cp38-cp38-win32.whl", hash = "sha256:4fe29a070de394e449fd88ebe1624d1e2d7ddeed4c12e0b31624561b58948d9a"}, + {file = "Pillow-9.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:c24f718f9dd73bb2b31a6201e6db5ea4a61fdd1d1c200f43ee585fc6dcd21b34"}, + {file = "Pillow-9.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fb89397013cf302f282f0fc998bb7abf11d49dcff72c8ecb320f76ea6e2c5717"}, + {file = "Pillow-9.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c870193cce4b76713a2b29be5d8327c8ccbe0d4a49bc22968aa1e680930f5581"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69e5ddc609230d4408277af135c5b5c8fe7a54b2bdb8ad7c5100b86b3aab04c6"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35be4a9f65441d9982240e6966c1eaa1c654c4e5e931eaf580130409e31804d4"}, + {file = "Pillow-9.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82283af99c1c3a5ba1da44c67296d5aad19f11c535b551a5ae55328a317ce331"}, + {file = "Pillow-9.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a325ac71914c5c043fa50441b36606e64a10cd262de12f7a179620f579752ff8"}, + {file = "Pillow-9.1.0-cp39-cp39-win32.whl", hash = "sha256:a598d8830f6ef5501002ae85c7dbfcd9c27cc4efc02a1989369303ba85573e58"}, + {file = "Pillow-9.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c51cb9edac8a5abd069fd0758ac0a8bfe52c261ee0e330f363548aca6893595"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a336a4f74baf67e26f3acc4d61c913e378e931817cd1e2ef4dfb79d3e051b481"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb1b89b11256b5b6cad5e7593f9061ac4624f7651f7a8eb4dfa37caa1dfaa4d0"}, + {file = "Pillow-9.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:255c9d69754a4c90b0ee484967fc8818c7ff8311c6dddcc43a4340e10cd1636a"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5a3ecc026ea0e14d0ad7cd990ea7f48bfcb3eb4271034657dc9d06933c6629a7"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5b0ff59785d93b3437c3703e3c64c178aabada51dea2a7f2c5eccf1bcf565a3"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7110ec1701b0bf8df569a7592a196c9d07c764a0a74f65471ea56816f10e2c8"}, + {file = "Pillow-9.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:8d79c6f468215d1a8415aa53d9868a6b40c4682165b8cb62a221b1baa47db458"}, + {file = "Pillow-9.1.0.tar.gz", hash = "sha256:f401ed2bbb155e1ade150ccc63db1a4f6c1909d3d378f7d1235a44e90d75fb97"}, +] +protobuf = [ + {file = "protobuf-3.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3cc797c9d15d7689ed507b165cd05913acb992d78b379f6014e013f9ecb20996"}, + {file = "protobuf-3.20.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:ff8d8fa42675249bb456f5db06c00de6c2f4c27a065955917b28c4f15978b9c3"}, + {file = "protobuf-3.20.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cd68be2559e2a3b84f517fb029ee611546f7812b1fdd0aa2ecc9bc6ec0e4fdde"}, + {file = "protobuf-3.20.1-cp310-cp310-win32.whl", hash = "sha256:9016d01c91e8e625141d24ec1b20fed584703e527d28512aa8c8707f105a683c"}, + {file = "protobuf-3.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:32ca378605b41fd180dfe4e14d3226386d8d1b002ab31c969c366549e66a2bb7"}, + {file = "protobuf-3.20.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9be73ad47579abc26c12024239d3540e6b765182a91dbc88e23658ab71767153"}, + {file = "protobuf-3.20.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:097c5d8a9808302fb0da7e20edf0b8d4703274d140fd25c5edabddcde43e081f"}, + {file = "protobuf-3.20.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e250a42f15bf9d5b09fe1b293bdba2801cd520a9f5ea2d7fb7536d4441811d20"}, + {file = "protobuf-3.20.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cdee09140e1cd184ba9324ec1df410e7147242b94b5f8b0c64fc89e38a8ba531"}, + {file = "protobuf-3.20.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:af0ebadc74e281a517141daad9d0f2c5d93ab78e9d455113719a45a49da9db4e"}, + {file = "protobuf-3.20.1-cp37-cp37m-win32.whl", hash = "sha256:755f3aee41354ae395e104d62119cb223339a8f3276a0cd009ffabfcdd46bb0c"}, + {file = "protobuf-3.20.1-cp37-cp37m-win_amd64.whl", hash = "sha256:62f1b5c4cd6c5402b4e2d63804ba49a327e0c386c99b1675c8a0fefda23b2067"}, + {file = "protobuf-3.20.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:06059eb6953ff01e56a25cd02cca1a9649a75a7e65397b5b9b4e929ed71d10cf"}, + {file = "protobuf-3.20.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:cb29edb9eab15742d791e1025dd7b6a8f6fcb53802ad2f6e3adcb102051063ab"}, + {file = "protobuf-3.20.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:69ccfdf3657ba59569c64295b7d51325f91af586f8d5793b734260dfe2e94e2c"}, + {file = "protobuf-3.20.1-cp38-cp38-win32.whl", hash = "sha256:dd5789b2948ca702c17027c84c2accb552fc30f4622a98ab5c51fcfe8c50d3e7"}, + {file = "protobuf-3.20.1-cp38-cp38-win_amd64.whl", hash = "sha256:77053d28427a29987ca9caf7b72ccafee011257561259faba8dd308fda9a8739"}, + {file = "protobuf-3.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f50601512a3d23625d8a85b1638d914a0970f17920ff39cec63aaef80a93fb7"}, + {file = "protobuf-3.20.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:284f86a6207c897542d7e956eb243a36bb8f9564c1742b253462386e96c6b78f"}, + {file = "protobuf-3.20.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7403941f6d0992d40161aa8bb23e12575637008a5a02283a930addc0508982f9"}, + {file = "protobuf-3.20.1-cp39-cp39-win32.whl", hash = "sha256:db977c4ca738dd9ce508557d4fce0f5aebd105e158c725beec86feb1f6bc20d8"}, + {file = "protobuf-3.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:7e371f10abe57cee5021797126c93479f59fccc9693dafd6bd5633ab67808a91"}, + {file = "protobuf-3.20.1-py2.py3-none-any.whl", hash = "sha256:adfc6cf69c7f8c50fd24c793964eef18f0ac321315439d94945820612849c388"}, + {file = "protobuf-3.20.1.tar.gz", hash = "sha256:adc31566d027f45efe3f44eeb5b1f329da43891634d61c75a5944e9be6dd42c9"}, +] +psutil = [ + {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:55ce319452e3d139e25d6c3f85a1acf12d1607ddedea5e35fb47a552c051161b"}, + {file = "psutil-5.9.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:7336292a13a80eb93c21f36bde4328aa748a04b68c13d01dfddd67fc13fd0618"}, + {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cb8d10461c1ceee0c25a64f2dd54872b70b89c26419e147a05a10b753ad36ec2"}, + {file = "psutil-5.9.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7641300de73e4909e5d148e90cc3142fb890079e1525a840cf0dfd39195239fd"}, + {file = "psutil-5.9.0-cp27-none-win32.whl", hash = "sha256:ea42d747c5f71b5ccaa6897b216a7dadb9f52c72a0fe2b872ef7d3e1eacf3ba3"}, + {file = "psutil-5.9.0-cp27-none-win_amd64.whl", hash = "sha256:ef216cc9feb60634bda2f341a9559ac594e2eeaadd0ba187a4c2eb5b5d40b91c"}, + {file = "psutil-5.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90a58b9fcae2dbfe4ba852b57bd4a1dded6b990a33d6428c7614b7d48eccb492"}, + {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d41f8b3e9ebb6b6110057e40019a432e96aae2008951121ba4e56040b84f3"}, + {file = "psutil-5.9.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:742c34fff804f34f62659279ed5c5b723bb0195e9d7bd9907591de9f8f6558e2"}, + {file = "psutil-5.9.0-cp310-cp310-win32.whl", hash = "sha256:8293942e4ce0c5689821f65ce6522ce4786d02af57f13c0195b40e1edb1db61d"}, + {file = "psutil-5.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:9b51917c1af3fa35a3f2dabd7ba96a2a4f19df3dec911da73875e1edaf22a40b"}, + {file = "psutil-5.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e9805fed4f2a81de98ae5fe38b75a74c6e6ad2df8a5c479594c7629a1fe35f56"}, + {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c51f1af02334e4b516ec221ee26b8fdf105032418ca5a5ab9737e8c87dafe203"}, + {file = "psutil-5.9.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32acf55cb9a8cbfb29167cd005951df81b567099295291bcfd1027365b36591d"}, + {file = "psutil-5.9.0-cp36-cp36m-win32.whl", hash = "sha256:e5c783d0b1ad6ca8a5d3e7b680468c9c926b804be83a3a8e95141b05c39c9f64"}, + {file = "psutil-5.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:d62a2796e08dd024b8179bd441cb714e0f81226c352c802fca0fd3f89eeacd94"}, + {file = "psutil-5.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d00a664e31921009a84367266b35ba0aac04a2a6cad09c550a89041034d19a0"}, + {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7779be4025c540d1d65a2de3f30caeacc49ae7a2152108adeaf42c7534a115ce"}, + {file = "psutil-5.9.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:072664401ae6e7c1bfb878c65d7282d4b4391f1bc9a56d5e03b5a490403271b5"}, + {file = "psutil-5.9.0-cp37-cp37m-win32.whl", hash = "sha256:df2c8bd48fb83a8408c8390b143c6a6fa10cb1a674ca664954de193fdcab36a9"}, + {file = "psutil-5.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1d7b433519b9a38192dfda962dd8f44446668c009833e1429a52424624f408b4"}, + {file = "psutil-5.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3400cae15bdb449d518545cbd5b649117de54e3596ded84aacabfbb3297ead2"}, + {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2237f35c4bbae932ee98902a08050a27821f8f6dfa880a47195e5993af4702d"}, + {file = "psutil-5.9.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1070a9b287846a21a5d572d6dddd369517510b68710fca56b0e9e02fd24bed9a"}, + {file = "psutil-5.9.0-cp38-cp38-win32.whl", hash = "sha256:76cebf84aac1d6da5b63df11fe0d377b46b7b500d892284068bacccf12f20666"}, + {file = "psutil-5.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3151a58f0fbd8942ba94f7c31c7e6b310d2989f4da74fcbf28b934374e9bf841"}, + {file = "psutil-5.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:539e429da49c5d27d5a58e3563886057f8fc3868a5547b4f1876d9c0f007bccf"}, + {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58c7d923dc209225600aec73aa2c4ae8ea33b1ab31bc11ef8a5933b027476f07"}, + {file = "psutil-5.9.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3611e87eea393f779a35b192b46a164b1d01167c9d323dda9b1e527ea69d697d"}, + {file = "psutil-5.9.0-cp39-cp39-win32.whl", hash = "sha256:4e2fb92e3aeae3ec3b7b66c528981fd327fb93fd906a77215200404444ec1845"}, + {file = "psutil-5.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d190ee2eaef7831163f254dc58f6d2e2a22e27382b936aab51c835fc080c3d3"}, + {file = "psutil-5.9.0.tar.gz", hash = "sha256:869842dbd66bb80c3217158e629d6fceaecc3a3166d3d1faee515b05dd26ca25"}, +] +pycryptodome = [ + {file = "pycryptodome-3.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win32.whl", hash = "sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2"}, + {file = "pycryptodome-3.14.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win32.whl", hash = "sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win_amd64.whl", hash = "sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-win32.whl", hash = "sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0"}, + {file = "pycryptodome-3.14.1.tar.gz", hash = "sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b"}, +] +pygments = [ + {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, + {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, +] +pymongo = [ + {file = "pymongo-3.12.3-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:c164eda0be9048f83c24b9b2656900041e069ddf72de81c17d874d0c32f6079f"}, + {file = "pymongo-3.12.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:a055d29f1302892a9389a382bed10a3f77708bcf3e49bfb76f7712fa5f391cc6"}, + {file = "pymongo-3.12.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:8c7ad5cab282f53b9d78d51504330d1c88c83fbe187e472c07e6908a0293142e"}, + {file = "pymongo-3.12.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a766157b195a897c64945d4ff87b050bb0e763bb78f3964e996378621c703b00"}, + {file = "pymongo-3.12.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c8d6bf6fcd42cde2f02efb8126812a010c297eacefcd090a609639d2aeda6185"}, + {file = "pymongo-3.12.3-cp27-cp27m-win32.whl", hash = "sha256:5fdffb0cfeb4dc8646a5381d32ec981ae8472f29c695bf09e8f7a8edb2db12ca"}, + {file = "pymongo-3.12.3-cp27-cp27m-win_amd64.whl", hash = "sha256:648fcfd8e019b122b7be0e26830a3a2224d57c3e934f19c1e53a77b8380e6675"}, + {file = "pymongo-3.12.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:3f0ac6e0203bd88863649e6ed9c7cfe53afab304bc8225f2597c4c0a74e4d1f0"}, + {file = "pymongo-3.12.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:71c0db2c313ea8a80825fb61b7826b8015874aec29ee6364ade5cb774fe4511b"}, + {file = "pymongo-3.12.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b779e87300635b8075e8d5cfd4fdf7f46078cd7610c381d956bca5556bb8f97"}, + {file = "pymongo-3.12.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:351a2efe1c9566c348ad0076f4bf541f4905a0ebe2d271f112f60852575f3c16"}, + {file = "pymongo-3.12.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0a02313e71b7c370c43056f6b16c45effbb2d29a44d24403a3d5ba6ed322fa3f"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux1_i686.whl", hash = "sha256:d3082e5c4d7b388792124f5e805b469109e58f1ab1eb1fbd8b998e8ab766ffb7"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:514e78d20d8382d5b97f32b20c83d1d0452c302c9a135f0a9022236eb9940fda"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux2014_i686.whl", hash = "sha256:b1b5be40ebf52c3c67ee547e2c4435ed5bc6352f38d23e394520b686641a6be4"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux2014_ppc64le.whl", hash = "sha256:58db209da08a502ce6948841d522dcec80921d714024354153d00b054571993c"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux2014_s390x.whl", hash = "sha256:5296e5e69243ffd76bd919854c4da6630ae52e46175c804bc4c0e050d937b705"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:51d1d061df3995c2332ae78f036492cc188cb3da8ef122caeab3631a67bb477e"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:463b974b7f49d65a16ca1435bc1c25a681bb7d630509dd23b2e819ed36da0b7f"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e099b79ccf7c40f18b149a64d3d10639980035f9ceb223169dd806ff1bb0d9cc"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27e5ea64332385385b75414888ce9d1a9806be8616d7cef4ef409f4f256c6d06"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed7d11330e443aeecab23866055e08a5a536c95d2c25333aeb441af2dbac38d2"}, + {file = "pymongo-3.12.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93111fd4e08fa889c126aa8baf5c009a941880a539c87672e04583286517450a"}, + {file = "pymongo-3.12.3-cp310-cp310-win32.whl", hash = "sha256:2301051701b27aff2cbdf83fae22b7ca883c9563dfd088033267291b46196643"}, + {file = "pymongo-3.12.3-cp310-cp310-win_amd64.whl", hash = "sha256:c7e8221278e5f9e2b6d3893cfc3a3e46c017161a57bb0e6f244826e4cee97916"}, + {file = "pymongo-3.12.3-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:7b4a9fcd95e978cd3c96cdc2096aa54705266551422cf0883c12a4044def31c6"}, + {file = "pymongo-3.12.3-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:06b64cdf5121f86b78a84e61b8f899b6988732a8d304b503ea1f94a676221c06"}, + {file = "pymongo-3.12.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:c8f7dd025cb0bf19e2f60a64dfc24b513c8330e0cfe4a34ccf941eafd6194d9e"}, + {file = "pymongo-3.12.3-cp34-cp34m-win32.whl", hash = "sha256:ab23b0545ec71ea346bf50a5d376d674f56205b729980eaa62cdb7871805014b"}, + {file = "pymongo-3.12.3-cp34-cp34m-win_amd64.whl", hash = "sha256:1b5cb75d2642ff7db823f509641f143f752c0d1ab03166cafea1e42e50469834"}, + {file = "pymongo-3.12.3-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:fc2048d13ff427605fea328cbe5369dce549b8c7657b0e22051a5b8831170af6"}, + {file = "pymongo-3.12.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c5f83bb59d0ff60c6fdb1f8a7b0288fbc4640b1f0fd56f5ae2387749c35d34e3"}, + {file = "pymongo-3.12.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6632b1c63d58cddc72f43ab9f17267354ddce563dd5e11eadabd222dcc808808"}, + {file = "pymongo-3.12.3-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fedad05147b40ff8a93fcd016c421e6c159f149a2a481cfa0b94bfa3e473bab"}, + {file = "pymongo-3.12.3-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:208a61db8b8b647fb5b1ff3b52b4ed6dbced01eac3b61009958adb203596ee99"}, + {file = "pymongo-3.12.3-cp35-cp35m-win32.whl", hash = "sha256:3100a2352bdded6232b385ceda0c0a4624598c517d52c2d8cf014b7abbebd84d"}, + {file = "pymongo-3.12.3-cp35-cp35m-win_amd64.whl", hash = "sha256:3492ae1f97209c66af70e863e6420e6301cecb0a51a5efa701058aa73a8ca29e"}, + {file = "pymongo-3.12.3-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:87e18f29bac4a6be76a30e74de9c9005475e27100acf0830679420ce1fd9a6fd"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b3e08aef4ea05afbc0a70cd23c13684e7f5e074f02450964ec5cfa1c759d33d2"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e66b3c9f8b89d4fd58a59c04fdbf10602a17c914fbaaa5e6ea593f1d54b06362"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5d67dbc8da2dac1644d71c1839d12d12aa333e266a9964d5b1a49feed036bc94"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:a351986d6c9006308f163c359ced40f80b6cffb42069f3e569b979829951038d"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:5296669bff390135528001b4e48d33a7acaffcd361d98659628ece7f282f11aa"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:9d5b66d457d2c5739c184a777455c8fde7ab3600a56d8bbebecf64f7c55169e1"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:1c771f1a8b3cd2d697baaf57e9cfa4ae42371cacfbea42ea01d9577c06d92f96"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81a3ebc33b1367f301d1c8eda57eec4868e951504986d5d3fe437479dcdac5b2"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf113a46d81cff0559d57aa66ffa473d57d1a9496f97426318b6b5b14fdec1c"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64b9122be1c404ce4eb367ad609b590394587a676d84bfed8e03c3ce76d70560"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c6c71e198b36f0f0dfe354f06d3655ecfa30d69493a1da125a9a54668aad652"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33ab8c031f788609924e329003088831045f683931932a52a361d4a955b7dce2"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e2b4c95c47fb81b19ea77dc1c50d23af3eba87c9628fcc2e03d44124a3d336ea"}, + {file = "pymongo-3.12.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4e0a3ea7fd01cf0a36509f320226bd8491e0f448f00b8cb89f601c109f6874e1"}, + {file = "pymongo-3.12.3-cp36-cp36m-win32.whl", hash = "sha256:dfec57f15f53d677b8e4535695ff3f37df7f8fe431f2efa8c3c8c4025b53d1eb"}, + {file = "pymongo-3.12.3-cp36-cp36m-win_amd64.whl", hash = "sha256:c22591cff80188dd8543be0b559d0c807f7288bd353dc0bcfe539b4588b3a5cd"}, + {file = "pymongo-3.12.3-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:7738147cd9dbd6d18d5593b3491b4620e13b61de975fd737283e4ad6c255c273"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:be1f10145f7ea76e3e836fdc5c8429c605675bdcddb0bca9725ee6e26874c00c"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:295a5beaecb7bf054c1c6a28749ed72b19f4d4b61edcd8a0815d892424baf780"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:320f8734553c50cffe8a8e1ae36dfc7d7be1941c047489db20a814d2a170d7b5"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:5d20072d81cbfdd8e15e6a0c91fc7e3a4948c71e0adebfc67d3b4bcbe8602711"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:2c46a0afef69d61938a6fe32c3afd75b91dec3ab3056085dc72abbeedcc94166"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:5f530f35e1a57d4360eddcbed6945aecdaee2a491cd3f17025e7b5f2eea88ee7"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:6526933760ee1e6090db808f1690a111ec409699c1990efc96f134d26925c37f"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95d15cf81cd2fb926f2a6151a9f94c7aacc102b415e72bc0e040e29332b6731c"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d52a70350ec3dfc39b513df12b03b7f4c8f8ec6873bbf958299999db7b05eb1"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9252c991e8176b5a2fa574c5ab9a841679e315f6e576eb7cf0bd958f3e39b0ad"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:145d78c345a38011497e55aff22c0f8edd40ee676a6810f7e69563d68a125e83"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8e0a086dbbee406cc6f603931dfe54d1cb2fba585758e06a2de01037784b737"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f6d5443104f89a840250087863c91484a72f254574848e951d1bdd7d8b2ce7c9"}, + {file = "pymongo-3.12.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6f93dbfa5a461107bc3f5026e0d5180499e13379e9404f07a9f79eb5e9e1303d"}, + {file = "pymongo-3.12.3-cp37-cp37m-win32.whl", hash = "sha256:c9d212e2af72d5c8d082775a43eb726520e95bf1c84826440f74225843975136"}, + {file = "pymongo-3.12.3-cp37-cp37m-win_amd64.whl", hash = "sha256:320a1fe403dd83a35709fcf01083d14bc1462e9789b711201349a9158db3a87e"}, + {file = "pymongo-3.12.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1ba93be779a9b8e5e44f5c133dc1db4313661cead8a2fd27661e6cb8d942ee9"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4294f2c1cd069b793e31c2e6d7ac44b121cf7cedccd03ebcc30f3fc3417b314a"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:845b178bd127bb074835d2eac635b980c58ec5e700ebadc8355062df708d5a71"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:176fdca18391e1206c32fb1d8265628a84d28333c20ad19468d91e3e98312cd1"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:28bfd5244d32faf3e49b5a8d1fab0631e922c26e8add089312e4be19fb05af50"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f38b35ecd2628bf0267761ed659e48af7e620a7fcccfccf5774e7308fb18325c"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:cebb3d8bcac4a6b48be65ebbc5c9881ed4a738e27bb96c86d9d7580a1fb09e05"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:80710d7591d579442c67a3bc7ae9dcba9ff95ea8414ac98001198d894fc4ff46"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d7baa847383b9814de640c6f1a8553d125ec65e2761ad146ea2e75a7ad197c"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:602284e652bb56ca8760f8e88a5280636c5b63d7946fca1c2fe0f83c37dffc64"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2d763d05ec7211313a06e8571236017d3e61d5fef97fcf34ec4b36c0b6556"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6e4dccae8ef5dd76052647d78f02d5d0ffaff1856277d951666c54aeba3ad2"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1fc4d3985868860b6585376e511bb32403c5ffb58b0ed913496c27fd791deea"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4e5d163e6644c2bc84dd9f67bfa89288c23af26983d08fefcc2cbc22f6e57e6"}, + {file = "pymongo-3.12.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8d92c6bb9174d47c2257528f64645a00bbc6324a9ff45a626192797aff01dc14"}, + {file = "pymongo-3.12.3-cp38-cp38-win32.whl", hash = "sha256:b0db9a4691074c347f5d7ee830ab3529bc5ad860939de21c1f9c403daf1eda9a"}, + {file = "pymongo-3.12.3-cp38-cp38-win_amd64.whl", hash = "sha256:d81047341ab56061aa4b6823c54d4632579c3b16e675089e8f520e9b918a133b"}, + {file = "pymongo-3.12.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07398d8a03545b98282f459f2603a6bb271f4448d484ed7f411121a519a7ea48"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b7df0d99e189b7027d417d4bfd9b8c53c9c7ed5a0a1495d26a6f547d820eca88"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:a283425e6a474facd73072d8968812d1d9058490a5781e022ccf8895500b83ce"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:2577b8161eeae4dd376d13100b2137d883c10bb457dd08935f60c9f9d4b5c5f6"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:517b09b1dd842390a965a896d1327c55dfe78199c9f5840595d40facbcd81854"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:2567885ff0c8c7c0887ba6cefe4ae4af96364a66a7069f924ce0cd12eb971d04"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:71c5c200fd37a5322706080b09c3ec8907cf01c377a7187f354fc9e9e13abc73"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:14dee106a10b77224bba5efeeb6aee025aabe88eb87a2b850c46d3ee55bdab4a"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f340a2a908644ea6cccd399be0fb308c66e05d2800107345f9f0f0d59e1731c4"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b4c535f524c9d8c86c3afd71d199025daa070859a2bdaf94a298120b0de16db"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8455176fd1b86de97d859fed4ae0ef867bf998581f584c7a1a591246dfec330f"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf254a1a95e95fdf4eaa25faa1ea450a6533ed7a997f9f8e49ab971b61ea514d"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8a3540e21213cb8ce232e68a7d0ee49cdd35194856c50b8bd87eeb572fadd42"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0e7a5d0b9077e8c3e57727f797ee8adf12e1d5e7534642230d98980d160d1320"}, + {file = "pymongo-3.12.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0be605bfb8461384a4cb81e80f51eb5ca1b89851f2d0e69a75458c788a7263a4"}, + {file = "pymongo-3.12.3-cp39-cp39-win32.whl", hash = "sha256:2157d68f85c28688e8b723bbe70c8013e0aba5570e08c48b3562f74d33fc05c4"}, + {file = "pymongo-3.12.3-cp39-cp39-win_amd64.whl", hash = "sha256:dfa217bf8cf3ff6b30c8e6a89014e0c0e7b50941af787b970060ae5ba04a4ce5"}, + {file = "pymongo-3.12.3-py2.7-macosx-10.14-intel.egg", hash = "sha256:d81299f63dc33cc172c26faf59cc54dd795fc6dd5821a7676cca112a5ee8bbd6"}, + {file = "pymongo-3.12.3.tar.gz", hash = "sha256:0a89cadc0062a5e53664dde043f6c097172b8c1c5f0094490095282ff9995a5f"}, +] +pyparsing = [ + {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, + {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] +python-gitlab = [ + {file = "python-gitlab-3.4.0.tar.gz", hash = "sha256:6180b81ee2f265ad8d8412956a1740b4d3ceca7b28ae2f707dfe62375fed0082"}, + {file = "python_gitlab-3.4.0-py3-none-any.whl", hash = "sha256:251b63f0589d51f854516948c84e9eb8df26e1e9dea595cf86b43f17c43007dd"}, +] +pytz = [ + {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, + {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, +] +pytz-deprecation-shim = [ + {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"}, + {file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"}, +] +pyyaml = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] +regex = [ + {file = "regex-2022.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ab69b4fe09e296261377d209068d52402fb85ef89dc78a9ac4a29a895f4e24a7"}, + {file = "regex-2022.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5bc5f921be39ccb65fdda741e04b2555917a4bced24b4df14eddc7569be3b493"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43eba5c46208deedec833663201752e865feddc840433285fbadee07b84b464d"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c68d2c04f7701a418ec2e5631b7f3552efc32f6bcc1739369c6eeb1af55f62e0"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:caa2734ada16a44ae57b229d45091f06e30a9a52ace76d7574546ab23008c635"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef806f684f17dbd6263d72a54ad4073af42b42effa3eb42b877e750c24c76f86"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be319f4eb400ee567b722e9ea63d5b2bb31464e3cf1b016502e3ee2de4f86f5c"}, + {file = "regex-2022.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:42bb37e2b2d25d958c25903f6125a41aaaa1ed49ca62c103331f24b8a459142f"}, + {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fbc88d3ba402b5d041d204ec2449c4078898f89c4a6e6f0ed1c1a510ef1e221d"}, + {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:91e0f7e7be77250b808a5f46d90bf0032527d3c032b2131b63dee54753a4d729"}, + {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cb3652bbe6720786b9137862205986f3ae54a09dec8499a995ed58292bdf77c2"}, + {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:878c626cbca3b649e14e972c14539a01191d79e58934e3f3ef4a9e17f90277f8"}, + {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6df070a986fc064d865c381aecf0aaff914178fdf6874da2f2387e82d93cc5bd"}, + {file = "regex-2022.3.2-cp310-cp310-win32.whl", hash = "sha256:b549d851f91a4efb3e65498bd4249b1447ab6035a9972f7fc215eb1f59328834"}, + {file = "regex-2022.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8babb2b5751105dc0aef2a2e539f4ba391e738c62038d8cb331c710f6b0f3da7"}, + {file = "regex-2022.3.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1977bb64264815d3ef016625adc9df90e6d0e27e76260280c63eca993e3f455f"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e73652057473ad3e6934944af090852a02590c349357b79182c1b681da2c772"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b22ff939a8856a44f4822da38ef4868bd3a9ade22bb6d9062b36957c850e404f"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:878f5d649ba1db9f52cc4ef491f7dba2d061cdc48dd444c54260eebc0b1729b9"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0008650041531d0eadecc96a73d37c2dc4821cf51b0766e374cb4f1ddc4e1c14"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b1df01cf2aef3a9790858af524ae2588762c8a90e784ba00d003f045306204"}, + {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57484d39447f94967e83e56db1b1108c68918c44ab519b8ecfc34b790ca52bf7"}, + {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74d86e8924835f863c34e646392ef39039405f6ce52956d8af16497af4064a30"}, + {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:ae17fc8103f3b63345709d3e9654a274eee1c6072592aec32b026efd401931d0"}, + {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:5f92a7cdc6a0ae2abd184e8dfd6ef2279989d24c85d2c85d0423206284103ede"}, + {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:5dcc4168536c8f68654f014a3db49b6b4a26b226f735708be2054314ed4964f4"}, + {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:1e30762ddddb22f7f14c4f59c34d3addabc789216d813b0f3e2788d7bcf0cf29"}, + {file = "regex-2022.3.2-cp36-cp36m-win32.whl", hash = "sha256:286ff9ec2709d56ae7517040be0d6c502642517ce9937ab6d89b1e7d0904f863"}, + {file = "regex-2022.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d326ff80ed531bf2507cba93011c30fff2dd51454c85f55df0f59f2030b1687b"}, + {file = "regex-2022.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9d828c5987d543d052b53c579a01a52d96b86f937b1777bbfe11ef2728929357"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87ac58b9baaf50b6c1b81a18d20eda7e2883aa9a4fb4f1ca70f2e443bfcdc57"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6c2441538e4fadd4291c8420853431a229fcbefc1bf521810fbc2629d8ae8c2"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3356afbb301ec34a500b8ba8b47cba0b44ed4641c306e1dd981a08b416170b5"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d96eec8550fd2fd26f8e675f6d8b61b159482ad8ffa26991b894ed5ee19038b"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf668f26604e9f7aee9f8eaae4ca07a948168af90b96be97a4b7fa902a6d2ac1"}, + {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb0e2845e81bdea92b8281a3969632686502565abf4a0b9e4ab1471c863d8f3"}, + {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:87bc01226cd288f0bd9a4f9f07bf6827134dc97a96c22e2d28628e824c8de231"}, + {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:09b4b6ccc61d4119342b26246ddd5a04accdeebe36bdfe865ad87a0784efd77f"}, + {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:9557545c10d52c845f270b665b52a6a972884725aa5cf12777374e18f2ea8960"}, + {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0be0c34a39e5d04a62fd5342f0886d0e57592a4f4993b3f9d257c1f688b19737"}, + {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7b103dffb9f6a47ed7ffdf352b78cfe058b1777617371226c1894e1be443afec"}, + {file = "regex-2022.3.2-cp37-cp37m-win32.whl", hash = "sha256:f8169ec628880bdbca67082a9196e2106060a4a5cbd486ac51881a4df805a36f"}, + {file = "regex-2022.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4b9c16a807b17b17c4fa3a1d8c242467237be67ba92ad24ff51425329e7ae3d0"}, + {file = "regex-2022.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:67250b36edfa714ba62dc62d3f238e86db1065fccb538278804790f578253640"}, + {file = "regex-2022.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5510932596a0f33399b7fff1bd61c59c977f2b8ee987b36539ba97eb3513584a"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f7ee2289176cb1d2c59a24f50900f8b9580259fa9f1a739432242e7d254f93"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d7a68fa53688e1f612c3246044157117403c7ce19ebab7d02daf45bd63913e"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf5317c961d93c1a200b9370fb1c6b6836cc7144fef3e5a951326912bf1f5a3"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad397bc7d51d69cb07ef89e44243f971a04ce1dca9bf24c992c362406c0c6573"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:297c42ede2c81f0cb6f34ea60b5cf6dc965d97fa6936c11fc3286019231f0d66"}, + {file = "regex-2022.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:af4d8cc28e4c7a2f6a9fed544228c567340f8258b6d7ea815b62a72817bbd178"}, + {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:452519bc4c973e961b1620c815ea6dd8944a12d68e71002be5a7aff0a8361571"}, + {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cb34c2d66355fb70ae47b5595aafd7218e59bb9c00ad8cc3abd1406ca5874f07"}, + {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d146e5591cb67c5e836229a04723a30af795ef9b70a0bbd913572e14b7b940f"}, + {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:03299b0bcaa7824eb7c0ebd7ef1e3663302d1b533653bfe9dc7e595d453e2ae9"}, + {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ccb0a4ab926016867260c24c192d9df9586e834f5db83dfa2c8fffb3a6e5056"}, + {file = "regex-2022.3.2-cp38-cp38-win32.whl", hash = "sha256:f7e8f1ee28e0a05831c92dc1c0c1c94af5289963b7cf09eca5b5e3ce4f8c91b0"}, + {file = "regex-2022.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:35ed2f3c918a00b109157428abfc4e8d1ffabc37c8f9abc5939ebd1e95dabc47"}, + {file = "regex-2022.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:55820bc631684172b9b56a991d217ec7c2e580d956591dc2144985113980f5a3"}, + {file = "regex-2022.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:83f03f0bd88c12e63ca2d024adeee75234d69808b341e88343b0232329e1f1a1"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42d6007722d46bd2c95cce700181570b56edc0dcbadbfe7855ec26c3f2d7e008"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:320c2f4106962ecea0f33d8d31b985d3c185757c49c1fb735501515f963715ed"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd3fe37353c62fd0eb19fb76f78aa693716262bcd5f9c14bb9e5aca4b3f0dc4"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e51ad1e6131c496b58d317bc9abec71f44eb1957d32629d06013a21bc99cac"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72bc3a5effa5974be6d965ed8301ac1e869bc18425c8a8fac179fbe7876e3aee"}, + {file = "regex-2022.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e5602a9b5074dcacc113bba4d2f011d2748f50e3201c8139ac5b68cf2a76bd8b"}, + {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:729aa8ca624c42f309397c5fc9e21db90bf7e2fdd872461aabdbada33de9063c"}, + {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d6ecfd1970b3380a569d7b3ecc5dd70dba295897418ed9e31ec3c16a5ab099a5"}, + {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:13bbf0c9453c6d16e5867bda7f6c0c7cff1decf96c5498318bb87f8136d2abd4"}, + {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:58ba41e462653eaf68fc4a84ec4d350b26a98d030be1ab24aba1adcc78ffe447"}, + {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c0446b2871335d5a5e9fcf1462f954586b09a845832263db95059dcd01442015"}, + {file = "regex-2022.3.2-cp39-cp39-win32.whl", hash = "sha256:20e6a27959f162f979165e496add0d7d56d7038237092d1aba20b46de79158f1"}, + {file = "regex-2022.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9efa41d1527b366c88f265a227b20bcec65bda879962e3fc8a2aee11e81266d7"}, + {file = "regex-2022.3.2.tar.gz", hash = "sha256:79e5af1ff258bc0fe0bdd6f69bc4ae33935a898e3cbefbbccf22e88a27fa053b"}, +] +requests = [ + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] +requests-oauthlib = [ + {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, + {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, +] +requests-toolbelt = [ + {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, + {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, +] +rich = [ + {file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"}, + {file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"}, +] +rook = [ + {file = "rook-0.1.171-cp27-cp27m-macosx_10_11_x86_64.whl", hash = "sha256:290ee068d18992fa5c27ebdb5c8745853c682cb44f26bedf858d323832ec8b74"}, + {file = "rook-0.1.171-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:96bd8983ad478f50ca22c524ec20fe095945f85c027b4d316ba46e844976bc35"}, + {file = "rook-0.1.171-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f36b614a85325cfd41e8e4a75ae29e4d7b91aac5981c0c1c4c36452e183e234e"}, + {file = "rook-0.1.171-cp310-cp310-macosx_10_11_x86_64.whl", hash = "sha256:b2d530a77fa1ebb59b15f7efbe810101fdbf7a10851a355c898ddedbfdafb513"}, + {file = "rook-0.1.171-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9a8f4a998c5e8c03dc5a844b9831d916d8e7b79e704600f8d91b2886ef0fe62a"}, + {file = "rook-0.1.171-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:01fa10624a6c773ba8b71d9cd397fd37425ca484f0e64f15a9ee6b3214351cdb"}, + {file = "rook-0.1.171-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:91db652819b6f6f99a5789bbd840cc46036a54908202492926ff62fbbaf9dcc5"}, + {file = "rook-0.1.171-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:d3770f3cc4626e56718d7c998024ca6cc75e240b82269b924b488e3d3f73e20c"}, + {file = "rook-0.1.171-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c7e20ecb27ceec33e4ca4efa4c77664d13ff47f64d176eabf81d97a481c8ed4"}, + {file = "rook-0.1.171-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:aacdb2aea2ca559ce3d93eddea9da70d12b10d8b710c049a52781d88dc2e3b6f"}, + {file = "rook-0.1.171-cp37-cp37m-macosx_10_11_x86_64.whl", hash = "sha256:6add21f04e3c28242638483cf34830fc931c61aac4bf47a88197f04273835e1f"}, + {file = "rook-0.1.171-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f8d478bda592fcc20fb73334fa24630cf2467c1faca81a1004908fc581d987b3"}, + {file = "rook-0.1.171-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a30e32da91b29629fc279990cdb952d6a7cd4945fbafafd7ad88fd71cf9fa624"}, + {file = "rook-0.1.171-cp38-cp38-macosx_10_11_x86_64.whl", hash = "sha256:d87f98c6f059571c40bcb395480cb10a33872e96c1520e8ea98d4703003c148c"}, + {file = "rook-0.1.171-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4474484145b6b4676fc51634dd979376caea7b5722694ec38132ba0e545c85ae"}, + {file = "rook-0.1.171-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:332f572df6a96f6e33e07d0354dfabade1d46a0aeb1aa54e25841bc1fd4239d2"}, + {file = "rook-0.1.171-cp39-cp39-macosx_10_11_x86_64.whl", hash = "sha256:b56ee99de3598a4dc76b25a66b09967f45bbab9c9dec3b6c91b03d4eed1cad7a"}, + {file = "rook-0.1.171-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6bc2dc7a329c7bcac29cae88ed486aba8452437456ee515b3e0ffcb1a1c8dfc9"}, + {file = "rook-0.1.171-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e9b96ab66de7616f6e65ad3f30f2bd442ee966a639faef3afcc740c16ed6c00"}, + {file = "rook-0.1.171.tar.gz", hash = "sha256:3ede95c8461546fd0baac2618397458ab7ddbbcb3f56e925fe21a871c70376c9"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +smmap = [ + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, +] +tweepy = [ + {file = "tweepy-4.8.0-py2.py3-none-any.whl", hash = "sha256:f281bb53ab3ba999ff5e3d743d92d3ed543ee5551c7250948f9e56190ec7a43e"}, + {file = "tweepy-4.8.0.tar.gz", hash = "sha256:8ba5774ac1663b09e5fce1b030daf076f2c9b3ddbf2e7e7ea0bae762e3b1fe3e"}, +] +typing-extensions = [ + {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, + {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, +] +tzdata = [ + {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, + {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, +] +tzlocal = [ + {file = "tzlocal-4.2-py3-none-any.whl", hash = "sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745"}, + {file = "tzlocal-4.2.tar.gz", hash = "sha256:ee5842fa3a795f023514ac2d801c4a81d1743bbe642e3940143326b3a00addd7"}, +] +ulid-py = [ + {file = "ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0"}, + {file = "ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987"}, +] +umongo = [ + {file = "umongo-3.1.0-py2.py3-none-any.whl", hash = "sha256:f6913027651ae673d71aaf54285f9ebf1e49a3f57662e526d029ba72e1a3fcd5"}, + {file = "umongo-3.1.0.tar.gz", hash = "sha256:20c72f09edae931285c22c1928862af35b90ec639a4dac2dbf015aaaac00e931"}, +] +update-checker = [ + {file = "update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd"}, + {file = "update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13"}, +] +urllib3 = [ + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, +] +watchdog = [ + {file = "watchdog-1.0.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e2a531e71be7b5cc3499ae2d1494d51b6a26684bcc7c3146f63c810c00e8a3cc"}, + {file = "watchdog-1.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e7c73edef48f4ceeebb987317a67e0080e5c9228601ff67b3c4062fa020403c7"}, + {file = "watchdog-1.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:85e6574395aa6c1e14e0f030d9d7f35c2340a6cf95d5671354ce876ac3ffdd4d"}, + {file = "watchdog-1.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:27d9b4666938d5d40afdcdf2c751781e9ce36320788b70208d0f87f7401caf93"}, + {file = "watchdog-1.0.2-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2f1ade0d0802503fda4340374d333408831cff23da66d7e711e279ba50fe6c4a"}, + {file = "watchdog-1.0.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f1d0e878fd69129d0d68b87cee5d9543f20d8018e82998efb79f7e412d42154a"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d948ad9ab9aba705f9836625b32e965b9ae607284811cd98334423f659ea537a"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_armv7l.whl", hash = "sha256:101532b8db506559e52a9b5d75a308729b3f68264d930670e6155c976d0e52a0"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_i686.whl", hash = "sha256:b1d723852ce90a14abf0ec0ca9e80689d9509ee4c9ee27163118d87b564a12ac"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_ppc64.whl", hash = "sha256:68744de2003a5ea2dfbb104f9a74192cf381334a9e2c0ed2bbe1581828d50b61"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:602dbd9498592eacc42e0632c19781c3df1728ef9cbab555fab6778effc29eeb"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_s390x.whl", hash = "sha256:016b01495b9c55b5d4126ed8ae75d93ea0d99377084107c33162df52887cee18"}, + {file = "watchdog-1.0.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:5f1f3b65142175366ba94c64d8d4c8f4015825e0beaacee1c301823266b47b9b"}, + {file = "watchdog-1.0.2-py3-none-win32.whl", hash = "sha256:57f05e55aa603c3b053eed7e679f0a83873c540255b88d58c6223c7493833bac"}, + {file = "watchdog-1.0.2-py3-none-win_amd64.whl", hash = "sha256:f84146f7864339c8addf2c2b9903271df21d18d2c721e9a77f779493234a82b5"}, + {file = "watchdog-1.0.2-py3-none-win_ia64.whl", hash = "sha256:ee21aeebe6b3e51e4ba64564c94cee8dbe7438b9cb60f0bb350c4fa70d1b52c2"}, + {file = "watchdog-1.0.2.tar.gz", hash = "sha256:376cbc2a35c0392b0fe7ff16fbc1b303fd99d4dd9911ab5581ee9d69adc88982"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +websocket-client = [ + {file = "websocket-client-1.3.2.tar.gz", hash = "sha256:50b21db0058f7a953d67cc0445be4b948d7fc196ecbeb8083d68d94628e4abf6"}, + {file = "websocket_client-1.3.2-py3-none-any.whl", hash = "sha256:722b171be00f2b90e1d4fb2f2b53146a536ca38db1da8ff49c972a4e1365d0ef"}, +] +yarl = [ + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"}, + {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"}, + {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"}, + {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"}, + {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"}, + {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"}, + {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"}, + {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"}, + {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"}, + {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"}, + {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"}, + {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"}, + {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, + {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a379526 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "jarvis" +version = "2.0.0b2" +description = "J.A.R.V.I.S. admin bot" +authors = ["Zevaryx "] + +[tool.poetry.dependencies] +python = "^3.10" +PyYAML = "^6.0" +GitPython = "^3.1.26" +mongoengine = "^0.23.1" +opencv-python = "^4.5.5" +Pillow = "^9.0.0" +psutil = "^5.9.0" +python-gitlab = "^3.1.1" +ulid-py = "^1.1.0" +tweepy = "^4.5.0" +orjson = "^3.6.6" +jarvis-core = {git = "https://git.zevaryx.com/stark-industries/jarvis/jarvis-core.git", rev = "main"} +aiohttp = "^3.8.1" +pastypy = "^1.0.1" +dateparser = "^1.1.1" +aiofile = "^3.7.4" +asyncpraw = "^7.5.0" +rook = "^0.1.170" +rich = "^12.3.0" +jurigged = "^0.5.0" +aioredis = "^2.0.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/run.py b/run.py index 844831e..d53ee79 100755 --- a/run.py +++ b/run.py @@ -1,117 +1,7 @@ -#!/bin/python3 -# flake8: noqa -from importlib import reload as ireload -from multiprocessing import Process, Value, freeze_support -from pathlib import Path -from time import sleep - -import git - -import jarvis -from jarvis.config import get_config - - -def run(): - ctx = None - while True: - ireload(jarvis) - ctx = jarvis.run(ctx) - - -def restart(): - global jarvis_process - Path(get_pid_file()).unlink() - jarvis_process.kill() - jarvis_process = Process(target=run, name="jarvis") - jarvis_process.start() - - -def update(): - repo = git.Repo(".") - dirty = repo.is_dirty() - if dirty: - print(" Local system has uncommitted changes.") - current_hash = repo.head.object.hexsha - origin = repo.remotes.origin - origin.fetch() - if current_hash != origin.refs["main"].object.hexsha: - if dirty: - return 2 - origin.pull() - return 0 - return 1 - - -def get_pid_file(): - return f"jarvis.{get_pid()}.pid" - - -def get_pid(): - global jarvis_process - return jarvis_process.pid - - -def cli(): - pfile = Path(get_pid_file()) - while not pfile.exists(): - sleep(0.2) - print( - """ - All systems online. - -Command List: - (R)eload - (U)pdate - (Q)uit - """ - ) - while True: - cmd = input("> ") - if cmd.lower() in ["q", "quit", "e", "exit"]: - print(" Shutting down core systems...") - pfile.unlink() - break - if cmd.lower() in ["u", "update"]: - print(" Updating core systems...") - status = update() - if status == 0: - restart() - pfile = Path(get_pid_file()) - while not pfile.exists(): - sleep(0.2) - print(" Core systems successfully updated.") - elif status == 1: - print(" No core updates available.") - elif status == 2: - print(" Core system update available, but core is dirty.") - if cmd.lower() in ["r", "reload"]: - print(" Reloading core systems...") - restart() - pfile = Path(get_pid_file()) - while not pfile.exists(): - sleep(0.2) - print(" All systems reloaded.") +"""Main run file for J.A.R.V.I.S.""" +import asyncio +from jarvis import run if __name__ == "__main__": - freeze_support() - config = get_config() - pid_file = Value("i", 0) - jarvis_process = Process(target=run, name="jarvis") - logo = jarvis.logo.get_logo(config.logo) - print(logo) - print("Initializing....") - print(" Updating core systems...") - status = update() - if status == 0: - print(" Core systems successfully updated") - elif status == 1: - print(" No core updates available.") - elif status == 2: - print(" Core updates available, but not applied.") - print(" Starting core systems...") - jarvis_process.start() - cli() - if jarvis_process.is_alive(): - jarvis_process.kill() - print("All systems shut down.") + asyncio.run(run())