Merge branch 'dev' into embeds

This commit is contained in:
Zeva Rose 2022-10-03 20:12:54 -06:00
commit 61a3cdbcd1
23 changed files with 1826 additions and 1402 deletions

View file

@ -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"})

View file

@ -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:

View file

@ -1,982 +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.embeds.admin import warning_embed
from jarvis.tracking import jarvis_info, malicious_tracker, warnings_tracker
from jarvis.utils import build_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
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(hours=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.raise_for_status()
data = await response.json()
self.logger.debug(f"Found {len(data)} changes to phishing domains")
add = 0
sub = 0
for update in data:
if update["type"] == "add":
for domain in update["domains"]:
if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete":
for domain in update["domains"]:
if domain in self.phishing_domains:
sub -= 1
self.phishing_domains.remove(domain)
self.logger.debug(f"{add} additions, {sub} removals")
async def _prerun(self, ctx: Context, *args, **kwargs) -> None:
name = ctx.invoke_target
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:
await self._sync_domains()
self._update_domains.start()
asyncio.create_task(self._chunk_all())
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"<t:{timestamp}:T>"
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.channel)
embed = build_embed(
title="Member Left",
description=f"{user.username}#{user.discriminator} left {guild.name}",
fields=[],
)
embed.set_author(name=user.username, icon_url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await channel.send(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", admin=self.user)
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", admin=self.user)
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", admin=self.user
)
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", admin=self.user)
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.get(
"https://spoopy.oceanlord.me/api/check_website", json={"website": match.string}
)
if resp.status != 200:
break
data = await resp.json()
for item in data["processed"]["urls"].values():
if not item["safe"]:
self.logger.debug(
f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Unsafe URL",
user=message.author.id,
).commit()
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, admin=self.user)
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) == 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)

38
jarvis/client/__init__.py Normal file
View file

@ -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'}")

107
jarvis/client/errors.py Normal file
View file

@ -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"<t:{timestamp}:T>"
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)

View file

@ -0,0 +1,144 @@
"""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.application_commands import ContextMenu
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__})
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)
)
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 {:>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)
# 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()

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,471 @@
"""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, 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, 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
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 = [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 = 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
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"<t:{ts}:F> <t:{ts}:R>"),
],
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."""
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 (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:
"""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 (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:
"""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}"
)

33
jarvis/client/tasks.py Normal file
View file

@ -0,0 +1,33 @@
"""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:
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()
if len(data) == 0:
return
self.logger.debug(f"Found {len(data)} changes to phishing domains")
add = 0
sub = 0
for update in data:
if update["type"] == "add":
for domain in update["domains"]:
if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete":
for domain in update["domains"]:
if domain in self.phishing_domains:
sub -= 1
self.phishing_domains.remove(domain)
self.logger.debug(f"{add} additions, {sub} removals")

View file

@ -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:

View file

@ -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))

View file

@ -195,6 +195,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 = unmute_embed(user=user, admin=ctx.author, reason=reason, guild=ctx.guild)

View file

@ -1,10 +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,
@ -13,7 +15,9 @@ 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.branding import CUSTOM_EMOJIS
from jarvis.config import JarvisConfig
from jarvis.data.dbrand import shipping_lookup
from jarvis.utils import build_embed
@ -21,6 +25,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 = CUSTOM_EMOJIS.get("ico_clock_green", "🟢")
elif any("yellow" in x for x in info):
cell = CUSTOM_EMOJIS.get("ico_clock_yellow", "🟡")
elif any("red" in x for x in info):
cell = CUSTOM_EMOJIS.get("ico_clock_red", "🔴")
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
@ -42,6 +90,45 @@ 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.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.get("operations")
emojies = [x["Status"] for x in status]
fields = [
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=color,
)
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:
@ -96,7 +183,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,58 +195,90 @@ 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():
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
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():
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)
self.cache[f"{dest}-{service}"] = service_data
fields.append(
EmbedField(
service_data["carrier"] + " " + service_data["tier-title"],
service_data["time-title"],
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: <t:{ts}:R>\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,
)

View file

@ -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",
@ -239,13 +241,13 @@ 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}"
)
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",
@ -255,7 +257,7 @@ class DevCog(Extension):
)
@slash_option(
name="data",
description="Data to encode",
description="Data to decode",
opt_type=OptionTypes.STRING,
required=True,
)
@ -274,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)
@ -283,13 +285,13 @@ 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()
output = subprocess.check_output( # noqa: S603, S607
["tokei", "-C", "--sort", "code"]
).decode("UTF-8")
output = subprocess.check_output(["tokei", "-C", "--sort", "code"]).decode(
"UTF-8"
) # noqa: S603, S607
console = Console()
with console.capture() as capture:
console.print(output)

View file

@ -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",

View file

@ -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)

View file

@ -212,9 +212,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))
@ -290,7 +288,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

View file

@ -260,61 +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",
)
@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"""

View file

@ -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,18 +50,37 @@ 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"
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"<t:{uptime}:F>", inline=False))
num_domains = len(self.bot.phishing_domains)
fields.append(
@ -74,9 +94,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 +109,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",
@ -121,14 +143,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
@ -176,7 +190,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
@ -193,6 +207,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:
@ -397,7 +426,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 +438,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(
"""

View file

@ -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": "´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

92
poetry.lock generated
View file

@ -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"
@ -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"
@ -291,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"
@ -308,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"
@ -439,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
@ -452,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"
@ -514,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
@ -769,7 +784,7 @@ optional = false
python-versions = ">=3.6.8"
[package.extras]
diagrams = ["railroad-diagrams", "jinja2"]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pyppeteer"
@ -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"
@ -1140,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"
@ -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"},
@ -1386,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"},
@ -1482,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"},
@ -1563,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 = [
@ -1823,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"},
@ -1936,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"},
@ -1953,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"},
@ -2102,7 +2142,14 @@ smmap = [
{file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"},
{file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"},
]
thefuzz = []
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 = [
{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"},
@ -2115,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"},

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "jarvis"
version = "2.2.1"
version = "2.2.3"
description = "JARVIS admin bot"
authors = ["Zevaryx <zevaryx@gmail.com>"]
@ -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}