From cbb0c075cc5f54ea2327c799b36981f7fd30e050 Mon Sep 17 00:00:00 2001 From: zevaryx Date: Mon, 22 Aug 2022 21:07:37 +0000 Subject: [PATCH 01/24] Version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 706e1e8..cdc1836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jarvis" -version = "2.2.1" +version = "2.2.2" description = "JARVIS admin bot" authors = ["Zevaryx "] From 5cf4d98ebb65a5660d13ba5600978c0997a9950c Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sun, 28 Aug 2022 19:14:27 -0600 Subject: [PATCH 02/24] Check if user in guild on unmute, closes #149 --- jarvis/cogs/admin/mute.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jarvis/cogs/admin/mute.py b/jarvis/cogs/admin/mute.py index 822bbc4..0cf301c 100644 --- a/jarvis/cogs/admin/mute.py +++ b/jarvis/cogs/admin/mute.py @@ -207,6 +207,10 @@ class MuteCog(ModcaseCog): await ctx.send("User is not muted", ephemeral=True) return + if not await ctx.guild.fetch_member(user.id): + await ctx.send("User must be in guild", ephemeral=True) + return + await user.timeout(communication_disabled_until=datetime.now(tz=timezone.utc)) embed = build_embed( From 23dc1e7de29ae7ba4ebbd8b817012f480d4f7c8f Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sun, 28 Aug 2022 21:36:56 -0600 Subject: [PATCH 03/24] Fix issue in on_member_remove --- jarvis/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jarvis/client.py b/jarvis/client.py index b37dda6..b721df9 100644 --- a/jarvis/client.py +++ b/jarvis/client.py @@ -306,7 +306,7 @@ class Jarvis(StatsClient): 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) + channel = await guild.fetch_channel(log.value) embed = build_embed( title="Member Left", description=f"{user.username}#{user.discriminator} left {guild.name}", From 7ecc217a92d6af24f395be5c9d38066945610da2 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Mon, 29 Aug 2022 09:40:59 -0600 Subject: [PATCH 04/24] New antiphish, faster yachts sink --- jarvis/client.py | 91 ++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/jarvis/client.py b/jarvis/client.py index b721df9..30800d6 100644 --- a/jarvis/client.py +++ b/jarvis/client.py @@ -81,6 +81,7 @@ class Jarvis(StatsClient): self.logger = logging.getLogger(__name__) self.phishing_domains = [] self.pre_run_callback = self._prerun + self.synced = False async def _chunk_all(self) -> None: """Chunk all guilds.""" @@ -88,15 +89,17 @@ class Jarvis(StatsClient): self.logger.debug(f"Chunking guild {guild.name} <{guild.id}>") await guild.chunk_guild() - @Task.create(IntervalTrigger(hours=1)) + @Task.create(IntervalTrigger(minutes=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/3700") + response = await session.get("https://phish.sinking.yachts/v2/recent/60") response.raise_for_status() data = await response.json() self.logger.debug(f"Found {len(data)} changes to phishing domains") + if len(data) == 0: + return add = 0 sub = 0 @@ -141,9 +144,11 @@ class Jarvis(StatsClient): async def on_ready(self) -> None: """NAFF on_ready override.""" try: - await self._sync_domains() - self._update_domains.start() - asyncio.create_task(self._chunk_all()) + if not self.synced: + await self._sync_domains() + self._update_domains.start() + asyncio.create_task(self._chunk_all()) + self.synced = True 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 @@ -662,46 +667,50 @@ class Jarvis(StatsClient): """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} + resp = await session.post( + "https://anti-fish.bitflow.dev/check", + json={"message": match.string}, + headers={ + "Application-Name": "JARVIS", + "Application-Link": "https://git.zevaryx.com/stark-industries/jarvis", + }, ) 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - reasons = ", ".join(item["not_safe_reasons"]) - embed = warning_embed(message.author, reasons) - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning embed") - try: - await message.delete() - except Exception: - self.logger.warn("Failed to delete malicious message") - tracker = malicious_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - return True + if data["match"]: + 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + reasons = ", ".join(f"{m['source']}: {m['type']}" for m in data["matches"]) + embed = warning_embed(message.author, reasons) + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning embed") + try: + await message.delete() + except Exception: + self.logger.warn("Failed to delete malicious message") + tracker = malicious_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + return True return False @listen() From 0e0e4ed6552b6d7c9b1e902e16736a7009a27a01 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sun, 28 Aug 2022 19:08:36 -0600 Subject: [PATCH 05/24] Update NAFF --- poetry.lock | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index abc28ae..5bd7d85 100644 --- a/poetry.lock +++ b/poetry.lock @@ -314,14 +314,14 @@ langdetect = ["langdetect"] [[package]] name = "discord-typings" -version = "0.4.0" +version = "0.5.1" description = "Maintained typings of payloads that Discord sends" category = "main" optional = false python-versions = ">=3.7" [package.dependencies] -typing_extensions = ">=4,<5" +typing_extensions = ">=4.3,<5" [[package]] name = "distro" @@ -514,7 +514,7 @@ python-versions = "*" [[package]] name = "naff" -version = "1.8.0" +version = "1.9.0" description = "Not another freaking fork" category = "main" optional = false @@ -523,11 +523,12 @@ python-versions = ">=3.10" [package.dependencies] aiohttp = "*" attrs = "*" -discord-typings = "*" +discord-typings = ">=0.5.1" tomli = "*" [package.extras] -all = ["PyNaCl (>=1.5.0,<1.6)", "cchardet", "aiodns", "orjson", "brotli"] +all = ["PyNaCl (>=1.5.0,<1.6)", "cchardet", "aiodns", "orjson", "brotli", "sentry-sdk"] +sentry = ["sentry-sdk"] speedup = ["cchardet", "aiodns", "orjson", "brotli"] tests = ["pytest", "pytest-recording", "pytest-asyncio", "pytest-cov", "typeguard"] voice = ["PyNaCl (>=1.5.0,<1.6)"] @@ -1012,7 +1013,7 @@ test = ["vcrpy (>=1.10.3)"] [[package]] name = "typing-extensions" -version = "4.2.0" +version = "4.3.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false @@ -1385,10 +1386,7 @@ dateparser = [ {file = "dateparser-1.1.1-py2.py3-none-any.whl", hash = "sha256:9600874312ff28a41f96ec7ccdc73be1d1c44435719da47fea3339d55ff5a628"}, {file = "dateparser-1.1.1.tar.gz", hash = "sha256:038196b1f12c7397e38aad3d61588833257f6f552baa63a1499e6987fa8d42d9"}, ] -discord-typings = [ - {file = "discord-typings-0.4.0.tar.gz", hash = "sha256:66bce666194e8f006914f788f940265c009cce9b63f9a8ce2bc7931d3d3ef11c"}, - {file = "discord_typings-0.4.0-py3-none-any.whl", hash = "sha256:a390b614cbcbd82af083660c12e46536b4b790ac026f8d43bdd11c8953837ca0"}, -] +discord-typings = [] distro = [ {file = "distro-1.7.0-py3-none-any.whl", hash = "sha256:d596311d707e692c2160c37807f83e3820c5d539d5a83e87cfb6babd8ba3a06b"}, {file = "distro-1.7.0.tar.gz", hash = "sha256:151aeccf60c216402932b52e40ee477a939f8d58898927378a02abbe852c1c39"}, @@ -1565,8 +1563,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] naff = [ - {file = "naff-1.8.0-py3-none-any.whl", hash = "sha256:96284f17841a782bdf4cb1e0b767b75e93a0afb9c0cd852a448e9a475b38efb6"}, - {file = "naff-1.8.0.tar.gz", hash = "sha256:0fada9174642d6daa5b76f2e52c992722ffc8219ba9067b101d018380df1ad24"}, + {file = "naff-1.9.0-py3-none-any.whl", hash = "sha256:20144495aed9452d9d2e713eb6ade9636601457ca3de255684b2186068505bcd"}, + {file = "naff-1.9.0.tar.gz", hash = "sha256:f4870ea304747368d6d750f3d52fcbc96017bd7afaa7ec06a3e9a68ff301997d"}, ] nafftrack = [] nanoid = [ @@ -2117,10 +2115,7 @@ tweepy = [ {file = "tweepy-4.10.0-py3-none-any.whl", hash = "sha256:f0abbd234a588e572f880f99a094ac321217ff3eade6c0eca118ed6db8e2cf0a"}, {file = "tweepy-4.10.0.tar.gz", hash = "sha256:7f92574920c2f233663fff154745fc2bb0d10aedc23617379a912d8e4fefa399"}, ] -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"}, -] +typing-extensions = [] tzdata = [ {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"}, From 325b12697b3b8161bf5f5144eea049560413bed9 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sun, 28 Aug 2022 19:10:21 -0600 Subject: [PATCH 06/24] Add delete button to all embeds, change how reminders work a little --- jarvis/client.py | 69 ++++++++++++++- jarvis/cogs/botutil.py | 23 ++++- jarvis/cogs/dev.py | 17 +++- jarvis/cogs/image.py | 8 +- jarvis/cogs/remindme.py | 181 ++++++++++++++++----------------------- jarvis/cogs/rolegiver.py | 15 ++-- jarvis/cogs/starboard.py | 9 +- jarvis/cogs/tags.py | 26 ++++-- jarvis/cogs/temprole.py | 8 +- jarvis/cogs/util.py | 40 ++++++--- 10 files changed, 251 insertions(+), 145 deletions(-) diff --git a/jarvis/client.py b/jarvis/client.py index 30800d6..74d7f04 100644 --- a/jarvis/client.py +++ b/jarvis/client.py @@ -14,8 +14,10 @@ from jarvis_core.db.models import ( Autoreact, Modlog, Note, + Reminder, Roleping, Setting, + Star, Warning, ) from jarvis_core.filters import invites, url @@ -159,6 +161,9 @@ class Jarvis(StatsClient): "{}&permissions=8&scope=bot%20applications.commands".format(self.user.id) ) + self.logger.debug("Hitting Reminders for faster loads") + _ = await Reminder.find().to_list(None) + async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None: """NAFF on_error override.""" if isinstance(error, HTTPException): @@ -850,13 +855,11 @@ class Jarvis(StatsClient): 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.""" + async def _handle_modcase_button(self, event: Button) -> None: context = event.context if not context.custom_id.startswith("modcase|"): - return # await super().on_button(event) + return # Failsafe if not context.deferred and not context.responded: await context.defer(ephemeral=True) @@ -926,3 +929,61 @@ class Jarvis(StatsClient): await context.send(msg) await self.redis.delete(user_key) await self.redis.delete(action_key) + + async def _handle_delete_button(self, event: Button) -> None: + context = event.context + + if not context.custom_id.startswith("delete|"): + return # Failsafe + + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + + uid = context.custom_id.split("|")[1] + + if ( + not context.author.has_permission(Permissions.MANAGE_MESSAGES) + and not context.author.has_permission(Permissions.ADMINISTRATOR) + and not str(context.author) == uid + ): + await context.send("I'm afraid I can't let you do that", ephemeral=True) + return # User does not have perms to delete + + if star := await Star.find_one(q(star=context.message.id, guild=context.guild.id)): + await star.delete() + + await context.message.delete() + await context.send("Message deleted", ephemeral=True) + + async def _handle_copy_button(self, event: Button) -> None: + context = event.context + + if not context.custom_id.startswith("copy|"): + return + + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + + what, rid = context.custom_id.split("|")[1:] + if what == "rme": + reminder = await Reminder.find_one(q(_id=rid)) + if reminder: + new_reminder = Reminder( + user=context.author.id, + channel=context.channel.id, + guild=context.guild.id, + message=reminder.message, + remind_at=reminder.remind_at, + private=reminder.private, + active=reminder.active, + ) + await new_reminder.commit() + + await context.send("Reminder copied!", ephemeral=True) + + @listen() + async def on_button(self, event: Button) -> None: + """Process button events.""" + await self._handle_modcase_button(event) + await self._handle_delete_button(event) + await self._handle_copy_button(event) diff --git a/jarvis/cogs/botutil.py b/jarvis/cogs/botutil.py index 8cd9d26..9f9ae83 100644 --- a/jarvis/cogs/botutil.py +++ b/jarvis/cogs/botutil.py @@ -7,7 +7,9 @@ from io import BytesIO import psutil from aiofile import AIOFile, LineReader from naff import Client, Extension, PrefixedContext, prefixed_command +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.file import File from rich.console import Console @@ -78,7 +80,10 @@ class BotutilCog(Extension): ) embed = build_embed(title="System Info", description="", fields=fields) embed.set_image(url=self.bot.user.avatar.url) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @prefixed_command(name="update") async def _update(self, ctx: PrefixedContext) -> None: @@ -111,15 +116,25 @@ class BotutilCog(Extension): self.logger.info("Updates applied") content = f"```ansi\n{capture.get()}\n```" + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) if len(content) < 3000: - await ctx.reply(content, embeds=embed) + await ctx.reply(content, embeds=embed, components=components) else: - await ctx.reply(f"Total Changes: {status.lines['total_lines']}", embeds=embed) + await ctx.reply( + f"Total Changes: {status.lines['total_lines']}", + embeds=embed, + components=components, + ) 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(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.reply(embeds=embed, components=components) def setup(bot: Client) -> None: diff --git a/jarvis/cogs/dev.py b/jarvis/cogs/dev.py index 49091b0..cad6dab 100644 --- a/jarvis/cogs/dev.py +++ b/jarvis/cogs/dev.py @@ -16,7 +16,9 @@ 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, Extension, InteractionContext +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.file import File from naff.models.discord.message import Attachment from naff.models.naff.application_commands import ( @@ -119,7 +121,10 @@ class DevCog(Extension): ] embed = build_embed(title=title, description=description, fields=fields) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command(name="uuid", description="Generate a UUID") @slash_option( @@ -235,7 +240,10 @@ class DevCog(Extension): EmbedField(name=mstr, value=f"`{encoded}`", inline=False), ] embed = build_embed(title="Decoded Data", description="", fields=fields) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command(name="decode", description="Decode some data") @slash_option( @@ -270,7 +278,10 @@ class DevCog(Extension): EmbedField(name=mstr, value=f"`{decoded}`", inline=False), ] embed = build_embed(title="Decoded Data", description="", fields=fields) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command(name="cloc", description="Get JARVIS lines of code") @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) diff --git a/jarvis/cogs/image.py b/jarvis/cogs/image.py index 10d9b01..93f7a12 100644 --- a/jarvis/cogs/image.py +++ b/jarvis/cogs/image.py @@ -8,7 +8,9 @@ import cv2 import numpy as np from jarvis_core.util import convert_bytesize, unconvert_bytesize from naff import Client, Extension, InteractionContext +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.file import File from naff.models.discord.message import Attachment from naff.models.naff.application_commands import ( @@ -147,9 +149,11 @@ class ImageCog(Extension): ] embed = build_embed(title=filename, description="", fields=fields) embed.set_image(url="attachment://resized.png") + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) await ctx.send( - embeds=embed, - file=File(file=bufio, file_name="resized.png"), + embeds=embed, file=File(file=bufio, file_name="resized.png"), components=components ) diff --git a/jarvis/cogs/remindme.py b/jarvis/cogs/remindme.py index a9aa431..a18dfb6 100644 --- a/jarvis/cogs/remindme.py +++ b/jarvis/cogs/remindme.py @@ -5,16 +5,15 @@ import re from datetime import datetime, timezone from typing import List -from bson import ObjectId 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, Extension, InteractionContext -from naff.client.utils.misc_utils import get +from naff import AutocompleteContext, Client, Extension, InteractionContext from naff.models.discord.channel import GuildChannel -from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.components import ActionRow, Button from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.modal import InputText, Modal, TextStyles from naff.models.naff.application_commands import ( OptionTypes, @@ -22,6 +21,7 @@ from naff.models.naff.application_commands import ( slash_command, slash_option, ) +from thefuzz import process from jarvis.utils import build_embed @@ -52,16 +52,8 @@ class RemindmeCog(Extension): ctx: InteractionContext, private: bool = None, ) -> None: - if private is None: + if private is None and ctx.guild: private = ctx.guild.member_count >= 5000 - 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", - ephemeral=True, - ) - return modal = Modal( title="Set your reminder!", components=[ @@ -145,7 +137,7 @@ class RemindmeCog(Extension): r = Reminder( user=ctx.author.id, channel=ctx.channel.id, - guild=ctx.guild.id, + guild=ctx.guild.id if ctx.guild else ctx.author.id, message=message, remind_at=remind_at, private=private, @@ -173,7 +165,13 @@ class RemindmeCog(Extension): ) embed.set_thumbnail(url=ctx.author.display_avatar.url) - await response.send(embeds=embed, ephemeral=private) + delete_button = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + copy_button = Button(style=ButtonStyles.GREEN, emoji="📋", custom_id=f"copy|rme|{r.id}") + components = [ActionRow(delete_button, copy_button)] + private = private if private is not None else False + await response.send(embeds=embed, components=components, ephemeral=private) async def get_reminders_embed( self, ctx: InteractionContext, reminders: List[Reminder] @@ -222,126 +220,99 @@ class RemindmeCog(Extension): return embed = await self.get_reminders_embed(ctx, reminders) - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @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", ephemeral=True) + @slash_option( + name="content", + description="Content of the reminder", + opt_type=OptionTypes.STRING, + required=True, + autocomplete=True, + ) + async def _delete(self, ctx: InteractionContext, content: str) -> None: + reminder = await Reminder.find_one(q(_id=content)) + if not reminder: + await ctx.send(f"Reminder `{content}` does not exist", ephemeral=True) return - options = [] - for reminder in reminders: - option = SelectOption( - label=f"{reminder.remind_at}", - value=str(reminder.id), - emoji="⏰", - ) - options.append(option) + ts = int(reminder.remind_at.timestamp()) - select = Select( - options=options, - custom_id="to_delete", - placeholder="Select reminders to delete", - min_values=1, - max_values=len(reminders), + fields = [EmbedField(name=f"", value=reminder.message, inline=False)] + + embed = build_embed( + title="Deleted Reminder(s)", + description="", + fields=fields, ) - components = [ActionRow(select)] - embed = await self.get_reminders_embed(ctx, reminders) - message = await ctx.send( - content=f"You have {len(reminders)} reminder(s) set:", - embeds=embed, - components=components, + embed.set_author( + name=ctx.author.display_name + "#" + ctx.author.discriminator, + icon_url=ctx.author.display_avatar.url, ) + embed.set_thumbnail(url=ctx.author.display_avatar.url) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{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, - ) - - fields = [] - 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="", - fields=fields, - ) - - embed.set_author( - name=ctx.author.display_name + "#" + ctx.author.discriminator, - icon_url=ctx.author.display_avatar.url, - ) - embed.set_thumbnail(url=ctx.author.display_avatar.url) - - await context.context.edit_origin( - content=f"Deleted {len(context.context.values)} reminder(s)", - components=components, - embeds=embed, - ) - except asyncio.TimeoutError: - for row in components: - for component in row.components: - component.disabled = True - await message.edit(components=components) + await reminder.delete() + except Exception: + self.logger.debug("Ignoring deletion error") + await ctx.send(embeds=embed, ephemeral=reminder.private, components=components) @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 + name="content", + description="Content of the reminder", + opt_type=OptionTypes.STRING, + required=True, + autocomplete=True, ) - async def _fetch(self, ctx: InteractionContext, id: str) -> None: - reminder = await Reminder.find_one(q(id=id)) + async def _fetch(self, ctx: InteractionContext, content: str) -> None: + reminder = await Reminder.find_one(q(_id=content)) if not reminder: - await ctx.send(f"Reminder `{id}` does not exist", ephemeral=True) + await ctx.send(f"Reminder `{content}` does not exist", ephemeral=True) return - embed = build_embed(title="You have a reminder!", description=reminder.message, fields=[]) + ts = int(reminder.remind_at.timestamp()) + + fields = [EmbedField(name="Remind At", value=f" ()")] + + embed = build_embed( + title="You have a reminder!", description=reminder.message, fields=fields + ) embed.set_author( name=ctx.author.display_name + "#" + ctx.author.discriminator, - icon_url=ctx.author.display_avatar, + icon_url=ctx.author.display_avatar.url, ) - embed.set_thumbnail(url=ctx.author.display_avatar) - await ctx.send(embeds=embed, ephemeral=reminder.private) + embed.set_thumbnail(url=ctx.author.display_avatar.url) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, ephemeral=reminder.private, components=components) 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") + @_fetch.autocomplete("content") + @_delete.autocomplete("content") + async def _search_reminders(self, ctx: AutocompleteContext, content: str) -> None: + reminders = await Reminder.find(q(user=ctx.author.id)).to_list(None) + lookup = {r.message: str(r.id) for r in reminders} + results = process.extract(content, list(lookup.keys()), limit=5) + choices = [{"name": r[0], "value": lookup[r[0]]} for r in results] + await ctx.send(choices=choices) + def setup(bot: Client) -> None: """Add RemindmeCog to JARVIS""" diff --git a/jarvis/cogs/rolegiver.py b/jarvis/cogs/rolegiver.py index cf818ed..6627a39 100644 --- a/jarvis/cogs/rolegiver.py +++ b/jarvis/cogs/rolegiver.py @@ -6,8 +6,9 @@ from jarvis_core.db import q from jarvis_core.db.models import Rolegiver from naff import Client, Extension, InteractionContext, Permissions from naff.client.utils.misc_utils import get -from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.components import ActionRow, Button, Select, SelectOption from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.role import Role from naff.models.naff.application_commands import ( OptionTypes, @@ -92,8 +93,10 @@ class RolegiverCog(Extension): 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}") - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @rolegiver.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role from rolegiver") @check(admin_or_permissions(Permissions.MANAGE_GUILD)) @@ -210,8 +213,10 @@ class RolegiverCog(Extension): ) embed.set_footer(text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}") - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) role = SlashCommand(name="role", description="Get/Remove Rolegiver roles") diff --git a/jarvis/cogs/starboard.py b/jarvis/cogs/starboard.py index 868ece6..7e62058 100644 --- a/jarvis/cogs/starboard.py +++ b/jarvis/cogs/starboard.py @@ -7,7 +7,8 @@ from jarvis_core.db.models import Star, Starboard from naff import Client, Extension, InteractionContext, Permissions from naff.client import errors from naff.models.discord.channel import GuildText -from naff.models.discord.components import ActionRow, Select, SelectOption +from naff.models.discord.components import ActionRow, Button, Select, SelectOption +from naff.models.discord.enums import ButtonStyles from naff.models.discord.message import Message from naff.models.naff.application_commands import ( CommandTypes, @@ -231,8 +232,10 @@ class StarboardCog(Extension): embed.set_footer(text=ctx.guild.name + " | " + channel.name) if image_url: embed.set_image(url=image_url) - - star = await starboard.send(embeds=embed) + star_components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + star = await starboard.send(embeds=embed, components=star_components) await Star( index=count, diff --git a/jarvis/cogs/tags.py b/jarvis/cogs/tags.py index d476244..43f9f8b 100644 --- a/jarvis/cogs/tags.py +++ b/jarvis/cogs/tags.py @@ -7,8 +7,9 @@ from typing import Dict, List from jarvis_core.db import q from jarvis_core.db.models import Setting, Tag from naff import AutocompleteContext, Client, Extension, InteractionContext +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField -from naff.models.discord.enums import Permissions +from naff.models.discord.enums import ButtonStyles, Permissions from naff.models.discord.modal import InputText, Modal, TextStyles from naff.models.naff.application_commands import ( OptionTypes, @@ -128,7 +129,11 @@ class TagCog(Extension): icon_url=ctx.author.display_avatar.url, ) - await response.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + + await response.send(embeds=embed, components=components) if ctx.guild.id not in self.cache: self.cache[ctx.guild.id] = [] self.cache[ctx.guild.id].append(tag.name) @@ -230,8 +235,10 @@ class TagCog(Extension): name=ctx.author.username + "#" + ctx.author.discriminator, icon_url=ctx.author.display_avatar.url, ) - - await response.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await response.send(embeds=embed, components=components) if tag.name not in self.cache[ctx.guild.id]: self.cache[ctx.guild.id].remove(old_name) self.cache[ctx.guild.id].append(tag.name) @@ -309,15 +316,20 @@ class TagCog(Extension): name=f"{username}#{discrim}" if username else "Unknown User", icon_url=url, ) - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @tag.subcommand(sub_cmd_name="list", sub_cmd_description="List tag names") async def _list(self, ctx: InteractionContext) -> None: tags = await Tag.find(q(guild=ctx.guild.id)).to_list(None) names = "\n".join(f"`{t.name}`" for t in tags) embed = build_embed(title="All Tags", description=names, fields=[]) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @_get.autocomplete("name") @_edit.autocomplete("name") diff --git a/jarvis/cogs/temprole.py b/jarvis/cogs/temprole.py index d2a89a3..679394e 100644 --- a/jarvis/cogs/temprole.py +++ b/jarvis/cogs/temprole.py @@ -6,7 +6,9 @@ from dateparser import parse from dateparser_data.settings import default_parsers from jarvis_core.db.models import Temprole from naff import Client, Extension, InteractionContext, Permissions +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.role import Role from naff.models.discord.user import Member from naff.models.naff.application_commands import ( @@ -117,8 +119,10 @@ class TemproleCog(Extension): embed.set_author( name=f"{user.username}#{user.discriminator}", icon_url=user.display_avatar.url ) - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) def setup(bot: Client) -> None: diff --git a/jarvis/cogs/util.py b/jarvis/cogs/util.py index a9f91d4..1134a81 100644 --- a/jarvis/cogs/util.py +++ b/jarvis/cogs/util.py @@ -10,7 +10,9 @@ import numpy as np from dateparser import parse from naff import Client, Extension, InteractionContext, const from naff.models.discord.channel import GuildCategory, GuildText, GuildVoice +from naff.models.discord.components import Button from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import ButtonStyles from naff.models.discord.file import File from naff.models.discord.guild import Guild from naff.models.discord.role import Role @@ -67,7 +69,10 @@ class UtilCog(Extension): ) ) embed = build_embed(title=title, description=desc, fields=fields, color=color) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command( name="logo", @@ -79,7 +84,10 @@ class UtilCog(Extension): JARVIS_LOGO.save(image_bytes, "PNG") image_bytes.seek(0) logo = File(image_bytes, file_name="logo.png") - await ctx.send(file=logo) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(file=logo, components=components) @slash_command(name="rchk", description="Robot Camo HK416") async def _rchk(self, ctx: InteractionContext) -> None: @@ -130,7 +138,10 @@ class UtilCog(Extension): embed = build_embed(title="Avatar", description="", fields=[], color="#00FFEE") embed.set_image(url=avatar) embed.set_author(name=f"{user.username}#{user.discriminator}", icon_url=avatar) - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command( name="roleinfo", @@ -177,8 +188,10 @@ class UtilCog(Extension): im.save(image_bytes, "PNG") image_bytes.seek(0) color_show = File(image_bytes, file_name="color_show.png") - - await ctx.send(embeds=embed, file=color_show) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, file=color_show, components=components) async def _userinfo(self, ctx: InteractionContext, user: User = None) -> None: await ctx.defer() @@ -224,8 +237,10 @@ class UtilCog(Extension): ) embed.set_thumbnail(url=user.display_avatar.url) embed.set_footer(text=f"ID: {user.id}") - - await ctx.send(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command( name="userinfo", @@ -279,8 +294,10 @@ class UtilCog(Extension): 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(embeds=embed) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, components=components) @slash_command( name="pw", @@ -375,7 +392,10 @@ class UtilCog(Extension): EmbedField(name="ISO8601", value=timestamp.isoformat()), ] embed = build_embed(title="Converted Time", description=f"`{string}`", fields=fields) - await ctx.send(embeds=embed, ephemeral=private) + components = Button( + style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" + ) + await ctx.send(embeds=embed, ephemeral=private, components=components) @slash_command(name="support", description="Got issues?") async def _support(self, ctx: InteractionContext) -> None: From f5517db6ae288bbe80baea2cee7e8d080cb74389 Mon Sep 17 00:00:00 2001 From: zevaryx Date: Mon, 29 Aug 2022 17:16:21 +0000 Subject: [PATCH 07/24] Fix tiny bug in delete button logic, version bump --- jarvis/client.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jarvis/client.py b/jarvis/client.py index 74d7f04..3ad5168 100644 --- a/jarvis/client.py +++ b/jarvis/client.py @@ -944,7 +944,7 @@ class Jarvis(StatsClient): if ( not context.author.has_permission(Permissions.MANAGE_MESSAGES) and not context.author.has_permission(Permissions.ADMINISTRATOR) - and not str(context.author) == uid + and not str(context.author.id) == uid ): await context.send("I'm afraid I can't let you do that", ephemeral=True) return # User does not have perms to delete diff --git a/pyproject.toml b/pyproject.toml index cdc1836..52c93fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jarvis" -version = "2.2.2" +version = "2.2.3" description = "JARVIS admin bot" authors = ["Zevaryx "] From 43e50cb753786e8e17d5df2c028809ba2dd2b894 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Tue, 30 Aug 2022 23:32:23 -0600 Subject: [PATCH 08/24] Re-write client to remove monofile --- jarvis/client.py | 989 ----------------------------- jarvis/client/__init__.py | 38 ++ jarvis/client/errors.py | 107 ++++ jarvis/client/events/__init__.py | 115 ++++ jarvis/client/events/components.py | 144 +++++ jarvis/client/events/member.py | 163 +++++ jarvis/client/events/message.py | 434 +++++++++++++ jarvis/client/tasks.py | 34 + 8 files changed, 1035 insertions(+), 989 deletions(-) delete mode 100644 jarvis/client.py create mode 100644 jarvis/client/__init__.py create mode 100644 jarvis/client/errors.py create mode 100644 jarvis/client/events/__init__.py create mode 100644 jarvis/client/events/components.py create mode 100644 jarvis/client/events/member.py create mode 100644 jarvis/client/events/message.py create mode 100644 jarvis/client/tasks.py diff --git a/jarvis/client.py b/jarvis/client.py deleted file mode 100644 index 3ad5168..0000000 --- a/jarvis/client.py +++ /dev/null @@ -1,989 +0,0 @@ -"""Custom JARVIS client.""" -import asyncio -import logging -import re -import traceback -from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING - -from aiohttp import ClientSession -from jarvis_core.db import q -from jarvis_core.db.models import ( - Action, - Autopurge, - Autoreact, - Modlog, - Note, - Reminder, - Roleping, - Setting, - Star, - Warning, -) -from jarvis_core.filters import invites, url -from jarvis_core.util.ansi import RESET, Fore, Format, fmt -from naff import 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 nafftrack.client import StatsClient -from pastypy import AsyncPaste as Paste - -from jarvis import const -from jarvis.tracking import jarvis_info, malicious_tracker, warnings_tracker -from jarvis.utils import build_embed -from jarvis.utils.embeds import warning_embed - -if TYPE_CHECKING: - from aioredis import Redis - -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(StatsClient): - def __init__(self, redis: "Redis", *args, **kwargs): # noqa: ANN002 ANN003 - super().__init__(*args, **kwargs) - self.redis = redis - self.logger = logging.getLogger(__name__) - self.phishing_domains = [] - self.pre_run_callback = self._prerun - self.synced = False - - async def _chunk_all(self) -> None: - """Chunk all guilds.""" - for guild in self.guilds: - self.logger.debug(f"Chunking guild {guild.name} <{guild.id}>") - await guild.chunk_guild() - - @Task.create(IntervalTrigger(minutes=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/60") - response.raise_for_status() - data = await response.json() - - self.logger.debug(f"Found {len(data)} changes to phishing domains") - if len(data) == 0: - return - - 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 - cargs = "" - if isinstance(ctx, InteractionContext) and ctx.target_id: - kwargs["context target"] = ctx.target - cargs = " ".join(f"{k}:{v}" for k, v in kwargs.items()) - elif isinstance(ctx, PrefixedContext): - cargs = " ".join(args) - self.logger.debug(f"Running command `{name}` with args: {cargs 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_startup(self) -> None: - """NAFF on_startup override. Prometheus info generated here.""" - jarvis_info.info({"version": const.__version__}) - - @listen() - async def on_ready(self) -> None: - """NAFF on_ready override.""" - try: - if not self.synced: - await self._sync_domains() - self._update_domains.start() - asyncio.create_task(self._chunk_all()) - self.synced = True - 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) - ) - - self.logger.debug("Hitting Reminders for faster loads") - _ = await Reminder.find().to_list(None) - - 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")) - ignore = await Setting.find_one(q(guild=ctx.guild.id, setting="log_ignore")) - if modlog and (ignore and ctx.channel.id not in ignore.value): - 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(embeds=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.value) - 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(embeds=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, - ) - 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, - ) - 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(embeds=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: - if not message.author.has_permission(Permissions.ADMINISTRATOR): - 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.replace("\n", " ") - name = re.sub(r"<:\w+:(\d+)>", "", name) - 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - embed = warning_embed(message.author, "Sent an invite link") - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - embed = warning_embed(message.author, "Mass Mention") - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role") - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - embed = warning_embed(message.author, "Phishing URL") - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning embed") - try: - await message.delete() - except Exception: - self.logger.warn("Failed to delete malicious message") - tracker = malicious_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - 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.post( - "https://anti-fish.bitflow.dev/check", - json={"message": match.string}, - headers={ - "Application-Name": "JARVIS", - "Application-Link": "https://git.zevaryx.com/stark-industries/jarvis", - }, - ) - if resp.status != 200: - break - data = await resp.json() - if data["match"]: - 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() - tracker = warnings_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - reasons = ", ".join(f"{m['source']}: {m['type']}" for m in data["matches"]) - embed = warning_embed(message.author, reasons) - try: - await message.channel.send(embeds=embed) - except Exception: - self.logger.warn("Failed to send warning embed") - try: - await message.delete() - except Exception: - self.logger.warn("Failed to delete malicious message") - tracker = malicious_tracker.labels( - guild_id=message.guild.id, guild_name=message.guild.name - ) - tracker.inc() - 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")) - ignore = await Setting.find_one(q(guild=after.guild.id, setting="log_ignore")) - if modlog and (ignore and after.channel.id not in ignore.value): - 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(embeds=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")) - ignore = await Setting.find_one(q(guild=message.guild.id, setting="log_ignore")) - if modlog and (ignore and message.channel.id not in ignore.value): - 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(embeds=embed) - except Exception as e: - self.logger.warning( - f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}" - ) - - async def _handle_modcase_button(self, event: Button) -> None: - context = event.context - - if not context.custom_id.startswith("modcase|"): - return # Failsafe - - if not context.deferred and not context.responded: - await context.defer(ephemeral=True) - - 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 = await Modlog.find_one( - q(user=user.id, guild=context.guild.id, open=True) - ) - if modlog: - self.logger.debug("User already has active case in guild") - await context.send( - f"User already has open case: {modlog.nanoid}", ephemeral=True - ) - else: - 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(embeds=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) - - async def _handle_delete_button(self, event: Button) -> None: - context = event.context - - if not context.custom_id.startswith("delete|"): - return # Failsafe - - if not context.deferred and not context.responded: - await context.defer(ephemeral=True) - - uid = context.custom_id.split("|")[1] - - if ( - not context.author.has_permission(Permissions.MANAGE_MESSAGES) - and not context.author.has_permission(Permissions.ADMINISTRATOR) - and not str(context.author.id) == uid - ): - await context.send("I'm afraid I can't let you do that", ephemeral=True) - return # User does not have perms to delete - - if star := await Star.find_one(q(star=context.message.id, guild=context.guild.id)): - await star.delete() - - await context.message.delete() - await context.send("Message deleted", ephemeral=True) - - async def _handle_copy_button(self, event: Button) -> None: - context = event.context - - if not context.custom_id.startswith("copy|"): - return - - if not context.deferred and not context.responded: - await context.defer(ephemeral=True) - - what, rid = context.custom_id.split("|")[1:] - if what == "rme": - reminder = await Reminder.find_one(q(_id=rid)) - if reminder: - new_reminder = Reminder( - user=context.author.id, - channel=context.channel.id, - guild=context.guild.id, - message=reminder.message, - remind_at=reminder.remind_at, - private=reminder.private, - active=reminder.active, - ) - await new_reminder.commit() - - await context.send("Reminder copied!", ephemeral=True) - - @listen() - async def on_button(self, event: Button) -> None: - """Process button events.""" - await self._handle_modcase_button(event) - await self._handle_delete_button(event) - await self._handle_copy_button(event) diff --git a/jarvis/client/__init__.py b/jarvis/client/__init__.py new file mode 100644 index 0000000..d7dac77 --- /dev/null +++ b/jarvis/client/__init__.py @@ -0,0 +1,38 @@ +"""Custom JARVIS client.""" +import logging +from typing import TYPE_CHECKING + +from jarvis_core.util.ansi import Fore, Format, fmt +from naff.models.naff.context import Context, InteractionContext, PrefixedContext +from nafftrack.client import StatsClient + +from jarvis.client.errors import ErrorMixin +from jarvis.client.events import EventMixin +from jarvis.client.tasks import TaskMixin + +if TYPE_CHECKING: + from aioredis import Redis + +KEY_FMT = fmt(Fore.GRAY) +VAL_FMT = fmt(Fore.WHITE) +CMD_FMT = fmt(Fore.GREEN, Format.BOLD) + + +class Jarvis(StatsClient, ErrorMixin, EventMixin, TaskMixin): + def __init__(self, redis: "Redis", *args, **kwargs): # noqa: ANN002 ANN003 + super().__init__(*args, **kwargs) + self.redis = redis + self.logger = logging.getLogger(__name__) + self.phishing_domains = [] + self.pre_run_callback = self._prerun + self.synced = False + + async def _prerun(self, ctx: Context, *args, **kwargs) -> None: + name = ctx.invoke_target + cargs = "" + if isinstance(ctx, InteractionContext) and ctx.target_id: + kwargs["context target"] = ctx.target + cargs = " ".join(f"{k}:{v}" for k, v in kwargs.items()) + elif isinstance(ctx, PrefixedContext): + cargs = " ".join(args) + self.logger.debug(f"Running command `{name}` with args: {cargs or 'None'}") diff --git a/jarvis/client/errors.py b/jarvis/client/errors.py new file mode 100644 index 0000000..5d15170 --- /dev/null +++ b/jarvis/client/errors.py @@ -0,0 +1,107 @@ +"""JARVIS error handling mixin.""" +import traceback +from datetime import datetime, timezone + +from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException +from naff.models.naff.context import Context, InteractionContext, PrefixedContext +from pastypy import AsyncPaste as Paste + +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} +""" + + +class ErrorMixin: + 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) diff --git a/jarvis/client/events/__init__.py b/jarvis/client/events/__init__.py new file mode 100644 index 0000000..f6f2b34 --- /dev/null +++ b/jarvis/client/events/__init__.py @@ -0,0 +1,115 @@ +"""JARVIS event mixin.""" +import asyncio + +from aiohttp import ClientSession +from jarvis_core.db import q +from jarvis_core.db.models import Reminder, Setting +from jarvis_core.util.ansi import RESET, Fore, Format, fmt +from naff import listen +from naff.models.discord.channel import DMChannel +from naff.models.discord.embed import EmbedField +from naff.models.naff.context import Context, InteractionContext, PrefixedContext + +from jarvis import const +from jarvis.client.events.components import ComponentEventMixin +from jarvis.client.events.member import MemberEventMixin +from jarvis.client.events.message import MessageEventMixin +from jarvis.tracking import jarvis_info +from jarvis.utils import build_embed + +KEY_FMT = fmt(Fore.GRAY) +VAL_FMT = fmt(Fore.WHITE) +CMD_FMT = fmt(Fore.GREEN, Format.BOLD) + + +class EventMixin(MemberEventMixin, MessageEventMixin, ComponentEventMixin): + async def _chunk_all(self) -> None: + """Chunk all guilds.""" + for guild in self.guilds: + self.logger.debug(f"Chunking guild {guild.name} <{guild.id}>") + await guild.chunk_guild() + + 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_startup(self) -> None: + """NAFF on_startup override. Prometheus info generated here.""" + jarvis_info.info({"version": const.__version__}) + + @listen() + async def on_ready(self) -> None: + """NAFF on_ready override.""" + try: + if not self.synced: + await self._sync_domains() + self._update_domains.start() + asyncio.create_task(self._chunk_all()) + self.synced = True + 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) + ) + + self.logger.debug("Hitting Reminders for faster loads") + _ = await Reminder.find().to_list(None) + + # 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")) + ignore = await Setting.find_one(q(guild=ctx.guild.id, setting="log_ignore")) + if modlog and (ignore and ctx.channel.id not in ignore.value): + 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(embeds=embed) + else: + self.logger.warning( + f"Activitylog channel no longer exists in {ctx.guild.name}, removing" + ) + await modlog.delete() diff --git a/jarvis/client/events/components.py b/jarvis/client/events/components.py new file mode 100644 index 0000000..8d33d3b --- /dev/null +++ b/jarvis/client/events/components.py @@ -0,0 +1,144 @@ +"""JARVIS component event mixin.""" +from jarvis_core.db import q +from jarvis_core.db.models import Action, Modlog, Note, Reminder, Star +from naff import listen +from naff.api.events.internal import Button +from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import Permissions + +from jarvis.utils import build_embed + + +class ComponentEventMixin: + async def _handle_modcase_button(self, event: Button) -> None: + context = event.context + + if not context.custom_id.startswith("modcase|"): + return # Failsafe + + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + + 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 = await Modlog.find_one( + q(user=user.id, guild=context.guild.id, open=True) + ) + if modlog: + self.logger.debug("User already has active case in guild") + await context.send( + f"User already has open case: {modlog.nanoid}", ephemeral=True + ) + else: + 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(embeds=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) + + async def _handle_delete_button(self, event: Button) -> None: + context = event.context + + if not context.custom_id.startswith("delete|"): + return # Failsafe + + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + + uid = context.custom_id.split("|")[1] + + if ( + not context.author.has_permission(Permissions.MANAGE_MESSAGES) + and not context.author.has_permission(Permissions.ADMINISTRATOR) + and not str(context.author.id) == uid + ): + await context.send("I'm afraid I can't let you do that", ephemeral=True) + return # User does not have perms to delete + + if star := await Star.find_one(q(star=context.message.id, guild=context.guild.id)): + await star.delete() + + await context.message.delete() + await context.send("Message deleted", ephemeral=True) + + async def _handle_copy_button(self, event: Button) -> None: + context = event.context + + if not context.custom_id.startswith("copy|"): + return + + if not context.deferred and not context.responded: + await context.defer(ephemeral=True) + + what, rid = context.custom_id.split("|")[1:] + if what == "rme": + reminder = await Reminder.find_one(q(_id=rid)) + if reminder: + new_reminder = Reminder( + user=context.author.id, + channel=context.channel.id, + guild=context.guild.id, + message=reminder.message, + remind_at=reminder.remind_at, + private=reminder.private, + active=reminder.active, + ) + await new_reminder.commit() + + await context.send("Reminder copied!", ephemeral=True) + + @listen() + async def on_button(self, event: Button) -> None: + """Process button events.""" + await self._handle_modcase_button(event) + await self._handle_delete_button(event) + await self._handle_copy_button(event) diff --git a/jarvis/client/events/member.py b/jarvis/client/events/member.py new file mode 100644 index 0000000..8c9ead4 --- /dev/null +++ b/jarvis/client/events/member.py @@ -0,0 +1,163 @@ +"""JARVIS member event mixin.""" +import asyncio + +from jarvis_core.db import q +from jarvis_core.db.models import Setting +from naff import listen +from naff.api.events.discord import MemberAdd, MemberRemove, MemberUpdate +from naff.client.utils.misc_utils import get +from naff.models.discord.embed import Embed, EmbedField +from naff.models.discord.enums import AuditLogEventType +from naff.models.discord.user import Member + +from jarvis.utils import build_embed + + +class MemberEventMixin: + # 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.value) + 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(embeds=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, + ) + 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, + ) + 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(embeds=embed) diff --git a/jarvis/client/events/message.py b/jarvis/client/events/message.py new file mode 100644 index 0000000..d9ac0eb --- /dev/null +++ b/jarvis/client/events/message.py @@ -0,0 +1,434 @@ +"""JARVIS message event mixin""" +import re +from datetime import datetime, timedelta, timezone + +from aiohttp import ClientSession +from jarvis_core.db import q +from jarvis_core.db.models import Autopurge, Autoreact, Roleping, Setting, Warning +from jarvis_core.filters import invites, url +from naff import listen +from naff.api.events.discord import MessageCreate, MessageDelete, MessageUpdate +from naff.client.utils.misc_utils import find_all +from naff.models.discord.channel import DMChannel +from naff.models.discord.embed import EmbedField +from naff.models.discord.enums import Permissions +from naff.models.discord.message import Message + +from jarvis.tracking import malicious_tracker, warnings_tracker +from jarvis.utils import build_embed +from jarvis.utils.embeds import warning_embed + + +class MessageEventMixin: + # 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: + if not message.author.has_permission(Permissions.ADMINISTRATOR): + 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.replace("\n", " ") + name = re.sub(r"<:\w+:(\d+)>", "", name) + 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + embed = warning_embed(message.author, "Sent an invite link") + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + embed = warning_embed(message.author, "Mass Mention") + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role") + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + embed = warning_embed(message.author, "Phishing URL") + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning embed") + try: + await message.delete() + except Exception: + self.logger.warn("Failed to delete malicious message") + tracker = malicious_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + 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.post( + "https://anti-fish.bitflow.dev/check", + json={"message": match.string}, + headers={ + "Application-Name": "JARVIS", + "Application-Link": "https://git.zevaryx.com/stark-industries/jarvis", + }, + ) + if resp.status != 200: + break + data = await resp.json() + if data["match"]: + 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() + tracker = warnings_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + reasons = ", ".join(f"{m['source']}: {m['type']}" for m in data["matches"]) + embed = warning_embed(message.author, reasons) + try: + await message.channel.send(embeds=embed) + except Exception: + self.logger.warn("Failed to send warning embed") + try: + await message.delete() + except Exception: + self.logger.warn("Failed to delete malicious message") + tracker = malicious_tracker.labels( + guild_id=message.guild.id, guild_name=message.guild.name + ) + tracker.inc() + 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")) + ignore = await Setting.find_one(q(guild=after.guild.id, setting="log_ignore")) + if modlog and (ignore and after.channel.id not in ignore.value): + 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(embeds=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")) + ignore = await Setting.find_one(q(guild=message.guild.id, setting="log_ignore")) + if modlog and (ignore and message.channel.id not in ignore.value): + 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(embeds=embed) + except Exception as e: + self.logger.warning( + f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}" + ) diff --git a/jarvis/client/tasks.py b/jarvis/client/tasks.py new file mode 100644 index 0000000..f8fe609 --- /dev/null +++ b/jarvis/client/tasks.py @@ -0,0 +1,34 @@ +"""JARVIS task mixin.""" +from aiohttp import ClientSession +from naff.models.naff.tasks.task import Task +from naff.models.naff.tasks.triggers import IntervalTrigger + + +class TaskMixin: + @Task.create(IntervalTrigger(minutes=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/60") + response.raise_for_status() + data = await response.json() + + self.logger.debug(f"Found {len(data)} changes to phishing domains") + if len(data) == 0: + return + + 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") From fa5f8ae8837c0ffa509e94f7c66a658510ad1b80 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Thu, 1 Sep 2022 15:16:29 -0600 Subject: [PATCH 09/24] Change embed format to include prices, add better lookup code --- jarvis/cogs/dbrand.py | 40 +-- jarvis/data/dbrand.py | 612 +++++++++++++++++++++++++----------------- 2 files changed, 386 insertions(+), 266 deletions(-) diff --git a/jarvis/cogs/dbrand.py b/jarvis/cogs/dbrand.py index c60fc43..f6c756c 100644 --- a/jarvis/cogs/dbrand.py +++ b/jarvis/cogs/dbrand.py @@ -13,6 +13,7 @@ from naff.models.naff.application_commands import ( ) from naff.models.naff.command import cooldown from naff.models.naff.cooldowns import Buckets +from thefuzz import process from jarvis.config import JarvisConfig from jarvis.data.dbrand import shipping_lookup @@ -96,7 +97,6 @@ class DbrandCog(Extension): ) @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( r"^[\U0001f1e6-\U0001f1ff]{2}$", @@ -109,12 +109,24 @@ class DbrandCog(Extension): elif search == "🏳️": search = "fr" else: - await ctx.send("Please use text to search for shipping.") + await ctx.send("Please use text to search for shipping.", ephemeral=True) return - if len(search) > 2: - matches = [x["code"] for x in shipping_lookup if search.lower() in x["country"]] - if len(matches) > 0: - search = matches[0] + if len(search) > 3: + countries = {x["country"]: x["alpha-2"] for x in shipping_lookup} + match = process.extractOne(search, countries.keys()) + if match: + search = countries[match[0]] + else: + await ctx.send(f"Unable to find country {search}", ephemeral=True) + return + elif len(search) == 3: + alpha3 = {x["alpha-3"]: x["alpha-2"] for x in shipping_lookup} + if search in alpha3: + search = alpha3[search] + else: + match = process.extractOne(search, alpha3.keys()) + search = alpha3[match[0]] + await ctx.defer() dest = search.lower() data = self.cache.get(dest, None) if not data or data["cache_expiry"] < datetime.utcnow(): @@ -129,10 +141,7 @@ class DbrandCog(Extension): fields = None if data is not None and data["is_valid"] and data["shipping_available"]: fields = [] - fields.append( - EmbedField(data["carrier"] + " " + data["tier-title"], data["time-title"]) - ) - for service in data["shipping_services_available"][1:]: + for service in data["shipping_services_available"]: service_data = self.cache.get(f"{dest}-{service}") if not service_data or service_data["cache_expiry"] < datetime.utcnow(): service_data = await self._session.get( @@ -143,12 +152,11 @@ class DbrandCog(Extension): service_data = await service_data.json() service_data["cache_expiry"] = datetime.utcnow() + timedelta(hours=24) self.cache[f"{dest}-{service}"] = service_data - fields.append( - EmbedField( - service_data["carrier"] + " " + service_data["tier-title"], - service_data["time-title"], - ) - ) + title = f'{service_data["carrier"]} {service_data["tier-title"]} | {service_data["costs-min"]}' + message = service_data["time-title"] + if service_data["free_threshold_available"]: + title += " | Free over " + service_data["free-threshold"] + fields.append(EmbedField(title, message)) country = "-".join(x for x in data["country"].split(" ") if x != "the") country_urlsafe = country.replace("-", "%20") description = ( diff --git a/jarvis/data/dbrand.py b/jarvis/data/dbrand.py index f9c4952..f45ae70 100644 --- a/jarvis/data/dbrand.py +++ b/jarvis/data/dbrand.py @@ -1,257 +1,369 @@ """dbrand-specific data.""" shipping_lookup = [ - {"country": "afghanistan", "code": "AF"}, - {"country": "albania", "code": "AL"}, - {"country": "algeria", "code": "DZ"}, - {"country": "american samoa", "code": "AS"}, - {"country": "andorra", "code": "AD"}, - {"country": "angola", "code": "AO"}, - {"country": "anguilla", "code": "AI"}, - {"country": "antarctica", "code": "AQ"}, - {"country": "antigua and barbuda", "code": "AG"}, - {"country": "argentina", "code": "AR"}, - {"country": "armenia", "code": "AM"}, - {"country": "aruba", "code": "AW"}, - {"country": "australia", "code": "AU"}, - {"country": "austria", "code": "AT"}, - {"country": "azerbaijan", "code": "AZ"}, - {"country": "bahamas (the)", "code": "BS"}, - {"country": "bahrain", "code": "BH"}, - {"country": "bangladesh", "code": "BD"}, - {"country": "barbados", "code": "BB"}, - {"country": "belarus", "code": "BY"}, - {"country": "belgium", "code": "BE"}, - {"country": "belize", "code": "BZ"}, - {"country": "benin", "code": "BJ"}, - {"country": "bermuda", "code": "BM"}, - {"country": "bhutan", "code": "BT"}, - {"country": "bolivia (plurinational state of)", "code": "BO"}, - {"country": "bonaire, sint eustatius and saba", "code": "BQ"}, - {"country": "bosnia and herzegovina", "code": "BA"}, - {"country": "botswana", "code": "BW"}, - {"country": "bouvet island", "code": "BV"}, - {"country": "brazil", "code": "BR"}, - {"country": "british indian ocean territory (the)", "code": "IO"}, - {"country": "brunei darussalam", "code": "BN"}, - {"country": "bulgaria", "code": "BG"}, - {"country": "burkina faso", "code": "BF"}, - {"country": "burundi", "code": "BI"}, - {"country": "cabo verde", "code": "CV"}, - {"country": "cambodia", "code": "KH"}, - {"country": "cameroon", "code": "CM"}, - {"country": "canada", "code": "CA"}, - {"country": "cayman islands (the)", "code": "KY"}, - {"country": "central african republic (the)", "code": "CF"}, - {"country": "chad", "code": "TD"}, - {"country": "chile", "code": "CL"}, - {"country": "china", "code": "CN"}, - {"country": "christmas island", "code": "CX"}, - {"country": "cocos (keeling) islands (the)", "code": "CC"}, - {"country": "colombia", "code": "CO"}, - {"country": "comoros (the)", "code": "KM"}, - {"country": "congo (the democratic republic of the)", "code": "CD"}, - {"country": "congo (the)", "code": "CG"}, - {"country": "cook islands (the)", "code": "CK"}, - {"country": "costa rica", "code": "CR"}, - {"country": "croatia", "code": "HR"}, - {"country": "cuba", "code": "CU"}, - {"country": "curaçao", "code": "CW"}, - {"country": "cyprus", "code": "CY"}, - {"country": "czechia", "code": "CZ"}, - {"country": "côte d'ivoire", "code": "CI"}, - {"country": "denmark", "code": "DK"}, - {"country": "djibouti", "code": "DJ"}, - {"country": "dominica", "code": "DM"}, - {"country": "dominican republic (the)", "code": "DO"}, - {"country": "ecuador", "code": "EC"}, - {"country": "egypt", "code": "EG"}, - {"country": "el salvador", "code": "SV"}, - {"country": "equatorial guinea", "code": "GQ"}, - {"country": "eritrea", "code": "ER"}, - {"country": "estonia", "code": "EE"}, - {"country": "eswatini", "code": "SZ"}, - {"country": "ethiopia", "code": "ET"}, - {"country": "falkland islands (the) [malvinas]", "code": "FK"}, - {"country": "faroe islands (the)", "code": "FO"}, - {"country": "fiji", "code": "FJ"}, - {"country": "finland", "code": "FI"}, - {"country": "france", "code": "FR"}, - {"country": "french guiana", "code": "GF"}, - {"country": "french polynesia", "code": "PF"}, - {"country": "french southern territories (the)", "code": "TF"}, - {"country": "gabon", "code": "GA"}, - {"country": "gambia (the)", "code": "GM"}, - {"country": "georgia", "code": "GE"}, - {"country": "germany", "code": "DE"}, - {"country": "ghana", "code": "GH"}, - {"country": "gibraltar", "code": "GI"}, - {"country": "greece", "code": "GR"}, - {"country": "greenland", "code": "GL"}, - {"country": "grenada", "code": "GD"}, - {"country": "guadeloupe", "code": "GP"}, - {"country": "guam", "code": "GU"}, - {"country": "guatemala", "code": "GT"}, - {"country": "guernsey", "code": "GG"}, - {"country": "guinea", "code": "GN"}, - {"country": "guinea-bissau", "code": "GW"}, - {"country": "guyana", "code": "GY"}, - {"country": "haiti", "code": "HT"}, - {"country": "heard island and mcdonald islands", "code": "HM"}, - {"country": "holy see (the)", "code": "VA"}, - {"country": "honduras", "code": "HN"}, - {"country": "hong kong", "code": "HK"}, - {"country": "hungary", "code": "HU"}, - {"country": "iceland", "code": "IS"}, - {"country": "india", "code": "IN"}, - {"country": "indonesia", "code": "ID"}, - {"country": "iran (islamic republic of)", "code": "IR"}, - {"country": "iraq", "code": "IQ"}, - {"country": "ireland", "code": "IE"}, - {"country": "isle of man", "code": "IM"}, - {"country": "israel", "code": "IL"}, - {"country": "italy", "code": "IT"}, - {"country": "jamaica", "code": "JM"}, - {"country": "japan", "code": "JP"}, - {"country": "jersey", "code": "JE"}, - {"country": "jordan", "code": "JO"}, - {"country": "kazakhstan", "code": "KZ"}, - {"country": "kenya", "code": "KE"}, - {"country": "kiribati", "code": "KI"}, - {"country": "korea (the democratic people's republic of)", "code": "KP"}, - {"country": "korea (the republic of)", "code": "KR"}, - {"country": "kuwait", "code": "KW"}, - {"country": "kyrgyzstan", "code": "KG"}, - {"country": "lao people's democratic republic (the)", "code": "LA"}, - {"country": "latvia", "code": "LV"}, - {"country": "lebanon", "code": "LB"}, - {"country": "lesotho", "code": "LS"}, - {"country": "liberia", "code": "LR"}, - {"country": "libya", "code": "LY"}, - {"country": "liechtenstein", "code": "LI"}, - {"country": "lithuania", "code": "LT"}, - {"country": "luxembourg", "code": "LU"}, - {"country": "macao", "code": "MO"}, - {"country": "madagascar", "code": "MG"}, - {"country": "malawi", "code": "MW"}, - {"country": "malaysia", "code": "MY"}, - {"country": "maldives", "code": "MV"}, - {"country": "mali", "code": "ML"}, - {"country": "malta", "code": "MT"}, - {"country": "marshall islands (the)", "code": "MH"}, - {"country": "martinique", "code": "MQ"}, - {"country": "mauritania", "code": "MR"}, - {"country": "mauritius", "code": "MU"}, - {"country": "mayotte", "code": "YT"}, - {"country": "mexico", "code": "MX"}, - {"country": "micronesia (federated states of)", "code": "FM"}, - {"country": "moldova (the republic of)", "code": "MD"}, - {"country": "monaco", "code": "MC"}, - {"country": "mongolia", "code": "MN"}, - {"country": "montenegro", "code": "ME"}, - {"country": "montserrat", "code": "MS"}, - {"country": "morocco", "code": "MA"}, - {"country": "mozambique", "code": "MZ"}, - {"country": "myanmar", "code": "MM"}, - {"country": "namibia", "code": "NA"}, - {"country": "nauru", "code": "NR"}, - {"country": "nepal", "code": "NP"}, - {"country": "netherlands (the)", "code": "NL"}, - {"country": "new caledonia", "code": "NC"}, - {"country": "new zealand", "code": "NZ"}, - {"country": "nicaragua", "code": "NI"}, - {"country": "niger (the)", "code": "NE"}, - {"country": "nigeria", "code": "NG"}, - {"country": "niue", "code": "NU"}, - {"country": "norfolk island", "code": "NF"}, - {"country": "northern mariana islands (the)", "code": "MP"}, - {"country": "norway", "code": "NO"}, - {"country": "oman", "code": "OM"}, - {"country": "pakistan", "code": "PK"}, - {"country": "palau", "code": "PW"}, - {"country": "palestine, state of", "code": "PS"}, - {"country": "panama", "code": "PA"}, - {"country": "papua new guinea", "code": "PG"}, - {"country": "paraguay", "code": "PY"}, - {"country": "peru", "code": "PE"}, - {"country": "philippines (the)", "code": "PH"}, - {"country": "pitcairn", "code": "PN"}, - {"country": "poland", "code": "PL"}, - {"country": "portugal", "code": "PT"}, - {"country": "puerto rico", "code": "PR"}, - {"country": "qatar", "code": "QA"}, - {"country": "republic of north macedonia", "code": "MK"}, - {"country": "romania", "code": "RO"}, - {"country": "russian federation (the)", "code": "RU"}, - {"country": "rwanda", "code": "RW"}, - {"country": "réunion", "code": "RE"}, - {"country": "saint barthélemy", "code": "BL"}, - {"country": "saint helena, ascension and tristan da cunha", "code": "SH"}, - {"country": "saint kitts and nevis", "code": "KN"}, - {"country": "saint lucia", "code": "LC"}, - {"country": "saint martin (french part)", "code": "MF"}, - {"country": "saint pierre and miquelon", "code": "PM"}, - {"country": "saint vincent and the grenadines", "code": "VC"}, - {"country": "samoa", "code": "WS"}, - {"country": "san marino", "code": "SM"}, - {"country": "sao tome and principe", "code": "ST"}, - {"country": "saudi arabia", "code": "SA"}, - {"country": "senegal", "code": "SN"}, - {"country": "serbia", "code": "RS"}, - {"country": "seychelles", "code": "SC"}, - {"country": "sierra leone", "code": "SL"}, - {"country": "singapore", "code": "SG"}, - {"country": "sint maarten (dutch part)", "code": "SX"}, - {"country": "slovakia", "code": "SK"}, - {"country": "slovenia", "code": "SI"}, - {"country": "solomon islands", "code": "SB"}, - {"country": "somalia", "code": "SO"}, - {"country": "south africa", "code": "ZA"}, - {"country": "south georgia and the south sandwich islands", "code": "GS"}, - {"country": "south sudan", "code": "SS"}, - {"country": "spain", "code": "ES"}, - {"country": "sri lanka", "code": "LK"}, - {"country": "sudan (the)", "code": "SD"}, - {"country": "suriname", "code": "SR"}, - {"country": "svalbard and jan mayen", "code": "SJ"}, - {"country": "sweden", "code": "SE"}, - {"country": "switzerland", "code": "CH"}, - {"country": "syrian arab republic", "code": "SY"}, - {"country": "taiwan", "code": "TW"}, - {"country": "tajikistan", "code": "TJ"}, - {"country": "tanzania, united republic of", "code": "TZ"}, - {"country": "thailand", "code": "TH"}, - {"country": "timor-leste", "code": "TL"}, - {"country": "togo", "code": "TG"}, - {"country": "tokelau", "code": "TK"}, - {"country": "tonga", "code": "TO"}, - {"country": "trinidad and tobago", "code": "TT"}, - {"country": "tunisia", "code": "TN"}, - {"country": "turkey", "code": "TR"}, - {"country": "turkmenistan", "code": "TM"}, - {"country": "turks and caicos islands (the)", "code": "TC"}, - {"country": "tuvalu", "code": "TV"}, - {"country": "uganda", "code": "UG"}, - {"country": "ukraine", "code": "UA"}, - {"country": "united arab emirates (the)", "code": "AE"}, + {"country": "Afghanistan", "alpha-2": "AF", "alpha-3": "AFG", "numeric": "0004"}, + {"country": "Ã…land Islands", "alpha-2": "AX", "alpha-3": "ALA", "numeric": "0248"}, + {"country": "Albania", "alpha-2": "AL", "alpha-3": "ALB", "numeric": "0008"}, + {"country": "Algeria", "alpha-2": "DZ", "alpha-3": "DZA", "numeric": "0012"}, + {"country": "American Samoa", "alpha-2": "AS", "alpha-3": "ASM", "numeric": "0016"}, + {"country": "Andorra", "alpha-2": "AD", "alpha-3": "AND", "numeric": "0020"}, + {"country": "Angola", "alpha-2": "AO", "alpha-3": "AGO", "numeric": "0024"}, + {"country": "Anguilla", "alpha-2": "AI", "alpha-3": "AIA", "numeric": "0660"}, + {"country": "Antarctica", "alpha-2": "AQ", "alpha-3": "ATA", "numeric": "0010"}, + {"country": "Antigua and Barbuda", "alpha-2": "AG", "alpha-3": "ATG", "numeric": "0028"}, + {"country": "Argentina", "alpha-2": "AR", "alpha-3": "ARG", "numeric": "0032"}, + {"country": "Armenia", "alpha-2": "AM", "alpha-3": "ARM", "numeric": "0051"}, + {"country": "Aruba", "alpha-2": "AW", "alpha-3": "ABW", "numeric": "0533"}, + {"country": "Australia", "alpha-2": "AU", "alpha-3": "AUS", "numeric": "0036"}, + {"country": "Austria", "alpha-2": "AT", "alpha-3": "AUT", "numeric": "0040"}, + {"country": "Azerbaijan", "alpha-2": "AZ", "alpha-3": "AZE", "numeric": "0031"}, + {"country": "Bahamas (the)", "alpha-2": "BS", "alpha-3": "BHS", "numeric": "0044"}, + {"country": "Bahrain", "alpha-2": "BH", "alpha-3": "BHR", "numeric": "0048"}, + {"country": "Bangladesh", "alpha-2": "BD", "alpha-3": "BGD", "numeric": "0050"}, + {"country": "Barbados", "alpha-2": "BB", "alpha-3": "BRB", "numeric": "0052"}, + {"country": "Belarus", "alpha-2": "BY", "alpha-3": "BLR", "numeric": "0112"}, + {"country": "Belgium", "alpha-2": "BE", "alpha-3": "BEL", "numeric": "0056"}, + {"country": "Belize", "alpha-2": "BZ", "alpha-3": "BLZ", "numeric": "0084"}, + {"country": "Benin", "alpha-2": "BJ", "alpha-3": "BEN", "numeric": "0204"}, + {"country": "Bermuda", "alpha-2": "BM", "alpha-3": "BMU", "numeric": "0060"}, + {"country": "Bhutan", "alpha-2": "BT", "alpha-3": "BTN", "numeric": "0064"}, { - "country": "united kingdom of great britain and northern ireland (the)", - "code": "GB", + "country": "Bolivia (Plurinational State of)", + "alpha-2": "BO", + "alpha-3": "BOL", + "numeric": "0068", }, - {"country": "the united states of america", "code": "US"}, - {"country": "united states minor outlying islands (the)", "code": "UM"}, - {"country": "uruguay", "code": "UY"}, - {"country": "uzbekistan", "code": "UZ"}, - {"country": "vanuatu", "code": "VU"}, - {"country": "venezuela (bolivarian republic of)", "code": "VE"}, - {"country": "viet nam", "code": "VN"}, - {"country": "virgin islands (british)", "code": "VG"}, - {"country": "virgin islands (u.s.)", "code": "VI"}, - {"country": "wallis and futuna", "code": "WF"}, - {"country": "western sahara", "code": "EH"}, - {"country": "yemen", "code": "YE"}, - {"country": "zambia", "code": "ZM"}, - {"country": "zimbabwe", "code": "ZW"}, - {"country": "åland islands", "code": "AX"}, + { + "country": "Bonaire, Sint Eustatius and Saba", + "alpha-2": "BQ", + "alpha-3": "BES", + "numeric": "0535", + }, + {"country": "Bosnia and Herzegovina", "alpha-2": "BA", "alpha-3": "BIH", "numeric": "0070"}, + {"country": "Botswana", "alpha-2": "BW", "alpha-3": "BWA", "numeric": "0072"}, + {"country": "Bouvet Island", "alpha-2": "BV", "alpha-3": "BVT", "numeric": "0074"}, + {"country": "Brazil", "alpha-2": "BR", "alpha-3": "BRA", "numeric": "0076"}, + { + "country": "British Indian Ocean Territory (the)", + "alpha-2": "IO", + "alpha-3": "IOT", + "numeric": "0086", + }, + {"country": "Brunei Darussalam", "alpha-2": "BN", "alpha-3": "BRN", "numeric": "0096"}, + {"country": "Bulgaria", "alpha-2": "BG", "alpha-3": "BGR", "numeric": "0100"}, + {"country": "Burkina Faso", "alpha-2": "BF", "alpha-3": "BFA", "numeric": "0854"}, + {"country": "Burundi", "alpha-2": "BI", "alpha-3": "BDI", "numeric": "0108"}, + {"country": "Cabo Verde", "alpha-2": "CV", "alpha-3": "CPV", "numeric": "0132"}, + {"country": "Cambodia", "alpha-2": "KH", "alpha-3": "KHM", "numeric": "0116"}, + {"country": "Cameroon", "alpha-2": "CM", "alpha-3": "CMR", "numeric": "0120"}, + {"country": "Canada", "alpha-2": "CA", "alpha-3": "CAN", "numeric": "0124"}, + {"country": "Cayman Islands (the)", "alpha-2": "KY", "alpha-3": "CYM", "numeric": "0136"}, + { + "country": "Central African Republic (the)", + "alpha-2": "CF", + "alpha-3": "CAF", + "numeric": "0140", + }, + {"country": "Chad", "alpha-2": "TD", "alpha-3": "TCD", "numeric": "0148"}, + {"country": "Chile", "alpha-2": "CL", "alpha-3": "CHL", "numeric": "0152"}, + {"country": "China", "alpha-2": "CN", "alpha-3": "CHN", "numeric": "0156"}, + {"country": "Christmas Island", "alpha-2": "CX", "alpha-3": "CXR", "numeric": "0162"}, + { + "country": "Cocos (Keeling) Islands (the)", + "alpha-2": "CC", + "alpha-3": "CCK", + "numeric": "0166", + }, + {"country": "Colombia", "alpha-2": "CO", "alpha-3": "COL", "numeric": "0170"}, + {"country": "Comoros (the)", "alpha-2": "KM", "alpha-3": "COM", "numeric": "0174"}, + { + "country": "Congo (the Democratic Republic of the)", + "alpha-2": "CD", + "alpha-3": "COD", + "numeric": "0180", + }, + {"country": "Congo (the)", "alpha-2": "CG", "alpha-3": "COG", "numeric": "0178"}, + {"country": "Cook Islands (the)", "alpha-2": "CK", "alpha-3": "COK", "numeric": "0184"}, + {"country": "Costa Rica", "alpha-2": "CR", "alpha-3": "CRI", "numeric": "0188"}, + {"country": "Côte d'Ivoire", "alpha-2": "CI", "alpha-3": "CIV", "numeric": "0384"}, + {"country": "Croatia", "alpha-2": "HR", "alpha-3": "HRV", "numeric": "0191"}, + {"country": "Cuba", "alpha-2": "CU", "alpha-3": "CUB", "numeric": "0192"}, + {"country": "Curaçao", "alpha-2": "CW", "alpha-3": "CUW", "numeric": "0531"}, + {"country": "Cyprus", "alpha-2": "CY", "alpha-3": "CYP", "numeric": "0196"}, + {"country": "Czechia", "alpha-2": "CZ", "alpha-3": "CZE", "numeric": "0203"}, + {"country": "Denmark", "alpha-2": "DK", "alpha-3": "DNK", "numeric": "0208"}, + {"country": "Djibouti", "alpha-2": "DJ", "alpha-3": "DJI", "numeric": "0262"}, + {"country": "Dominica", "alpha-2": "DM", "alpha-3": "DMA", "numeric": "0212"}, + {"country": "Dominican Republic (the)", "alpha-2": "DO", "alpha-3": "DOM", "numeric": "0214"}, + {"country": "Ecuador", "alpha-2": "EC", "alpha-3": "ECU", "numeric": "0218"}, + {"country": "Egypt", "alpha-2": "EG", "alpha-3": "EGY", "numeric": "0818"}, + {"country": "El Salvador", "alpha-2": "SV", "alpha-3": "SLV", "numeric": "0222"}, + {"country": "Equatorial Guinea", "alpha-2": "GQ", "alpha-3": "GNQ", "numeric": "0226"}, + {"country": "Eritrea", "alpha-2": "ER", "alpha-3": "ERI", "numeric": "0232"}, + {"country": "Estonia", "alpha-2": "EE", "alpha-3": "EST", "numeric": "0233"}, + {"country": "Eswatini", "alpha-2": "SZ", "alpha-3": "SWZ", "numeric": "0748"}, + {"country": "Ethiopia", "alpha-2": "ET", "alpha-3": "ETH", "numeric": "0231"}, + { + "country": "Falkland Islands (the) [Malvinas]", + "alpha-2": "FK", + "alpha-3": "FLK", + "numeric": "0238", + }, + {"country": "Faroe Islands (the)", "alpha-2": "FO", "alpha-3": "FRO", "numeric": "0234"}, + {"country": "Fiji", "alpha-2": "FJ", "alpha-3": "FJI", "numeric": "0242"}, + {"country": "Finland", "alpha-2": "FI", "alpha-3": "FIN", "numeric": "0246"}, + {"country": "France", "alpha-2": "FR", "alpha-3": "FRA", "numeric": "0250"}, + {"country": "French Guiana", "alpha-2": "GF", "alpha-3": "GUF", "numeric": "0254"}, + {"country": "French Polynesia", "alpha-2": "PF", "alpha-3": "PYF", "numeric": "0258"}, + { + "country": "French Southern Territories (the)", + "alpha-2": "TF", + "alpha-3": "ATF", + "numeric": "0260", + }, + {"country": "Gabon", "alpha-2": "GA", "alpha-3": "GAB", "numeric": "0266"}, + {"country": "Gambia (the)", "alpha-2": "GM", "alpha-3": "GMB", "numeric": "0270"}, + {"country": "Georgia", "alpha-2": "GE", "alpha-3": "GEO", "numeric": "0268"}, + {"country": "Germany", "alpha-2": "DE", "alpha-3": "DEU", "numeric": "0276"}, + {"country": "Ghana", "alpha-2": "GH", "alpha-3": "GHA", "numeric": "0288"}, + {"country": "Gibraltar", "alpha-2": "GI", "alpha-3": "GIB", "numeric": "0292"}, + {"country": "Greece", "alpha-2": "GR", "alpha-3": "GRC", "numeric": "0300"}, + {"country": "Greenland", "alpha-2": "GL", "alpha-3": "GRL", "numeric": "0304"}, + {"country": "Grenada", "alpha-2": "GD", "alpha-3": "GRD", "numeric": "0308"}, + {"country": "Guadeloupe", "alpha-2": "GP", "alpha-3": "GLP", "numeric": "0312"}, + {"country": "Guam", "alpha-2": "GU", "alpha-3": "GUM", "numeric": "0316"}, + {"country": "Guatemala", "alpha-2": "GT", "alpha-3": "GTM", "numeric": "0320"}, + {"country": "Guernsey", "alpha-2": "GG", "alpha-3": "GGY", "numeric": "0831"}, + {"country": "Guinea", "alpha-2": "GN", "alpha-3": "GIN", "numeric": "0324"}, + {"country": "Guinea-Bissau", "alpha-2": "GW", "alpha-3": "GNB", "numeric": "0624"}, + {"country": "Guyana", "alpha-2": "GY", "alpha-3": "GUY", "numeric": "0328"}, + {"country": "Haiti", "alpha-2": "HT", "alpha-3": "HTI", "numeric": "0332"}, + { + "country": "Heard Island and McDonald Islands", + "alpha-2": "HM", + "alpha-3": "HMD", + "numeric": "0334", + }, + {"country": "Holy See (the)", "alpha-2": "VA", "alpha-3": "VAT", "numeric": "0336"}, + {"country": "Honduras", "alpha-2": "HN", "alpha-3": "HND", "numeric": "0340"}, + {"country": "Hong Kong", "alpha-2": "HK", "alpha-3": "HKG", "numeric": "0344"}, + {"country": "Hungary", "alpha-2": "HU", "alpha-3": "HUN", "numeric": "0348"}, + {"country": "Iceland", "alpha-2": "IS", "alpha-3": "ISL", "numeric": "0352"}, + {"country": "India", "alpha-2": "IN", "alpha-3": "IND", "numeric": "0356"}, + {"country": "Indonesia", "alpha-2": "ID", "alpha-3": "IDN", "numeric": "0360"}, + {"country": "Iran (Islamic Republic of)", "alpha-2": "IR", "alpha-3": "IRN", "numeric": "0364"}, + {"country": "Iraq", "alpha-2": "IQ", "alpha-3": "IRQ", "numeric": "0368"}, + {"country": "Ireland", "alpha-2": "IE", "alpha-3": "IRL", "numeric": "0372"}, + {"country": "Isle of Man", "alpha-2": "IM", "alpha-3": "IMN", "numeric": "0833"}, + {"country": "Israel", "alpha-2": "IL", "alpha-3": "ISR", "numeric": "0376"}, + {"country": "Italy", "alpha-2": "IT", "alpha-3": "ITA", "numeric": "0380"}, + {"country": "Jamaica", "alpha-2": "JM", "alpha-3": "JAM", "numeric": "0388"}, + {"country": "Japan", "alpha-2": "JP", "alpha-3": "JPN", "numeric": "0392"}, + {"country": "Jersey", "alpha-2": "JE", "alpha-3": "JEY", "numeric": "0832"}, + {"country": "Jordan", "alpha-2": "JO", "alpha-3": "JOR", "numeric": "0400"}, + {"country": "Kazakhstan", "alpha-2": "KZ", "alpha-3": "KAZ", "numeric": "0398"}, + {"country": "Kenya", "alpha-2": "KE", "alpha-3": "KEN", "numeric": "0404"}, + {"country": "Kiribati", "alpha-2": "KI", "alpha-3": "KIR", "numeric": "0296"}, + { + "country": "Korea (the Democratic People's Republic of)", + "alpha-2": "KP", + "alpha-3": "PRK", + "numeric": "0408", + }, + {"country": "Korea (the Republic of)", "alpha-2": "KR", "alpha-3": "KOR", "numeric": "0410"}, + {"country": "Kuwait", "alpha-2": "KW", "alpha-3": "KWT", "numeric": "0414"}, + {"country": "Kyrgyzstan", "alpha-2": "KG", "alpha-3": "KGZ", "numeric": "0417"}, + { + "country": "Lao People's Democratic Republic (the)", + "alpha-2": "LA", + "alpha-3": "LAO", + "numeric": "0418", + }, + {"country": "Latvia", "alpha-2": "LV", "alpha-3": "LVA", "numeric": "0428"}, + {"country": "Lebanon", "alpha-2": "LB", "alpha-3": "LBN", "numeric": "0422"}, + {"country": "Lesotho", "alpha-2": "LS", "alpha-3": "LSO", "numeric": "0426"}, + {"country": "Liberia", "alpha-2": "LR", "alpha-3": "LBR", "numeric": "0430"}, + {"country": "Libya", "alpha-2": "LY", "alpha-3": "LBY", "numeric": "0434"}, + {"country": "Liechtenstein", "alpha-2": "LI", "alpha-3": "LIE", "numeric": "0438"}, + {"country": "Lithuania", "alpha-2": "LT", "alpha-3": "LTU", "numeric": "0440"}, + {"country": "Luxembourg", "alpha-2": "LU", "alpha-3": "LUX", "numeric": "0442"}, + {"country": "Macao", "alpha-2": "MO", "alpha-3": "MAC", "numeric": "0446"}, + { + "country": "Republic of North Macedonia", + "alpha-2": "MK", + "alpha-3": "MKD", + "numeric": "0807", + }, + {"country": "Madagascar", "alpha-2": "MG", "alpha-3": "MDG", "numeric": "0450"}, + {"country": "Malawi", "alpha-2": "MW", "alpha-3": "MWI", "numeric": "0454"}, + {"country": "Malaysia", "alpha-2": "MY", "alpha-3": "MYS", "numeric": "0458"}, + {"country": "Maldives", "alpha-2": "MV", "alpha-3": "MDV", "numeric": "0462"}, + {"country": "Mali", "alpha-2": "ML", "alpha-3": "MLI", "numeric": "0466"}, + {"country": "Malta", "alpha-2": "MT", "alpha-3": "MLT", "numeric": "0470"}, + {"country": "Marshall Islands (the)", "alpha-2": "MH", "alpha-3": "MHL", "numeric": "0584"}, + {"country": "Martinique", "alpha-2": "MQ", "alpha-3": "MTQ", "numeric": "0474"}, + {"country": "Mauritania", "alpha-2": "MR", "alpha-3": "MRT", "numeric": "0478"}, + {"country": "Mauritius", "alpha-2": "MU", "alpha-3": "MUS", "numeric": "0480"}, + {"country": "Mayotte", "alpha-2": "YT", "alpha-3": "MYT", "numeric": "0175"}, + {"country": "Mexico", "alpha-2": "MX", "alpha-3": "MEX", "numeric": "0484"}, + { + "country": "Micronesia (Federated States of)", + "alpha-2": "FM", + "alpha-3": "FSM", + "numeric": "0583", + }, + {"country": "Moldova (the Republic of)", "alpha-2": "MD", "alpha-3": "MDA", "numeric": "0498"}, + {"country": "Monaco", "alpha-2": "MC", "alpha-3": "MCO", "numeric": "0492"}, + {"country": "Mongolia", "alpha-2": "MN", "alpha-3": "MNG", "numeric": "0496"}, + {"country": "Montenegro", "alpha-2": "ME", "alpha-3": "MNE", "numeric": "0499"}, + {"country": "Montserrat", "alpha-2": "MS", "alpha-3": "MSR", "numeric": "0500"}, + {"country": "Morocco", "alpha-2": "MA", "alpha-3": "MAR", "numeric": "0504"}, + {"country": "Mozambique", "alpha-2": "MZ", "alpha-3": "MOZ", "numeric": "0508"}, + {"country": "Myanmar", "alpha-2": "MM", "alpha-3": "MMR", "numeric": "0104"}, + {"country": "Namibia", "alpha-2": "NA", "alpha-3": "NAM", "numeric": "0516"}, + {"country": "Nauru", "alpha-2": "NR", "alpha-3": "NRU", "numeric": "0520"}, + {"country": "Nepal", "alpha-2": "NP", "alpha-3": "NPL", "numeric": "0524"}, + {"country": "Netherlands (the)", "alpha-2": "NL", "alpha-3": "NLD", "numeric": "0528"}, + {"country": "New Caledonia", "alpha-2": "NC", "alpha-3": "NCL", "numeric": "0540"}, + {"country": "New Zealand", "alpha-2": "NZ", "alpha-3": "NZL", "numeric": "0554"}, + {"country": "Nicaragua", "alpha-2": "NI", "alpha-3": "NIC", "numeric": "0558"}, + {"country": "Niger (the)", "alpha-2": "NE", "alpha-3": "NER", "numeric": "0562"}, + {"country": "Nigeria", "alpha-2": "NG", "alpha-3": "NGA", "numeric": "0566"}, + {"country": "Niue", "alpha-2": "NU", "alpha-3": "NIU", "numeric": "0570"}, + {"country": "Norfolk Island", "alpha-2": "NF", "alpha-3": "NFK", "numeric": "0574"}, + { + "country": "Northern Mariana Islands (the)", + "alpha-2": "MP", + "alpha-3": "MNP", + "numeric": "0580", + }, + {"country": "Norway", "alpha-2": "NO", "alpha-3": "NOR", "numeric": "0578"}, + {"country": "Oman", "alpha-2": "OM", "alpha-3": "OMN", "numeric": "0512"}, + {"country": "Pakistan", "alpha-2": "PK", "alpha-3": "PAK", "numeric": "0586"}, + {"country": "Palau", "alpha-2": "PW", "alpha-3": "PLW", "numeric": "0585"}, + {"country": "Palestine, State of", "alpha-2": "PS", "alpha-3": "PSE", "numeric": "0275"}, + {"country": "Panama", "alpha-2": "PA", "alpha-3": "PAN", "numeric": "0591"}, + {"country": "Papua New Guinea", "alpha-2": "PG", "alpha-3": "PNG", "numeric": "0598"}, + {"country": "Paraguay", "alpha-2": "PY", "alpha-3": "PRY", "numeric": "0600"}, + {"country": "Peru", "alpha-2": "PE", "alpha-3": "PER", "numeric": "0604"}, + {"country": "Philippines (the)", "alpha-2": "PH", "alpha-3": "PHL", "numeric": "0608"}, + {"country": "Pitcairn", "alpha-2": "PN", "alpha-3": "PCN", "numeric": "0612"}, + {"country": "Poland", "alpha-2": "PL", "alpha-3": "POL", "numeric": "0616"}, + {"country": "Portugal", "alpha-2": "PT", "alpha-3": "PRT", "numeric": "0620"}, + {"country": "Puerto Rico", "alpha-2": "PR", "alpha-3": "PRI", "numeric": "0630"}, + {"country": "Qatar", "alpha-2": "QA", "alpha-3": "QAT", "numeric": "0634"}, + {"country": "Réunion", "alpha-2": "RE", "alpha-3": "REU", "numeric": "0638"}, + {"country": "Romania", "alpha-2": "RO", "alpha-3": "ROU", "numeric": "0642"}, + {"country": "Russian Federation (the)", "alpha-2": "RU", "alpha-3": "RUS", "numeric": "0643"}, + {"country": "Rwanda", "alpha-2": "RW", "alpha-3": "RWA", "numeric": "0646"}, + {"country": "Saint Barthélemy", "alpha-2": "BL", "alpha-3": "BLM", "numeric": "0652"}, + { + "country": "Saint Helena, Ascension and Tristan da Cunha", + "alpha-2": "SH", + "alpha-3": "SHN", + "numeric": "0654", + }, + {"country": "Saint Kitts and Nevis", "alpha-2": "KN", "alpha-3": "KNA", "numeric": "0659"}, + {"country": "Saint Lucia", "alpha-2": "LC", "alpha-3": "LCA", "numeric": "0662"}, + {"country": "Saint Martin (French part)", "alpha-2": "MF", "alpha-3": "MAF", "numeric": "0663"}, + {"country": "Saint Pierre and Miquelon", "alpha-2": "PM", "alpha-3": "SPM", "numeric": "0666"}, + { + "country": "Saint Vincent and the Grenadines", + "alpha-2": "VC", + "alpha-3": "VCT", + "numeric": "0670", + }, + {"country": "Samoa", "alpha-2": "WS", "alpha-3": "WSM", "numeric": "0882"}, + {"country": "San Marino", "alpha-2": "SM", "alpha-3": "SMR", "numeric": "0674"}, + {"country": "Sao Tome and Principe", "alpha-2": "ST", "alpha-3": "STP", "numeric": "0678"}, + {"country": "Saudi Arabia", "alpha-2": "SA", "alpha-3": "SAU", "numeric": "0682"}, + {"country": "Senegal", "alpha-2": "SN", "alpha-3": "SEN", "numeric": "0686"}, + {"country": "Serbia", "alpha-2": "RS", "alpha-3": "SRB", "numeric": "0688"}, + {"country": "Seychelles", "alpha-2": "SC", "alpha-3": "SYC", "numeric": "0690"}, + {"country": "Sierra Leone", "alpha-2": "SL", "alpha-3": "SLE", "numeric": "0694"}, + {"country": "Singapore", "alpha-2": "SG", "alpha-3": "SGP", "numeric": "0702"}, + {"country": "Sint Maarten (Dutch part)", "alpha-2": "SX", "alpha-3": "SXM", "numeric": "0534"}, + {"country": "Slovakia", "alpha-2": "SK", "alpha-3": "SVK", "numeric": "0703"}, + {"country": "Slovenia", "alpha-2": "SI", "alpha-3": "SVN", "numeric": "0705"}, + {"country": "Solomon Islands", "alpha-2": "SB", "alpha-3": "SLB", "numeric": "0090"}, + {"country": "Somalia", "alpha-2": "SO", "alpha-3": "SOM", "numeric": "0706"}, + {"country": "South Africa", "alpha-2": "ZA", "alpha-3": "ZAF", "numeric": "0710"}, + { + "country": "South Georgia and the South Sandwich Islands", + "alpha-2": "GS", + "alpha-3": "SGS", + "numeric": "0239", + }, + {"country": "South Sudan", "alpha-2": "SS", "alpha-3": "SSD", "numeric": "0728"}, + {"country": "Spain", "alpha-2": "ES", "alpha-3": "ESP", "numeric": "0724"}, + {"country": "Sri Lanka", "alpha-2": "LK", "alpha-3": "LKA", "numeric": "0144"}, + {"country": "Sudan (the)", "alpha-2": "SD", "alpha-3": "SDN", "numeric": "0729"}, + {"country": "Suriname", "alpha-2": "SR", "alpha-3": "SUR", "numeric": "0740"}, + {"country": "Svalbard and Jan Mayen", "alpha-2": "SJ", "alpha-3": "SJM", "numeric": "0744"}, + {"country": "Sweden", "alpha-2": "SE", "alpha-3": "SWE", "numeric": "0752"}, + {"country": "Switzerland", "alpha-2": "CH", "alpha-3": "CHE", "numeric": "0756"}, + {"country": "Syrian Arab Republic", "alpha-2": "SY", "alpha-3": "SYR", "numeric": "0760"}, + {"country": "Taiwan (Province of China)", "alpha-2": "TW", "alpha-3": "TWN", "numeric": "0158"}, + {"country": "Tajikistan", "alpha-2": "TJ", "alpha-3": "TJK", "numeric": "0762"}, + { + "country": "Tanzania, United Republic of", + "alpha-2": "TZ", + "alpha-3": "TZA", + "numeric": "0834", + }, + {"country": "Thailand", "alpha-2": "TH", "alpha-3": "THA", "numeric": "0764"}, + {"country": "Timor-Leste", "alpha-2": "TL", "alpha-3": "TLS", "numeric": "0626"}, + {"country": "Togo", "alpha-2": "TG", "alpha-3": "TGO", "numeric": "0768"}, + {"country": "Tokelau", "alpha-2": "TK", "alpha-3": "TKL", "numeric": "0772"}, + {"country": "Tonga", "alpha-2": "TO", "alpha-3": "TON", "numeric": "0776"}, + {"country": "Trinidad and Tobago", "alpha-2": "TT", "alpha-3": "TTO", "numeric": "0780"}, + {"country": "Tunisia", "alpha-2": "TN", "alpha-3": "TUN", "numeric": "0788"}, + {"country": "Turkey", "alpha-2": "TR", "alpha-3": "TUR", "numeric": "0792"}, + {"country": "Turkmenistan", "alpha-2": "TM", "alpha-3": "TKM", "numeric": "0795"}, + { + "country": "Turks and Caicos Islands (the)", + "alpha-2": "TC", + "alpha-3": "TCA", + "numeric": "0796", + }, + {"country": "Tuvalu", "alpha-2": "TV", "alpha-3": "TUV", "numeric": "0798"}, + {"country": "Uganda", "alpha-2": "UG", "alpha-3": "UGA", "numeric": "0800"}, + {"country": "Ukraine", "alpha-2": "UA", "alpha-3": "UKR", "numeric": "0804"}, + {"country": "United Arab Emirates (the)", "alpha-2": "AE", "alpha-3": "ARE", "numeric": "0784"}, + { + "country": "United Kingdom of Great Britain and Northern Ireland (the)", + "alpha-2": "GB", + "alpha-3": "GBR", + "numeric": "0826", + }, + { + "country": "United States Minor Outlying Islands (the)", + "alpha-2": "UM", + "alpha-3": "UMI", + "numeric": "0581", + }, + { + "country": "United States of America (the)", + "alpha-2": "US", + "alpha-3": "USA", + "numeric": "0840", + }, + {"country": "Uruguay", "alpha-2": "UY", "alpha-3": "URY", "numeric": "0858"}, + {"country": "Uzbekistan", "alpha-2": "UZ", "alpha-3": "UZB", "numeric": "0860"}, + {"country": "Vanuatu", "alpha-2": "VU", "alpha-3": "VUT", "numeric": "0548"}, + { + "country": "Venezuela (Bolivarian Republic of)", + "alpha-2": "VE", + "alpha-3": "VEN", + "numeric": "0862", + }, + {"country": "Viet Nam", "alpha-2": "VN", "alpha-3": "VNM", "numeric": "0704"}, + {"country": "Virgin Islands (British)", "alpha-2": "VG", "alpha-3": "VGB", "numeric": "0092"}, + {"country": "Virgin Islands (U.S.)", "alpha-2": "VI", "alpha-3": "VIR", "numeric": "0850"}, + {"country": "Wallis and Futuna", "alpha-2": "WF", "alpha-3": "WLF", "numeric": "0876"}, + {"country": "Western Sahara", "alpha-2": "EH", "alpha-3": "ESH", "numeric": "0732"}, + {"country": "Yemen", "alpha-2": "YE", "alpha-3": "YEM", "numeric": "0887"}, + {"country": "Zambia", "alpha-2": "ZM", "alpha-3": "ZMB", "numeric": "0894"}, + {"country": "Zimbabwe", "alpha-2": "ZW", "alpha-3": "ZWE", "numeric": "0716"}, ] # TODO: Implement lookup for this. Currently not doable From b1c1f609441510c2230940ccb0bd7836bfdf2f60 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Thu, 1 Sep 2022 18:54:43 -0600 Subject: [PATCH 10/24] Add db gripcheck command, db status command --- jarvis/cogs/dbrand.py | 77 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/jarvis/cogs/dbrand.py b/jarvis/cogs/dbrand.py index f6c756c..4900e0f 100644 --- a/jarvis/cogs/dbrand.py +++ b/jarvis/cogs/dbrand.py @@ -4,6 +4,7 @@ import re from datetime import datetime, timedelta import aiohttp +from bs4 import BeautifulSoup from naff import Client, Extension, InteractionContext from naff.models.discord.embed import EmbedField from naff.models.naff.application_commands import ( @@ -22,6 +23,50 @@ from jarvis.utils import build_embed guild_ids = [578757004059738142, 520021794380447745, 862402786116763668] +async def parse_db_status() -> dict: + """Parse the dbrand status page for a local API""" + async with aiohttp.ClientSession() as session: + async with session.get("https://dbrand.com/status") as response: + response.raise_for_status() + soup = BeautifulSoup(await response.content.read(), features="html.parser") + tables = soup.find_all("table") + data = {} + for table in tables: + data_key = "countries" + rows = table.find_all("tr") + headers = rows.pop(0) + headers = [h.get_text() for h in headers.find_all("th")] + if headers[0] == "Service": + data_key = "operations" + data[data_key] = [] + for row in rows: + row_data = [] + cells = row.find_all("td") + for cell in cells: + if "column--comment" in cell["class"]: + text = cell.find("span").get_text() + if cell != "Unavailable": + text += ": " + cell.find("div").get_text() + cell = text.strip() + elif "column--status" in cell["class"]: + info = cell.find("span")["class"] + if any("green" in x for x in info): + cell = "🟢" + elif any("yellow" in x for x in info): + cell = "🟡" + elif any("red" in x for x in info): + cell = "🔴" + elif any("black" in x for x in info): + cell = "⚫" + else: + cell = cell.get_text().strip() + row_data.append(cell) + data[data_key].append( + {headers[idx]: value for idx, value in enumerate(row_data)} + ) + return data + + class DbrandCog(Extension): """ dbrand functions for JARVIS @@ -43,6 +88,38 @@ class DbrandCog(Extension): db = SlashCommand(name="db", description="dbrand commands", scopes=guild_ids) + @db.subcommand(sub_cmd_name="status", sub_cmd_description="Get dbrand operational status") + async def _status(self, ctx: InteractionContext) -> None: + status = self.cache.get("status") + if not status or status["cache_expiry"] <= datetime.utcnow(): + status = await parse_db_status() + status["cache_expiry"] = datetime.utcnow() + timedelta(hours=2) + self.cache["status"] = status + status = status.get("operations") + fields = [ + EmbedField(name=f'{x["Status"]} {x["Service"]}', value=x["Detail"]) for x in status + ] + embed = build_embed( + title="Operational Status", + description="Current dbrand operational status.\n[View online](https://dbrand.com/status)", + fields=fields, + url="https://dbrand.com/status", + color="#FFBB00", + ) + + embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") + embed.set_footer( + text="dbrand.com", + icon_url="https://dev.zevaryx.com/db_logo.png", + ) + await ctx.send(embeds=embed) + + @db.subcommand(sub_cmd_name="gripcheck", sub_cmd_description="Watch a dbrand grip get thrown") + async def _gripcheck(self, ctx: InteractionContext) -> None: + video_url = "https://cdn.discordapp.com/attachments/599068193339736096/890679742263623751/video0.mov" + image_url = "https://cdn.discordapp.com/attachments/599068193339736096/890680198306095104/image0.jpg" + await ctx.send(f"Video: {video_url}\nResults: {image_url}") + @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: From 9a23a1adfcc3c6b036213371fcb4457bddea70c8 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Thu, 1 Sep 2022 18:55:57 -0600 Subject: [PATCH 11/24] Add bs4 to requirements --- poetry.lock | 33 ++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 5bd7d85..c1ce6e3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -191,6 +191,21 @@ 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 = "beautifulsoup4" +version = "4.11.1" +description = "Screen-scraping library" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "22.3.0" @@ -953,6 +968,14 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "soupsieve" +version = "2.3.2.post1" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "thefuzz" version = "0.19.0" @@ -1179,7 +1202,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "d4a3ccd2f79fe0c323784bfba2c5950817257639bbdcdb57a6e71682a8846504" +content-hash = "e95b3b3bed46990e5d3bd4f8662fd888dbf35a9272f09a18192c78d6c60b6f83" [metadata.files] aiofile = [ @@ -1312,6 +1335,10 @@ attrs = [ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] +beautifulsoup4 = [ + {file = "beautifulsoup4-4.11.1-py3-none-any.whl", hash = "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30"}, + {file = "beautifulsoup4-4.11.1.tar.gz", hash = "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693"}, +] black = [ {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, @@ -2102,6 +2129,10 @@ smmap = [ {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] +soupsieve = [ + {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, + {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, +] thefuzz = [] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, diff --git a/pyproject.toml b/pyproject.toml index 52c93fa..c13722a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ nafftrack = {git = "https://github.com/artem30801/nafftrack.git", rev = "master" ansitoimg = "^2022.1" nest-asyncio = "^1.5.5" thefuzz = {extras = ["speedup"], version = "^0.19.0"} +beautifulsoup4 = "^4.11.1" [tool.poetry.dev-dependencies] black = {version = "^22.3.0", allow-prereleases = true} From 008da0df03b9f16494025ff8ae079628c0821211 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:23:13 -0600 Subject: [PATCH 12/24] Condense slash commands --- jarvis/client/events/__init__.py | 30 +++++++++++++++++++ jarvis/cogs/dev.py | 38 ++++++++++++------------ jarvis/cogs/image.py | 6 ++-- jarvis/cogs/remindme.py | 7 ++--- jarvis/cogs/rolegiver.py | 6 ++-- jarvis/cogs/starboard.py | 50 -------------------------------- jarvis/cogs/util.py | 29 +++++++++++------- 7 files changed, 77 insertions(+), 89 deletions(-) diff --git a/jarvis/client/events/__init__.py b/jarvis/client/events/__init__.py index f6f2b34..4c68ce7 100644 --- a/jarvis/client/events/__init__.py +++ b/jarvis/client/events/__init__.py @@ -8,6 +8,7 @@ from jarvis_core.util.ansi import RESET, Fore, Format, fmt from naff import listen from naff.models.discord.channel import DMChannel from naff.models.discord.embed import EmbedField +from naff.models.naff.application_commands import ContextMenu from naff.models.naff.context import Context, InteractionContext, PrefixedContext from jarvis import const @@ -61,6 +62,35 @@ class EventMixin(MemberEventMixin, MessageEventMixin, ComponentEventMixin): "{}&permissions=8&scope=bot%20applications.commands".format(self.user.id) ) + global_base_commands = {} + guild_base_commands = {} + global_context_menus = [] + guild_context_menus = [] + for cid in self.interactions: + commands = self.interactions[cid] + to_update = global_base_commands if cid == 0 else guild_base_commands + for command in commands: + if isinstance(commands[command], ContextMenu): + if cid == 0: + global_context_menus.append(command) + else: + guild_context_menus.append(command) + continue + full = command.split(" ") + base = full[0] + if base not in to_update: + to_update[base] = {} + if len(full) == 3: + to_update[base][full[1]] = full[2] + elif len(full) == 2: + to_update[base][full[1]] = None + + self.logger.info( + "Loaded {:>2} global base slash commands".format(len(global_base_commands)) + ) + self.logger.info("Loaded {:>2} global context menus".format(len(global_context_menus))) + self.logger.info("Loaded {:>2} guild base slash commands".format(len(guild_base_commands))) + self.logger.info("Loaded {:>2} guild context menus".format(len(guild_context_menus))) self.logger.debug("Hitting Reminders for faster loads") _ = await Reminder.find().to_list(None) diff --git a/jarvis/cogs/dev.py b/jarvis/cogs/dev.py index cad6dab..561dee0 100644 --- a/jarvis/cogs/dev.py +++ b/jarvis/cogs/dev.py @@ -23,8 +23,8 @@ from naff.models.discord.file import File from naff.models.discord.message import Attachment from naff.models.naff.application_commands import ( OptionTypes, + SlashCommand, SlashCommandChoice, - slash_command, slash_option, ) from naff.models.naff.command import cooldown @@ -60,7 +60,9 @@ class DevCog(Extension): self.bot = bot self.logger = logging.getLogger(__name__) - @slash_command(name="hash", description="Hash some data") + dev = SlashCommand(name="dev", description="Developer utilities") + + @dev.subcommand(sub_cmd_name="hash", sub_cmd_description="Hash some data") @slash_option( name="method", description="Hash method", @@ -126,7 +128,7 @@ class DevCog(Extension): ) await ctx.send(embeds=embed, components=components) - @slash_command(name="uuid", description="Generate a UUID") + @dev.subcommand(sub_cmd_name="uuid", sub_cmd_description="Generate a UUID") @slash_option( name="version", description="UUID version", @@ -159,25 +161,25 @@ class DevCog(Extension): to_send = UUID_GET[version](uuidpy.NAMESPACE_DNS, data) await ctx.send(f"UUID{version}: `{to_send}`") - @slash_command( - name="objectid", - description="Generate an ObjectID", + @dev.subcommand( + sub_cmd_name="objectid", + sub_cmd_description="Generate an ObjectID", ) @cooldown(bucket=Buckets.USER, rate=1, interval=2) async def _objectid(self, ctx: InteractionContext) -> None: await ctx.send(f"ObjectId: `{str(ObjectId())}`") - @slash_command( - name="ulid", - description="Generate a ULID", + @dev.subcommand( + sub_cmd_name="ulid", + sub_cmd_description="Generate a ULID", ) @cooldown(bucket=Buckets.USER, rate=1, interval=2) async def _ulid(self, ctx: InteractionContext) -> None: await ctx.send(f"ULID: `{ulidpy.new().str}`") - @slash_command( - name="uuid2ulid", - description="Convert a UUID to a ULID", + @dev.subcommand( + sub_cmd_name="uuid2ulid", + sub_cmd_description="Convert a UUID to a ULID", ) @slash_option( name="uuid", description="UUID to convert", opt_type=OptionTypes.STRING, required=True @@ -190,9 +192,9 @@ class DevCog(Extension): else: await ctx.send("Invalid UUID") - @slash_command( - name="ulid2uuid", - description="Convert a ULID to a UUID", + @dev.subcommand( + sub_cmd_name="ulid2uuid", + sub_cmd_description="Convert a ULID to a UUID", ) @slash_option( name="ulid", description="ULID to convert", opt_type=OptionTypes.STRING, required=True @@ -207,7 +209,7 @@ class DevCog(Extension): base64_methods = ["b64", "b16", "b32", "a85", "b85"] - @slash_command(name="encode", description="Encode some data") + @dev.subcommand(sub_cmd_name="encode", sub_cmd_description="Encode some data") @slash_option( name="method", description="Encode method", @@ -245,7 +247,7 @@ class DevCog(Extension): ) await ctx.send(embeds=embed, components=components) - @slash_command(name="decode", description="Decode some data") + @dev.subcommand(sub_cmd_name="decode", sub_cmd_description="Decode some data") @slash_option( name="method", description="Decode method", @@ -283,7 +285,7 @@ class DevCog(Extension): ) await ctx.send(embeds=embed, components=components) - @slash_command(name="cloc", description="Get JARVIS lines of code") + @dev.subcommand(sub_cmd_name="cloc", sub_cmd_description="Get JARVIS lines of code") @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) async def _cloc(self, ctx: InteractionContext) -> None: await ctx.defer() diff --git a/jarvis/cogs/image.py b/jarvis/cogs/image.py index 93f7a12..83e385b 100644 --- a/jarvis/cogs/image.py +++ b/jarvis/cogs/image.py @@ -15,7 +15,7 @@ from naff.models.discord.file import File from naff.models.discord.message import Attachment from naff.models.naff.application_commands import ( OptionTypes, - slash_command, + SlashCommand, slash_option, ) @@ -40,7 +40,9 @@ class ImageCog(Extension): def __del__(self): self._session.close() - @slash_command(name="resize", description="Resize an image") + image = SlashCommand(name="image", description="Manipulate images") + + @image.subcommand(sub_cmd_name="shrink", sub_cmd_description="Shrink an image") @slash_option( name="target", description="Target size, i.e. 200KB", diff --git a/jarvis/cogs/remindme.py b/jarvis/cogs/remindme.py index a18dfb6..2c67d05 100644 --- a/jarvis/cogs/remindme.py +++ b/jarvis/cogs/remindme.py @@ -18,7 +18,6 @@ from naff.models.discord.modal import InputText, Modal, TextStyles from naff.models.naff.application_commands import ( OptionTypes, SlashCommand, - slash_command, slash_option, ) from thefuzz import process @@ -40,7 +39,9 @@ class RemindmeCog(Extension): self.bot = bot self.logger = logging.getLogger(__name__) - @slash_command(name="remindme", description="Set a reminder") + reminders = SlashCommand(name="reminders", description="Manage reminders") + + @reminders.subcommand(sub_cmd_name="set", sub_cmd_description="Set a reminder") @slash_option( name="private", description="Send as DM?", @@ -210,8 +211,6 @@ class RemindmeCog(Extension): return embed - 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) diff --git a/jarvis/cogs/rolegiver.py b/jarvis/cogs/rolegiver.py index 6627a39..34552fe 100644 --- a/jarvis/cogs/rolegiver.py +++ b/jarvis/cogs/rolegiver.py @@ -218,9 +218,7 @@ class RolegiverCog(Extension): ) await ctx.send(embeds=embed, components=components) - role = SlashCommand(name="role", description="Get/Remove Rolegiver roles") - - @role.subcommand(sub_cmd_name="get", sub_cmd_description="Get a role") + @rolegiver.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)) @@ -296,7 +294,7 @@ class RolegiverCog(Extension): component.disabled = True await message.edit(components=components) - @role.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role") + @rolegiver.subcommand(sub_cmd_name="forfeit", sub_cmd_description="Forfeit a role") @cooldown(bucket=Buckets.USER, rate=1, interval=10) async def _role_remove(self, ctx: InteractionContext) -> None: user_roles = ctx.author.roles diff --git a/jarvis/cogs/starboard.py b/jarvis/cogs/starboard.py index 7e62058..018480c 100644 --- a/jarvis/cogs/starboard.py +++ b/jarvis/cogs/starboard.py @@ -265,56 +265,6 @@ class StarboardCog(Extension): description="Manage stars", ) - @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: InteractionContext, - id: int, - starboard: GuildText, - ) -> None: - if not isinstance(starboard, GuildText): - await ctx.send("Channel must be a GuildText channel", ephemeral=True) - return - - 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", - ephemeral=True, - ) - return - - 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}", ephemeral=True) - return - - message = await starboard.fetch_message(star.star) - if message: - await message.delete() - - await star.delete() - - await ctx.send(f"Star {id} deleted from {starboard.mention}") - def setup(bot: Client) -> None: """Add StarboardCog to JARVIS""" diff --git a/jarvis/cogs/util.py b/jarvis/cogs/util.py index 1134a81..f9ac647 100644 --- a/jarvis/cogs/util.py +++ b/jarvis/cogs/util.py @@ -20,6 +20,7 @@ from naff.models.discord.user import User from naff.models.naff.application_commands import ( CommandTypes, OptionTypes, + SlashCommand, SlashCommandChoice, context_menu, slash_command, @@ -49,7 +50,9 @@ class UtilCog(Extension): self.bot = bot self.logger = logging.getLogger(__name__) - @slash_command(name="status", description="Retrieve JARVIS status") + bot = SlashCommand(name="bot", description="Bot commands") + + @bot.subcommand(sub_cmd_name="status", sub_cmd_description="Retrieve JARVIS status") @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) async def _status(self, ctx: InteractionContext) -> None: title = "JARVIS Status" @@ -74,9 +77,9 @@ class UtilCog(Extension): ) await ctx.send(embeds=embed, components=components) - @slash_command( - name="logo", - description="Get the current logo", + @bot.subcommand( + sub_cmd_name="logo", + sub_cmd_description="Get the current logo", ) @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30) async def _logo(self, ctx: InteractionContext) -> None: @@ -89,13 +92,15 @@ class UtilCog(Extension): ) await ctx.send(file=logo, components=components) - @slash_command(name="rchk", description="Robot Camo HK416") + rc = SlashCommand(name="rc", description="Robot Camo emoji commands") + + @rc.subcommand(sub_cmd_name="hk", sub_cmd_description="Robot Camo HK416") async def _rchk(self, ctx: InteractionContext) -> None: await ctx.send(content=hk, ephemeral=True) - @slash_command( - name="rcauto", - description="Automates robot camo letters", + @rc.subcommand( + sub_cmd_name="auto", + sub_cmd_description="Automates robot camo letters", ) @slash_option( name="text", @@ -176,7 +181,7 @@ class UtilCog(Extension): embed.set_thumbnail(url="attachment://color_show.png") data = np.array(JARVIS_LOGO) - r, g, b, a = data.T + *_, a = data.T fill = a > 0 @@ -397,7 +402,7 @@ class UtilCog(Extension): ) await ctx.send(embeds=embed, ephemeral=private, components=components) - @slash_command(name="support", description="Got issues?") + @bot.subcommand(sub_cmd_name="support", sub_cmd_description="Got issues?") async def _support(self, ctx: InteractionContext) -> None: await ctx.send( f""" @@ -409,7 +414,9 @@ We'll help as best we can with whatever issues you encounter. """ ) - @slash_command(name="privacy_terms", description="View Privacy and Terms of Use") + @bot.subcommand( + sub_cmd_name="privacy_terms", sub_cmd_description="View Privacy and Terms of Use" + ) async def _privacy_terms(self, ctx: InteractionContext) -> None: await ctx.send( """ From 5df60aea3d986ad9d72932a6688043aab65fac1c Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:26:58 -0600 Subject: [PATCH 13/24] Increase lock limits to 7 days from 12 hours --- jarvis/cogs/admin/lock.py | 4 ++-- jarvis/cogs/admin/lockdown.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/jarvis/cogs/admin/lock.py b/jarvis/cogs/admin/lock.py index 55a99c4..faad57e 100644 --- a/jarvis/cogs/admin/lock.py +++ b/jarvis/cogs/admin/lock.py @@ -57,8 +57,8 @@ class LockCog(Extension): await ctx.send("Duration must be > 0", ephemeral=True) return - elif duration > 60 * 12: - await ctx.send("Duration must be <= 12 hours", ephemeral=True) + elif duration > 60 * 24 * 7: + await ctx.send("Duration must be <= 7 days", ephemeral=True) return if len(reason) > 100: diff --git a/jarvis/cogs/admin/lockdown.py b/jarvis/cogs/admin/lockdown.py index b1c3e86..15c75ad 100644 --- a/jarvis/cogs/admin/lockdown.py +++ b/jarvis/cogs/admin/lockdown.py @@ -130,8 +130,8 @@ class LockdownCog(Extension): if duration <= 0: await ctx.send("Duration must be > 0", ephemeral=True) return - elif duration >= 300: - await ctx.send("Duration must be < 5 hours", ephemeral=True) + elif duration > 60 * 24 * 7: + await ctx.send("Duration must be <= 7 days", ephemeral=True) return exists = await Lockdown.find_one(q(guild=ctx.guild.id, active=True)) From c9db7d452934f3855cf23eac06b18b8abc70a833 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:27:37 -0600 Subject: [PATCH 14/24] Remove star command group as it is no longer needed --- jarvis/cogs/starboard.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/jarvis/cogs/starboard.py b/jarvis/cogs/starboard.py index 018480c..38c823a 100644 --- a/jarvis/cogs/starboard.py +++ b/jarvis/cogs/starboard.py @@ -260,11 +260,6 @@ class StarboardCog(Extension): 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", - ) - def setup(bot: Client) -> None: """Add StarboardCog to JARVIS""" From a4aec6bf60f4ef7a96281a67a018b76a2c64df46 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:32:54 -0600 Subject: [PATCH 15/24] Add Avatar context menu --- jarvis/cogs/util.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/jarvis/cogs/util.py b/jarvis/cogs/util.py index f9ac647..43b547a 100644 --- a/jarvis/cogs/util.py +++ b/jarvis/cogs/util.py @@ -126,14 +126,6 @@ class UtilCog(Extension): else: await ctx.send(to_send, ephemeral=True) - @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, - ) - @cooldown(bucket=Buckets.USER, rate=1, interval=5) async def _avatar(self, ctx: InteractionContext, user: User = None) -> None: if not user: user = ctx.author @@ -198,6 +190,21 @@ class UtilCog(Extension): ) await ctx.send(embeds=embed, file=color_show, components=components) + @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, + ) + @cooldown(bucket=Buckets.USER, rate=1, interval=5) + async def _avatar_slash(self, ctx: InteractionContext, user: User = None) -> None: + await self._userinfo(ctx, user) + + @context_menu(name="Avatar", context_type=CommandTypes.USER) + async def _avatar_menu(self, ctx: InteractionContext) -> None: + await self._avatar(ctx, ctx.target) + async def _userinfo(self, ctx: InteractionContext, user: User = None) -> None: await ctx.defer() if not user: From 76f4fccc33861b8c21521b31069a348b3c59b0e5 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:36:26 -0600 Subject: [PATCH 16/24] Move all startup logic to on_startup --- jarvis/client/events/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/jarvis/client/events/__init__.py b/jarvis/client/events/__init__.py index 4c68ce7..ddb423c 100644 --- a/jarvis/client/events/__init__.py +++ b/jarvis/client/events/__init__.py @@ -42,10 +42,6 @@ class EventMixin(MemberEventMixin, MessageEventMixin, ComponentEventMixin): async def on_startup(self) -> None: """NAFF on_startup override. Prometheus info generated here.""" jarvis_info.info({"version": const.__version__}) - - @listen() - async def on_ready(self) -> None: - """NAFF on_ready override.""" try: if not self.synced: await self._sync_domains() From 552073cd71ef6f5a6d38dfc5cb9ffcfbecde833f Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:37:29 -0600 Subject: [PATCH 17/24] Change domain update task to not log if no changes --- jarvis/client/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jarvis/client/tasks.py b/jarvis/client/tasks.py index f8fe609..08b19f2 100644 --- a/jarvis/client/tasks.py +++ b/jarvis/client/tasks.py @@ -13,9 +13,9 @@ class TaskMixin: response.raise_for_status() data = await response.json() - self.logger.debug(f"Found {len(data)} changes to phishing domains") if len(data) == 0: return + self.logger.debug(f"Found {len(data)} changes to phishing domains") add = 0 sub = 0 From 3315270ff5014ad4464409780ee0de8416754ecd Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Fri, 9 Sep 2022 22:39:35 -0600 Subject: [PATCH 18/24] Remove unnecessary logging line --- jarvis/client/tasks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/jarvis/client/tasks.py b/jarvis/client/tasks.py index 08b19f2..ff28c44 100644 --- a/jarvis/client/tasks.py +++ b/jarvis/client/tasks.py @@ -7,7 +7,6 @@ from naff.models.naff.tasks.triggers import IntervalTrigger class TaskMixin: @Task.create(IntervalTrigger(minutes=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/60") response.raise_for_status() From d70de07a288661bea72ad2967cc7d96c88210f01 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sat, 10 Sep 2022 17:40:02 -0600 Subject: [PATCH 19/24] Utilize Client.interaction_tree --- jarvis/client/events/__init__.py | 59 +++++++++++++++++--------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/jarvis/client/events/__init__.py b/jarvis/client/events/__init__.py index ddb423c..d6d14c2 100644 --- a/jarvis/client/events/__init__.py +++ b/jarvis/client/events/__init__.py @@ -58,35 +58,38 @@ class EventMixin(MemberEventMixin, MessageEventMixin, ComponentEventMixin): "{}&permissions=8&scope=bot%20applications.commands".format(self.user.id) ) - global_base_commands = {} - guild_base_commands = {} - global_context_menus = [] - guild_context_menus = [] - for cid in self.interactions: - commands = self.interactions[cid] - to_update = global_base_commands if cid == 0 else guild_base_commands - for command in commands: - if isinstance(commands[command], ContextMenu): - if cid == 0: - global_context_menus.append(command) - else: - guild_context_menus.append(command) - continue - full = command.split(" ") - base = full[0] - if base not in to_update: - to_update[base] = {} - if len(full) == 3: - to_update[base][full[1]] = full[2] - elif len(full) == 2: - to_update[base][full[1]] = None + global_base_commands = 0 + guild_base_commands = 0 + global_context_menus = 0 + guild_context_menus = 0 + for cid in self.interaction_tree: + if cid == 0: + global_base_commands = sum( + 1 + for _ in self.interaction_tree[cid] + if not isinstance(self.interaction_tree[cid][_], ContextMenu) + ) + global_context_menus = sum( + 1 + for _ in self.interaction_tree[cid] + if isinstance(self.interaction_tree[cid][_], ContextMenu) + ) + else: + guild_base_commands += sum( + 1 + for _ in self.interaction_tree[cid] + if not isinstance(self.interaction_tree[cid][_], ContextMenu) + ) + guild_context_menus += sum( + 1 + for _ in self.interaction_tree[cid] + if isinstance(self.interaction_tree[cid][_], ContextMenu) + ) - self.logger.info( - "Loaded {:>2} global base slash commands".format(len(global_base_commands)) - ) - self.logger.info("Loaded {:>2} global context menus".format(len(global_context_menus))) - self.logger.info("Loaded {:>2} guild base slash commands".format(len(guild_base_commands))) - self.logger.info("Loaded {:>2} guild context menus".format(len(guild_context_menus))) + self.logger.info("Loaded {:>3} global base slash commands".format(global_base_commands)) + self.logger.info("Loaded {:>3} global context menus".format(global_context_menus)) + self.logger.info("Loaded {:>3} guild base slash commands".format(guild_base_commands)) + self.logger.info("Loaded {:>3} guild context menus".format(guild_context_menus)) self.logger.debug("Hitting Reminders for faster loads") _ = await Reminder.find().to_list(None) From 880a8c01ffab2d70ee0137a794110590d2d3dd8d Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sat, 10 Sep 2022 17:48:46 -0600 Subject: [PATCH 20/24] Add total number of commands to status --- jarvis/cogs/util.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/jarvis/cogs/util.py b/jarvis/cogs/util.py index 43b547a..4b0160d 100644 --- a/jarvis/cogs/util.py +++ b/jarvis/cogs/util.py @@ -56,14 +56,31 @@ class UtilCog(Extension): @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" + desc = ( + f"All systems online" + f"\nConnected to **{len(self.bot.guilds)}** guilds" + f"\nListening for **{len(self.bot.application_commands)}** commands" + ) color = "#3498db" fields = [] uptime = int(self.bot.start_time.timestamp()) - 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="Version", + value=f"[{jconst.__version__}](https://git.zevaryx.com/stark-industries/jarvis/jarvis-bot)", + inline=True, + ) + ) + fields.append( + EmbedField(name="NAFF", value=f"[{const.__version__}](https://naff.info)", inline=True) + ) + repo_url = ( + f"https://git.zevaryx.com/stark-industries/jarvis/jarvis-bot/-/tree/{get_repo_hash()}" + ) + fields.append( + EmbedField(name="Git Hash", value=f"[{get_repo_hash()[:7]}]({repo_url})", inline=True) + ) fields.append(EmbedField(name="Online Since", value=f"", inline=False)) num_domains = len(self.bot.phishing_domains) fields.append( From 3cceee8283f13423e5fee50f380c795584d2db0f Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Mon, 12 Sep 2022 11:17:46 -0600 Subject: [PATCH 21/24] Auto mute on harmful link --- jarvis/client/events/message.py | 49 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/jarvis/client/events/message.py b/jarvis/client/events/message.py index d9ac0eb..2b2ded4 100644 --- a/jarvis/client/events/message.py +++ b/jarvis/client/events/message.py @@ -4,16 +4,18 @@ from datetime import datetime, timedelta, timezone from aiohttp import ClientSession from jarvis_core.db import q -from jarvis_core.db.models import Autopurge, Autoreact, Roleping, Setting, Warning +from jarvis_core.db.models import Autopurge, Autoreact, Mute, Roleping, Setting, Warning from jarvis_core.filters import invites, url from naff import listen from naff.api.events.discord import MessageCreate, MessageDelete, MessageUpdate from naff.client.utils.misc_utils import find_all -from naff.models.discord.channel import DMChannel +from naff.models.discord.channel import DMChannel, GuildText from naff.models.discord.embed import EmbedField from naff.models.discord.enums import Permissions from naff.models.discord.message import Message +from naff.models.discord.user import Member +from jarvis.branding import get_command_color from jarvis.tracking import malicious_tracker, warnings_tracker from jarvis.utils import build_embed from jarvis.utils.embeds import warning_embed @@ -296,6 +298,37 @@ class MessageEventMixin: return True return False + async def timeout_user(self, user: Member, channel: GuildText) -> None: + """Timeout a user.""" + expires_at = datetime.now(tz=timezone.utc) + timedelta(minutes=30) + try: + await user.timeout(communication_disabled_until=expires_at, reason="Phishing link") + await Mute( + user=user.id, + reason="Auto mute for harmful link", + admin=self.user.id, + guild=user.guild.id, + duration=30, + active=True, + ).commit() + ts = int(expires_at.timestamp()) + embed = build_embed( + title="User Muted", + description=f"{user.mention} has been muted", + fields=[ + EmbedField(name="Reason", value="Auto mute for harmful link"), + EmbedField(name="Until", value=f" "), + ], + color=get_command_color("mute"), + ) + 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 channel.send(embeds=embed) + + except Exception: + self.logger.warn("Failed to timeout user for phishing") + @listen() async def on_message(self, event: MessageCreate) -> None: """Handle on_message event. Calls other event handlers.""" @@ -306,8 +339,10 @@ class MessageEventMixin: await self.roleping(message) await self.autopurge(message) await self.checks(message) - if not await self.phishing(message): - await self.malicious_url(message) + if not (phish := await self.phishing(message)): + malicious = await self.malicious_url(message) + if phish or malicious: + await self.timeout_user(message.author, message.channel) @listen() async def on_message_edit(self, event: MessageUpdate) -> None: @@ -361,8 +396,10 @@ class MessageEventMixin: await self.checks(after) await self.roleping(after) await self.checks(after) - if not await self.phishing(after): - await self.malicious_url(after) + if not (phish := await self.phishing(after)): + malicious = await self.malicious_url(after) + if phish or malicious: + await self.timeout_user(after.author, after.channel) @listen() async def on_message_delete(self, event: MessageDelete) -> None: From f50ba86c7b5491f44a0459e20c856879224d4db3 Mon Sep 17 00:00:00 2001 From: zevaryx Date: Wed, 14 Sep 2022 17:32:29 +0000 Subject: [PATCH 22/24] Fix invite detection bug, closes #175 --- jarvis/client/events/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jarvis/client/events/message.py b/jarvis/client/events/message.py index d9ac0eb..c268a9c 100644 --- a/jarvis/client/events/message.py +++ b/jarvis/client/events/message.py @@ -67,10 +67,10 @@ class MessageEventMixin: setting = Setting(guild=message.guild.id, setting="noinvite", value=True) await setting.commit() if match: - guild_invites = await message.guild.fetch_invites() + guild_invites = [x.code for x in 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] + [ + allowed = guild_invites + [ "dbrand", "VtgZntXcnZ", "gPfYGbvTCE", From 697b163c1c2533ac6d8453488a079299e5430219 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sat, 17 Sep 2022 18:04:23 -0600 Subject: [PATCH 23/24] Fix typos in dev embeds --- jarvis/cogs/dev.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jarvis/cogs/dev.py b/jarvis/cogs/dev.py index 561dee0..af8ff09 100644 --- a/jarvis/cogs/dev.py +++ b/jarvis/cogs/dev.py @@ -241,7 +241,7 @@ class DevCog(Extension): 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) + embed = build_embed(title="Encoded Data", description="", fields=fields) components = Button( style=ButtonStyles.DANGER, emoji="🗑️", custom_id=f"delete|{ctx.author.id}" ) @@ -257,7 +257,7 @@ class DevCog(Extension): ) @slash_option( name="data", - description="Data to encode", + description="Data to decode", opt_type=OptionTypes.STRING, required=True, ) @@ -276,7 +276,7 @@ class DevCog(Extension): ) return fields = [ - EmbedField(name="Plaintext", value=f"`{data}`", inline=False), + EmbedField(name="Encoded Text", value=f"`{data}`", inline=False), EmbedField(name=mstr, value=f"`{decoded}`", inline=False), ] embed = build_embed(title="Decoded Data", description="", fields=fields) From 13968c3c41223a474a41eef21e0970985c40a096 Mon Sep 17 00:00:00 2001 From: Zevaryx Date: Sat, 1 Oct 2022 22:49:27 -0600 Subject: [PATCH 24/24] Some visual changes --- jarvis/__init__.py | 43 +++++++++++++++++++++++-- jarvis/branding.py | 7 ++++- jarvis/cogs/dbrand.py | 73 +++++++++++++++++++++++++++++++------------ poetry.lock | 59 ++++++++++++++++++++++------------ 4 files changed, 139 insertions(+), 43 deletions(-) diff --git a/jarvis/__init__.py b/jarvis/__init__.py index acb5cd0..7eba22d 100644 --- a/jarvis/__init__.py +++ b/jarvis/__init__.py @@ -1,5 +1,7 @@ """Main JARVIS package.""" import logging +from functools import partial +from typing import Any import aioredis import jurigged @@ -17,11 +19,47 @@ from jarvis.utils import get_extensions __version__ = const.__version__ +def jlogger(logger: logging.Logger, event: Any) -> None: + """ + Logging for jurigged + + Args: + logger: Logger to use + event: Event to parse + """ + jlog = partial(logger.log, 11) + if isinstance(event, jurigged.live.WatchOperation): + jlog(f"[bold]Watch[/] {event.filename}", extra={"markup": True}) + elif isinstance(event, jurigged.codetools.AddOperation): + event_str = f"{event.defn.parent.dotpath()}:{event.defn.stashed.lineno}" + if isinstance(event.defn, jurigged.codetools.LineDefinition): + event_str += f" | {event.defn.text}" + jlog( + f"[bold green]Run[/] {event_str}", + extra={"markup": True}, + ) + else: + jlog(f"[bold green]Add[/] {event_str}", extra={"markup": True}) + elif isinstance(event, jurigged.codetools.UpdateOperation): + if isinstance(event.defn, jurigged.codetools.FunctionDefinition): + event_str = f"{event.defn.parent.dotpath()}:{event.defn.stashed.lineno}" + jlog(f"[bold yellow]Update[/] {event_str}", extra={"markup": True}) + elif isinstance(event, jurigged.codetools.DeleteOperation): + event_str = f"{event.defn.parent.dotpath()}:{event.defn.stashed.lineno}" + if isinstance(event.defn, jurigged.codetools.LineDefinition): + event_str += f" | {event.defn.text}" + jlog(f"[bold red]Delete[/] {event_str}", extra={"markup": True}) + elif isinstance(event, (Exception, SyntaxError)): + logger.exception("Jurigged encountered error", exc_info=True) + else: + jlog(event) + + async def run() -> None: """Run JARVIS""" # Configure logger jconfig = JarvisConfig.from_yaml() - logger = get_logger("jarvis", show_locals=jconfig.log_level == "DEBUG") + logger = get_logger("jarvis", show_locals=False) # jconfig.log_level == "DEBUG") logger.setLevel(jconfig.log_level) file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w") file_handler.setFormatter( @@ -49,7 +87,8 @@ async def run() -> None: # External modules if jconfig.log_level == "DEBUG": - jurigged.watch(pattern="jarvis/*.py") + logging.addLevelName(11, "\033[35mJURIG\033[0m ") + jurigged.watch(pattern="jarvis/*.py", logger=partial(jlogger, logger)) if jconfig.rook_token: rook.start(token=jconfig.rook_token, labels={"env": "dev"}) diff --git a/jarvis/branding.py b/jarvis/branding.py index 33b1352..e7d2a02 100644 --- a/jarvis/branding.py +++ b/jarvis/branding.py @@ -11,8 +11,13 @@ COMMAND_TYPES = { "SOFT": ["warning"], "GOOD": ["unban", "unmute"], } - CUSTOM_COMMANDS = {} +CUSTOM_EMOJIS = { + "ico_clock_green": "<:ico_clock_green:1019710693206933605>", + "ico_clock_yellow": "<:ico_clock_yellow:1019710734340472834>", + "ico_clock_red": "<:ico_clock_red:1019710735896551534>", + "ico_check_green": "<:ico_check_green:1019725504120639549>", +} def get_command_color(command: str) -> str: diff --git a/jarvis/cogs/dbrand.py b/jarvis/cogs/dbrand.py index 4900e0f..859220f 100644 --- a/jarvis/cogs/dbrand.py +++ b/jarvis/cogs/dbrand.py @@ -1,11 +1,12 @@ """JARVIS dbrand cog.""" import logging import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import aiohttp from bs4 import BeautifulSoup from naff import Client, Extension, InteractionContext +from naff.client.utils import find from naff.models.discord.embed import EmbedField from naff.models.naff.application_commands import ( OptionTypes, @@ -16,6 +17,7 @@ from naff.models.naff.command import cooldown from naff.models.naff.cooldowns import Buckets from thefuzz import process +from jarvis.branding import CUSTOM_EMOJIS from jarvis.config import JarvisConfig from jarvis.data.dbrand import shipping_lookup from jarvis.utils import build_embed @@ -51,11 +53,11 @@ async def parse_db_status() -> dict: elif "column--status" in cell["class"]: info = cell.find("span")["class"] if any("green" in x for x in info): - cell = "🟢" + cell = CUSTOM_EMOJIS.get("ico_clock_green", "🟢") elif any("yellow" in x for x in info): - cell = "🟡" + cell = CUSTOM_EMOJIS.get("ico_clock_yellow", "🟡") elif any("red" in x for x in info): - cell = "🔴" + cell = CUSTOM_EMOJIS.get("ico_clock_red", "🔴") elif any("black" in x for x in info): cell = "⚫" else: @@ -91,20 +93,26 @@ class DbrandCog(Extension): @db.subcommand(sub_cmd_name="status", sub_cmd_description="Get dbrand operational status") async def _status(self, ctx: InteractionContext) -> None: status = self.cache.get("status") - if not status or status["cache_expiry"] <= datetime.utcnow(): + if not status or status["cache_expiry"] <= datetime.now(tz=timezone.utc): status = await parse_db_status() - status["cache_expiry"] = datetime.utcnow() + timedelta(hours=2) + status["cache_expiry"] = datetime.now(tz=timezone.utc) + timedelta(hours=2) self.cache["status"] = status status = status.get("operations") + emojies = [x["Status"] for x in status] fields = [ - EmbedField(name=f'{x["Status"]} {x["Service"]}', value=x["Detail"]) for x in status + EmbedField(name=f'{x["Status"]} {x["Service"]}', value=x["Detail"]) for x in status ] + color = "#FBBD1E" + if all("green" in x for x in emojies): + color = "#38F657" + elif all("red" in x for x in emojies): + color = "#F12D20" embed = build_embed( title="Operational Status", description="Current dbrand operational status.\n[View online](https://dbrand.com/status)", fields=fields, url="https://dbrand.com/status", - color="#FFBB00", + color=color, ) embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") @@ -112,6 +120,7 @@ class DbrandCog(Extension): text="dbrand.com", icon_url="https://dev.zevaryx.com/db_logo.png", ) + await ctx.send(embeds=embed) @db.subcommand(sub_cmd_name="gripcheck", sub_cmd_description="Watch a dbrand grip get thrown") @@ -206,12 +215,12 @@ class DbrandCog(Extension): await ctx.defer() dest = search.lower() data = self.cache.get(dest, None) - if not data or data["cache_expiry"] < datetime.utcnow(): + if not data or data["cache_expiry"] < datetime.now(tz=timezone.utc): api_link = self.api_url + dest data = await self._session.get(api_link) if 200 <= data.status < 400: data = await data.json() - data["cache_expiry"] = datetime.utcnow() + timedelta(hours=24) + data["cache_expiry"] = datetime.now(tz=timezone.utc) + timedelta(hours=24) self.cache[dest] = data else: data = None @@ -220,32 +229,56 @@ class DbrandCog(Extension): fields = [] for service in data["shipping_services_available"]: service_data = self.cache.get(f"{dest}-{service}") - if not service_data or service_data["cache_expiry"] < datetime.utcnow(): + if not service_data or service_data["cache_expiry"] < datetime.now(tz=timezone.utc): service_data = await self._session.get( self.api_url + dest + "/" + service["url"] ) if service_data.status > 400: continue service_data = await service_data.json() - service_data["cache_expiry"] = datetime.utcnow() + timedelta(hours=24) + service_data["cache_expiry"] = datetime.now(tz=timezone.utc) + timedelta( + hours=24 + ) self.cache[f"{dest}-{service}"] = service_data title = f'{service_data["carrier"]} {service_data["tier-title"]} | {service_data["costs-min"]}' message = service_data["time-title"] if service_data["free_threshold_available"]: title += " | Free over " + service_data["free-threshold"] fields.append(EmbedField(title, message)) + + status = self.cache.get("status") + if not status or status["cache_expiry"] <= datetime.now(tz=timezone.utc): + status = await parse_db_status() + status["cache_expiry"] = datetime.now(tz=timezone.utc) + timedelta(hours=2) + self.cache["status"] = status + status = status["countries"] + + country = data["country"] + if country.startswith("the"): + country = country.replace("the", "").strip() + shipping_info = find(lambda x: x["Country"] == country, status) + country = "-".join(x for x in data["country"].split(" ") if x != "the") - country_urlsafe = country.replace("-", "%20") - description = ( - f"Click the link above to see shipping time to {data['country']}." - "\n[View all shipping destinations](https://dbrand.com/shipping)" - " | [Check shipping status]" - f"(https://dbrand.com/status#main-content:~:text={country_urlsafe})" - ) + description = "" + color = "#FFBB00" + if shipping_info: + description = f'{shipping_info["Status"]}\u200b \u200b {shipping_info["Est. Delivery Time"].split(":")[0]}' + created = self.cache.get("status").get("cache_expiry") - timedelta(hours=2) + ts = int(created.timestamp()) + description += f" \u200b | \u200b Last updated: \n\u200b" + if "green" in shipping_info["Status"]: + color = "#38F657" + elif "yellow" in shipping_info["Status"]: + color = "#FBBD1E" + elif "red" in shipping_info["Status"]: + color = "#F12D20" + else: + color = "#FFFFFF" + embed = build_embed( title="Shipping to {}".format(data["country"]), description=description, - color="#FFBB00", + color=color, fields=fields, url=self.base_url + "shipping/" + country, ) diff --git a/poetry.lock b/poetry.lock index c1ce6e3..df79f1b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -172,10 +172,10 @@ aiohttp = "*" yarl = "*" [package.extras] +test = ["vcrpy (==4.0.2)", "testfixtures (>4.13.2,<7)", "pytest-vcr", "pytest", "mock (>=0.8)", "asynctest (>=0.13.0)"] +lint = ["pydocstyle", "pre-commit", "flynt", "flake8", "black"] +dev = ["vcrpy (==4.0.2)", "testfixtures (>4.13.2,<7)", "pytest-vcr", "pytest", "mock (>=0.8)", "asynctest (>=0.13.0)", "pydocstyle", "pre-commit", "flynt", "flake8", "black"] 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" @@ -306,7 +306,7 @@ optional = false python-versions = "*" [package.extras] -test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] +test = ["hypothesis (==3.55.3)", "flake8 (==3.7.8)"] [[package]] name = "dateparser" @@ -323,9 +323,9 @@ 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"] +fasttext = ["fasttext"] +calendars = ["convertdate", "hijri-converter", "convertdate"] [[package]] name = "discord-typings" @@ -454,7 +454,7 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "jurigged" -version = "0.5.2" +version = "0.5.3" description = "Live update of Python functions" category = "main" optional = false @@ -467,7 +467,7 @@ ovld = ">=0.3.1,<0.4.0" watchdog = ">=1.0.2" [package.extras] -develoop = ["giving (>=0.3.6,<0.4.0)", "rich (>=10.13.0,<11.0.0)", "hrepr (>=0.4.0,<0.5.0)"] +develoop = ["hrepr (>=0.4.0,<0.5.0)", "rich (>=10.13.0,<11.0.0)", "giving (>=0.3.6,<0.4.0)"] [[package]] name = "marshmallow" @@ -529,7 +529,7 @@ python-versions = "*" [[package]] name = "naff" -version = "1.9.0" +version = "1.10.0" description = "Not another freaking fork" category = "main" optional = false @@ -784,7 +784,7 @@ optional = false python-versions = ">=3.6.8" [package.extras] -diagrams = ["railroad-diagrams", "jinja2"] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pyppeteer" @@ -1163,9 +1163,9 @@ optional = false python-versions = ">=3.7" [package.extras] -docs = ["Sphinx (>=3.4)", "sphinx-rtd-theme (>=0.5)"] -optional = ["python-socks", "wsaccel"] test = ["websockets"] +optional = ["wsaccel", "python-socks"] +docs = ["sphinx-rtd-theme (>=0.5)", "Sphinx (>=3.4)"] [[package]] name = "websockets" @@ -1413,7 +1413,10 @@ dateparser = [ {file = "dateparser-1.1.1-py2.py3-none-any.whl", hash = "sha256:9600874312ff28a41f96ec7ccdc73be1d1c44435719da47fea3339d55ff5a628"}, {file = "dateparser-1.1.1.tar.gz", hash = "sha256:038196b1f12c7397e38aad3d61588833257f6f552baa63a1499e6987fa8d42d9"}, ] -discord-typings = [] +discord-typings = [ + {file = "discord-typings-0.5.1.tar.gz", hash = "sha256:1a4fb1e00201416ae94ca64ca5935d447c005e0475b1ec274c1a6e09072db70e"}, + {file = "discord_typings-0.5.1-py3-none-any.whl", hash = "sha256:55ebdb6d6f0f47df774a0c31193ba6a45de14625fab9c6fbd43bfe87bb8c0128"}, +] distro = [ {file = "distro-1.7.0-py3-none-any.whl", hash = "sha256:d596311d707e692c2160c37807f83e3820c5d539d5a83e87cfb6babd8ba3a06b"}, {file = "distro-1.7.0.tar.gz", hash = "sha256:151aeccf60c216402932b52e40ee477a939f8d58898927378a02abbe852c1c39"}, @@ -1509,8 +1512,8 @@ jinxed = [ {file = "jinxed-1.2.0.tar.gz", hash = "sha256:032acda92d5c57cd216033cbbd53de731e6ed50deb63eb4781336ca55f72cda5"}, ] jurigged = [ - {file = "jurigged-0.5.2-py3-none-any.whl", hash = "sha256:410ff6199c659108dace9179507342883fe2fffec1966fd19709f9d59fd69e24"}, - {file = "jurigged-0.5.2.tar.gz", hash = "sha256:de1d4daeb99c0299eaa86f691d35cb1eab3bfa836cfe9a3551a56f3829479e3b"}, + {file = "jurigged-0.5.3-py3-none-any.whl", hash = "sha256:355a9bddf42cae541e862796fb125827fc35573a982c6f35d3dc5621e59c91e3"}, + {file = "jurigged-0.5.3.tar.gz", hash = "sha256:47cf4e9f10455a39602caa447888c06adda962699c65f19d8c37509817341b5e"}, ] marshmallow = [ {file = "marshmallow-3.16.0-py3-none-any.whl", hash = "sha256:53a1e0ee69f79e1f3e80d17393b25cfc917eda52f859e8183b4af72c3390c1f1"}, @@ -1590,8 +1593,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] naff = [ - {file = "naff-1.9.0-py3-none-any.whl", hash = "sha256:20144495aed9452d9d2e713eb6ade9636601457ca3de255684b2186068505bcd"}, - {file = "naff-1.9.0.tar.gz", hash = "sha256:f4870ea304747368d6d750f3d52fcbc96017bd7afaa7ec06a3e9a68ff301997d"}, + {file = "naff-1.10.0-py3-none-any.whl", hash = "sha256:bb28ef19efb3f8e04f3569a3aac6b3e2738cf5747dea0bed483c458588933682"}, + {file = "naff-1.10.0.tar.gz", hash = "sha256:d0ab71c39ea5bf352228f0bc3d3dfe3610122cb01733bca4565497078de95650"}, ] nafftrack = [] nanoid = [ @@ -1850,6 +1853,7 @@ pymongo = [ {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_15_universal2.whl", hash = "sha256:858af7c2ab98f21ed06b642578b769ecfcabe4754648b033168a91536f7beef9"}, {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"}, @@ -1963,7 +1967,9 @@ python-gitlab = [ {file = "python-gitlab-3.5.0.tar.gz", hash = "sha256:29ae7fb9b8c9aeb2e6e19bd2fd04867e93ecd7af719978ce68fac0cf116ab30d"}, {file = "python_gitlab-3.5.0-py3-none-any.whl", hash = "sha256:73b5aa6502efa557ee1a51227cceb0243fac5529627da34f08c5f265bf50417c"}, ] -python-levenshtein = [] +python-levenshtein = [ + {file = "python-Levenshtein-0.12.2.tar.gz", hash = "sha256:dc2395fbd148a1ab31090dd113c366695934b9e85fe5a4b2a032745efd0346f6"}, +] pytz = [ {file = "pytz-2022.1-py2.py3-none-any.whl", hash = "sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c"}, {file = "pytz-2022.1.tar.gz", hash = "sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7"}, @@ -1980,6 +1986,13 @@ pyyaml = [ {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-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, {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"}, @@ -2133,7 +2146,10 @@ soupsieve = [ {file = "soupsieve-2.3.2.post1-py3-none-any.whl", hash = "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759"}, {file = "soupsieve-2.3.2.post1.tar.gz", hash = "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d"}, ] -thefuzz = [] +thefuzz = [ + {file = "thefuzz-0.19.0-py2.py3-none-any.whl", hash = "sha256:4fcdde8e40f5ca5e8106bc7665181f9598a9c8b18b0a4d38c41a095ba6788972"}, + {file = "thefuzz-0.19.0.tar.gz", hash = "sha256:6f7126db2f2c8a54212b05e3a740e45f4291c497d75d20751728f635bb74aa3d"}, +] tomli = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -2146,7 +2162,10 @@ tweepy = [ {file = "tweepy-4.10.0-py3-none-any.whl", hash = "sha256:f0abbd234a588e572f880f99a094ac321217ff3eade6c0eca118ed6db8e2cf0a"}, {file = "tweepy-4.10.0.tar.gz", hash = "sha256:7f92574920c2f233663fff154745fc2bb0d10aedc23617379a912d8e4fefa399"}, ] -typing-extensions = [] +typing-extensions = [ + {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, + {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, +] tzdata = [ {file = "tzdata-2022.1-py2.py3-none-any.whl", hash = "sha256:238e70234214138ed7b4e8a0fab0e5e13872edab3be586ab8198c407620e2ab9"}, {file = "tzdata-2022.1.tar.gz", hash = "sha256:8b536a8ec63dc0751342b3984193a3118f8fca2afe25752bb9b7fffd398552d3"},