V2.0 Beta 2

Closes #138 and #128
This commit is contained in:
Zeva Rose 2022-05-05 22:01:25 +00:00
commit a7efedd46a
38 changed files with 2584 additions and 734 deletions

View file

@ -1,42 +1,53 @@
"""Main JARVIS package.""" """Main JARVIS package."""
import logging import logging
from importlib.metadata import version as _v
from dis_snek import Intents import aioredis
import jurigged
import rook
from jarvis_core.db import connect from jarvis_core.db import connect
from jarvis_core.log import get_logger from jarvis_core.log import get_logger
from naff import Intents
from jarvis import utils from jarvis import const
from jarvis.client import Jarvis from jarvis.client import Jarvis
from jarvis.cogs import __path__ as cogs_path
from jarvis.config import JarvisConfig from jarvis.config import JarvisConfig
from jarvis.utils import get_extensions
try: __version__ = const.__version__
__version__ = _v("jarvis")
except Exception:
__version__ = "0.0.0"
jconfig = JarvisConfig.from_yaml()
logger = get_logger("jarvis")
logger.setLevel(jconfig.log_level)
file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w")
file_handler.setFormatter(
logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)8s] %(message)s")
)
logger.addHandler(file_handler)
intents = Intents.DEFAULT | Intents.MESSAGES | Intents.GUILD_MEMBERS | Intents.GUILD_MESSAGES
restart_ctx = None
jarvis = Jarvis(
intents=intents,
sync_interactions=jconfig.sync,
delete_unused_application_cmds=True,
send_command_tracebacks=False,
)
async def run() -> None: async def run() -> None:
"""Run JARVIS""" """Run JARVIS"""
jconfig = JarvisConfig.from_yaml()
logger = get_logger("jarvis", show_locals=jconfig.log_level == "DEBUG")
logger.setLevel(jconfig.log_level)
file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w")
file_handler.setFormatter(
logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)8s] %(message)s")
)
logger.addHandler(file_handler)
intents = (
Intents.DEFAULT | Intents.MESSAGES | Intents.GUILD_MEMBERS | Intents.GUILD_MESSAGE_CONTENT
)
redis_config = jconfig.redis.copy()
redis_host = redis_config.pop("host")
redis = await aioredis.from_url(redis_host, decode_responses=True, **redis_config)
jarvis = Jarvis(
intents=intents,
sync_interactions=jconfig.sync,
delete_unused_application_cmds=True,
send_command_tracebacks=False,
redis=redis,
)
if jconfig.log_level == "DEBUG":
jurigged.watch()
if jconfig.rook_token:
rook.start(token=jconfig.rook_token, labels={"env": "dev"})
logger.info("Starting JARVIS") logger.info("Starting JARVIS")
logger.debug("Connecting to database") logger.debug("Connecting to database")
connect(**jconfig.mongo["connect"], testing=jconfig.mongo["database"] != "jarvis") connect(**jconfig.mongo["connect"], testing=jconfig.mongo["database"] != "jarvis")
@ -44,9 +55,9 @@ async def run() -> None:
# jconfig.get_db_config() # jconfig.get_db_config()
logger.debug("Loading extensions") logger.debug("Loading extensions")
for extension in utils.get_extensions(): for extension in get_extensions(cogs_path):
jarvis.load_extension(extension) jarvis.load_extension(extension)
logger.debug(f"Loaded {extension}") logger.debug("Loaded %s", extension)
jarvis.max_messages = jconfig.max_messages jarvis.max_messages = jconfig.max_messages
logger.debug("Running JARVIS") logger.debug("Running JARVIS")

View file

@ -1,34 +1,48 @@
"""Custom JARVIS client.""" """Custom JARVIS client."""
import asyncio
import logging import logging
import re import re
import traceback import traceback
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from aiohttp import ClientSession from aiohttp import ClientSession
from dis_snek import Snake, listen from jarvis_core.db import q
from dis_snek.api.events.discord import ( from jarvis_core.db.models import (
Action,
Autopurge,
Autoreact,
Modlog,
Note,
Roleping,
Setting,
Warning,
)
from jarvis_core.filters import invites, url
from jarvis_core.util.ansi import RESET, Fore, Format, fmt
from naff import Client, listen
from naff.api.events.discord import (
MemberAdd, MemberAdd,
MemberRemove, MemberRemove,
MemberUpdate,
MessageCreate, MessageCreate,
MessageDelete, MessageDelete,
MessageUpdate, MessageUpdate,
) )
from dis_snek.client.errors import CommandCheckFailure, CommandOnCooldown from naff.api.events.internal import Button
from dis_snek.client.utils.misc_utils import find_all from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException
from dis_snek.models.discord.channel import DMChannel from naff.client.utils.misc_utils import find_all, get
from dis_snek.models.discord.embed import EmbedField from naff.models.discord.channel import DMChannel
from dis_snek.models.discord.enums import Permissions from naff.models.discord.embed import Embed, EmbedField
from dis_snek.models.discord.message import Message from naff.models.discord.enums import AuditLogEventType, Permissions
from dis_snek.models.snek.context import Context, InteractionContext, MessageContext from naff.models.discord.message import Message
from dis_snek.models.snek.tasks.task import Task from naff.models.discord.user import Member
from dis_snek.models.snek.tasks.triggers import IntervalTrigger from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from jarvis_core.db import q from naff.models.naff.tasks.task import Task
from jarvis_core.db.models import Autopurge, Autoreact, Roleping, Setting, Warning from naff.models.naff.tasks.triggers import IntervalTrigger
from jarvis_core.filters import invites, url
from jarvis_core.util import build_embed
from jarvis_core.util.ansi import RESET, Fore, Format, fmt
from pastypy import AsyncPaste as Paste from pastypy import AsyncPaste as Paste
from jarvis import const
from jarvis.utils import build_embed
from jarvis.utils.embeds import warning_embed from jarvis.utils.embeds import warning_embed
DEFAULT_GUILD = 862402786116763668 DEFAULT_GUILD = 862402786116763668
@ -54,9 +68,11 @@ VAL_FMT = fmt(Fore.WHITE)
CMD_FMT = fmt(Fore.GREEN, Format.BOLD) CMD_FMT = fmt(Fore.GREEN, Format.BOLD)
class Jarvis(Snake): class Jarvis(Client):
def __init__(self, *args, **kwargs): # noqa: ANN002 ANN003 def __init__(self, *args, **kwargs): # noqa: ANN002 ANN003
redis = kwargs.pop("redis")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.redis = redis
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.phishing_domains = [] self.phishing_domains = []
self.pre_run_callback = self._prerun self.pre_run_callback = self._prerun
@ -71,19 +87,29 @@ class Jarvis(Snake):
self.logger.debug(f"Found {len(data)} changes to phishing domains") self.logger.debug(f"Found {len(data)} changes to phishing domains")
add = 0
sub = 0
for update in data: for update in data:
if update["type"] == "add": if update["type"] == "add":
if update["domain"] not in self.phishing_domains: for domain in update["domains"]:
self.phishing_domains.append(update["domain"]) if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete": elif update["type"] == "delete":
if update["domain"] in self.phishing_domains: for domain in update["domains"]:
self.phishing_domains.remove(update["domain"]) 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: async def _prerun(self, ctx: Context, *args, **kwargs) -> None:
name = ctx.invoked_name name = ctx.invoke_target
if isinstance(ctx, InteractionContext) and ctx.target_id: if isinstance(ctx, InteractionContext) and ctx.target_id:
kwargs["context target"] = ctx.target kwargs["context target"] = ctx.target
args = " ".join(f"{k}:{v}" for k, v in kwargs.items()) args = " ".join(f"{k}:{v}" for k, v in kwargs.items())
elif isinstance(ctx, PrefixedContext):
args = " ".join(args)
self.logger.debug(f"Running command `{name}` with args: {args or 'None'}") self.logger.debug(f"Running command `{name}` with args: {args or 'None'}")
async def _sync_domains(self) -> None: async def _sync_domains(self) -> None:
@ -96,21 +122,35 @@ class Jarvis(Snake):
@listen() @listen()
async def on_ready(self) -> None: async def on_ready(self) -> None:
"""Lepton on_ready override.""" """NAFF on_ready override."""
try:
await self._sync_domains() await self._sync_domains()
self._update_domains.start() self._update_domains.start()
except Exception as e:
self.logger.error("Failed to load anti-phishing", exc_info=e)
self.logger.info("Logged in as {}".format(self.user)) # noqa: T001 self.logger.info("Logged in as {}".format(self.user)) # noqa: T001
self.logger.info("Connected to {} guild(s)".format(len(self.guilds))) # 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 self.logger.info( # noqa: T001
"https://discord.com/api/oauth2/authorize?client_id=" "https://discord.com/api/oauth2/authorize?client_id="
"{}&permissions=8&scope=bot%20applications.commands".format(self.user.id) "{}&permissions=8&scope=bot%20applications.commands".format(self.user.id)
) )
async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None:
"""NAFF on_error override."""
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
out = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
self.logger.error(out, exc_info=error)
else:
self.logger.error(f"Ignoring exception in {source}", exc_info=error)
async def on_command_error( async def on_command_error(
self, ctx: Context, error: Exception, *args: list, **kwargs: dict self, ctx: Context, error: Exception, *args: list, **kwargs: dict
) -> None: ) -> None:
"""Lepton on_command_error override.""" """NAFF on_command_error override."""
self.logger.debug(f"Handling error in {ctx.invoked_name}: {error}") name = ctx.invoke_target
self.logger.debug(f"Handling error in {name}: {error}")
if isinstance(error, CommandOnCooldown): if isinstance(error, CommandOnCooldown):
await ctx.send(str(error), ephemeral=True) await ctx.send(str(error), ephemeral=True)
return return
@ -131,7 +171,7 @@ class Jarvis(Snake):
if isinstance(v, str) and len(v) > 100: if isinstance(v, str) and len(v) > 100:
v = v[97] + "..." v = v[97] + "..."
arg_str += f"{v}\n" arg_str += f"{v}\n"
elif isinstance(ctx, MessageContext): elif isinstance(ctx, PrefixedContext):
for v in ctx.args: for v in ctx.args:
if isinstance(v, str) and len(v) > 100: if isinstance(v, str) and len(v) > 100:
v = v[97] + "..." v = v[97] + "..."
@ -143,11 +183,15 @@ class Jarvis(Snake):
full_message = ERROR_MSG.format( full_message = ERROR_MSG.format(
guild_name=ctx.guild.name, guild_name=ctx.guild.name,
error_time=error_time, error_time=error_time,
invoked_name=ctx.invoked_name, invoked_name=name,
arg_str=arg_str, arg_str=arg_str,
callback_args=callback_args, callback_args=callback_args,
callback_kwargs=callback_kwargs, 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)) error_message = "".join(traceback.format_exception(error))
if len(full_message + error_message) >= 1800: if len(full_message + error_message) >= 1800:
error_message = "\n ".join(error_message.split("\n")) error_message = "\n ".join(error_message.split("\n"))
@ -167,12 +211,16 @@ class Jarvis(Snake):
f"\nException:\n```py\n{error_message}\n```" f"\nException:\n```py\n{error_message}\n```"
) )
await ctx.send("Whoops! Encountered an error. The error has been logged.", ephemeral=True) 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) return await super().on_command_error(ctx, error, *args, **kwargs)
except Exception as e:
self.logger.error("Uncaught exception", exc_info=e)
# Modlog # Modlog
async def on_command(self, ctx: Context) -> None: async def on_command(self, ctx: Context) -> None:
"""Lepton on_command override.""" """NAFF on_command override."""
if not isinstance(ctx.channel, DMChannel) and ctx.invoked_name not in ["pw"]: 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")) modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="activitylog"))
if modlog: if modlog:
channel = await ctx.guild.fetch_channel(modlog.value) channel = await ctx.guild.fetch_channel(modlog.value)
@ -186,7 +234,7 @@ class Jarvis(Snake):
if len(v) > 100: if len(v) > 100:
v = v[:97] + "..." v = v[:97] + "..."
args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}") args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}")
elif isinstance(ctx, MessageContext): elif isinstance(ctx, PrefixedContext):
for v in ctx.args: for v in ctx.args:
if isinstance(v, str) and len(v) > 100: if isinstance(v, str) and len(v) > 100:
v = v[97] + "..." v = v[97] + "..."
@ -195,7 +243,7 @@ class Jarvis(Snake):
fields = [ fields = [
EmbedField( EmbedField(
name="Command", name="Command",
value=f"```ansi\n{CMD_FMT}{ctx.invoked_name}{RESET} {args}\n```", value=f"```ansi\n{CMD_FMT}{ctx.invoke_target}{RESET} {args}\n```",
inline=False, inline=False,
), ),
] ]
@ -242,13 +290,131 @@ class Jarvis(Snake):
channel = await guild.fetch_channel(log.channel) channel = await guild.fetch_channel(log.channel)
embed = build_embed( embed = build_embed(
title="Member Left", title="Member Left",
desciption=f"{user.username}#{user.discriminator} left {guild.name}", description=f"{user.username}#{user.discriminator} left {guild.name}",
fields=[], fields=[],
) )
embed.set_author(name=user.username, icon_url=user.avatar.url) embed.set_author(name=user.username, icon_url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}") embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await channel.send(embed=embed) await channel.send(embed=embed)
async def process_verify(self, before: Member, after: Member) -> Embed:
"""Process user verification."""
auditlog = await after.guild.fetch_audit_log(
user_id=before.id, action_type=AuditLogEventType.MEMBER_ROLE_UPDATE
)
audit_event = get(auditlog.events, reason="Verification passed")
if audit_event:
admin_mention = "[N/A]"
admin_text = "[N/A]"
if admin := await after.guild.fet_member(audit_event.user_id):
admin_mention = admin.mention
admin_text = f"{admin.username}#{admin.discriminator}"
fields = (
EmbedField(name="Moderator", value=f"{admin_mention} ({admin_text})"),
EmbedField(name="Reason", value=audit_event.reason),
)
embed = build_embed(
title="User Verified",
description=f"{after.mention} was verified",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rolechange(self, before: Member, after: Member) -> Embed:
"""Process role changes."""
if before.roles == after.roles:
return
new_roles = []
removed_roles = []
for role in before.roles:
if role not in after.roles:
removed_roles.append(role)
for role in after.roles:
if role not in before.roles:
new_roles.append(role)
new_text = "\n".join(role.mention for role in new_roles) or "None"
removed_text = "\n".join(role.mention for role in removed_roles) or "None"
fields = (
EmbedField(name="Added Roles", value=new_text),
EmbedField(name="Removed Roles", value=removed_text),
)
embed = build_embed(
title="User Roles Changed",
description=f"{after.mention} had roles changed",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rename(self, before: Member, after: Member) -> None:
"""Process name change."""
if (
before.nickname == after.nickname
and before.discriminator == after.discriminator
and before.username == after.username
):
return
fields = (
EmbedField(
name="Before",
value=f"{before.display_name} ({before.username}#{before.discriminator})",
),
EmbedField(
name="After", value=f"{after.display_name} ({after.username}#{after.discriminator})"
),
)
embed = build_embed(
title="User Renamed",
description=f"{after.mention} changed their name",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
@listen()
async def on_member_update(self, event: MemberUpdate) -> None:
"""Handle on_member_update event."""
before = event.before
after = event.after
if (before.display_name == after.display_name and before.roles == after.roles) or (
not after or not before
):
return
log = await Setting.find_one(q(guild=before.guild.id, setting="activitylog"))
if log:
channel = await before.guild.fetch_channel(log.value)
await asyncio.sleep(0.5) # Wait for audit log
embed = None
if before._role_ids != after._role_ids:
verified = await Setting.find_one(q(guild=before.guild.id, setting="verified"))
v_role = None
if verified:
v_role = await before.guild.fetch_role(verified.value)
if not v_role:
self.logger.debug(f"Guild {before.guild.id} verified role no longer exists")
await verified.delete()
else:
if not before.has_role(v_role) and after.has_role(v_role):
embed = await self.process_verify(before, after)
embed = embed or await self.process_rolechange(before, after)
embed = embed or await self.process_rename(before, after)
if embed:
await channel.send(embed=embed)
# Message # Message
async def autopurge(self, message: Message) -> None: async def autopurge(self, message: Message) -> None:
"""Handle autopurge events.""" """Handle autopurge events."""
@ -308,10 +474,13 @@ class Jarvis(Snake):
await message.delete() await message.delete()
except Exception: except Exception:
self.logger.debug("Message deleted before action taken") self.logger.debug("Message deleted before action taken")
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning( await Warning(
active=True, active=True,
admin=self.user.id, admin=self.user.id,
duration=24, duration=24,
expires_at=expires_at,
guild=message.guild.id, guild=message.guild.id,
reason="Sent an invite link", reason="Sent an invite link",
user=message.author.id, user=message.author.id,
@ -338,10 +507,12 @@ class Jarvis(Snake):
self.logger.debug( self.logger.debug(
f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}" f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}"
) )
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning( await Warning(
active=True, active=True,
admin=self.user.id, admin=self.user.id,
duration=24, duration=24,
expires_at=expires_at,
guild=message.guild.id, guild=message.guild.id,
reason="Mass Mention", reason="Mass Mention",
user=message.author.id, user=message.author.id,
@ -351,19 +522,19 @@ class Jarvis(Snake):
async def roleping(self, message: Message) -> None: async def roleping(self, message: Message) -> None:
"""Handle roleping events.""" """Handle roleping events."""
try:
if message.author.has_permission(Permissions.MANAGE_GUILD): if message.author.has_permission(Permissions.MANAGE_GUILD):
return 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: if await Roleping.collection.count_documents(q(guild=message.guild.id, active=True)) == 0:
return return
rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None) rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None)
# Get all role IDs involved with message # Get all role IDs involved with message
roles = [] roles = [x.id async for x in message.mention_roles]
async for mention in message.mention_roles:
roles.append(mention.id)
async for mention in message.mention_users: async for mention in message.mention_users:
for role in mention.roles: roles += [x.id for x in mention.roles]
roles.append(role.id)
if not roles: if not roles:
return return
@ -381,12 +552,15 @@ class Jarvis(Snake):
user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR) user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR)
# Check if user in a bypass list # 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 user_has_bypass = False
for roleping in rolepings: for roleping in rolepings:
if message.author.id in roleping.bypass["users"]: if message.author.id in roleping.bypass["users"]:
user_has_bypass = True user_has_bypass = True
break break
if any(role.id in roleping.bypass["roles"] for role in message.author.roles): if check_has_role(roleping):
user_has_bypass = True user_has_bypass = True
break break
@ -394,10 +568,12 @@ class Jarvis(Snake):
self.logger.debug( self.logger.debug(
f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}" f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}"
) )
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning( await Warning(
active=True, active=True,
admin=self.user.id, admin=self.user.id,
duration=24, duration=24,
expires_at=expires_at,
guild=message.guild.id, guild=message.guild.id,
reason="Pinged a blocked role/user with a blocked role", reason="Pinged a blocked role/user with a blocked role",
user=message.author.id, user=message.author.id,
@ -412,10 +588,12 @@ class Jarvis(Snake):
self.logger.debug( self.logger.debug(
f"Phishing url `{m}` detected in {message.guild.id}/{message.channel.id}/{message.id}" 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( await Warning(
active=True, active=True,
admin=self.user.id, admin=self.user.id,
duration=24, duration=24,
expires_at=expires_at,
guild=message.guild.id, guild=message.guild.id,
reason="Phishing URL", reason="Phishing URL",
user=message.author.id, user=message.author.id,
@ -441,10 +619,12 @@ class Jarvis(Snake):
self.logger.debug( self.logger.debug(
f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}" 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( await Warning(
active=True, active=True,
admin=self.user.id, admin=self.user.id,
duration=24, duration=24,
expires_at=expires_at,
guild=message.guild.id, guild=message.guild.id,
reason="Unsafe URL", reason="Unsafe URL",
user=message.author.id, user=message.author.id,
@ -511,7 +691,7 @@ class Jarvis(Snake):
) )
await channel.send(embed=embed) await channel.send(embed=embed)
except Exception as e: except Exception as e:
self.logger.warn( self.logger.warning(
f"Failed to process edit {before.guild.id}/{before.channel.id}/{before.id}: {e}" 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: if not isinstance(after.channel, DMChannel) and not after.author.bot:
@ -587,6 +767,72 @@ class Jarvis(Snake):
) )
await channel.send(embed=embed) await channel.send(embed=embed)
except Exception as e: except Exception as e:
self.logger.warn( self.logger.warning(
f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}" f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}"
) )
@listen()
async def on_button(self, event: Button) -> None:
"""Process button events."""
context = event.context
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
if not context.custom_id.startswith("modcase|"):
return await super().on_button(event)
if not context.author.has_permission(Permissions.MODERATE_MEMBERS):
return
user_key = f"msg|{context.message.id}"
action_key = ""
if context.custom_id == "modcase|yes":
if user_id := await self.redis.get(user_key):
action_key = f"{user_id}|{context.guild.id}"
if (user := await context.guild.fetch_member(user_id)) and (
action_data := await self.redis.get(action_key)
):
name, parent = action_data.split("|")[:2]
action = Action(action_type=name, parent=parent)
note = Note(
admin=context.author.id, content="Moderation case opened via message"
)
modlog = Modlog(
user=user.id,
admin=context.author.id,
guild=context.guild.id,
actions=[action],
notes=[note],
)
await modlog.commit()
fields = (
EmbedField(name="Admin", value=context.author.mention),
EmbedField(name="Opening Action", value=f"{name} {parent}"),
)
embed = build_embed(
title="Moderation Case Opened",
description=f"Moderation case opened against {user.mention}",
fields=fields,
)
embed.set_author(
name=user.username + "#" + user.discriminator,
icon_url=user.display_avatar.url,
)
await context.message.edit(embed=embed)
elif not user:
self.logger.debug("User no longer in guild")
await context.send("User no longer in guild", ephemeral=True)
else:
self.logger.warn("Unable to get action data ( %s )", action_key)
await context.send("Unable to get action data", ephemeral=True)
for row in context.message.components:
for component in row.components:
component.disabled = True
await context.message.edit(components=context.message.components)
msg = "Cancelled" if context.custom_id == "modcase|no" else "Moderation case opened"
await context.send(msg)
await self.redis.delete(user_key)
await self.redis.delete(action_key)

View file

@ -1,12 +1,22 @@
"""JARVIS Admin Cogs.""" """JARVIS Admin Cogs."""
import logging import logging
from dis_snek import Snake from naff import Client
from jarvis.cogs.admin import ban, kick, lock, lockdown, mute, purge, roleping, warning from jarvis.cogs.admin import (
ban,
kick,
lock,
lockdown,
modcase,
mute,
purge,
roleping,
warning,
)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add admin cogs to JARVIS""" """Add admin cogs to JARVIS"""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
msg = "Loaded jarvis.cogs.admin.{}" msg = "Loaded jarvis.cogs.admin.{}"
@ -17,7 +27,9 @@ def setup(bot: Snake) -> None:
lock.LockCog(bot) lock.LockCog(bot)
logger.debug(msg.format("lock")) logger.debug(msg.format("lock"))
lockdown.LockdownCog(bot) lockdown.LockdownCog(bot)
logger.debug(msg.format("ban")) logger.debug(msg.format("lockdown"))
modcase.CaseCog(bot)
logger.debug(msg.format("modcase"))
mute.MuteCog(bot) mute.MuteCog(bot)
logger.debug(msg.format("mute")) logger.debug(msg.format("mute"))
purge.PurgeCog(bot) purge.PurgeCog(bot)

View file

@ -1,22 +1,21 @@
"""JARVIS BanCog.""" """JARVIS BanCog."""
import logging
import re import re
from dis_snek import InteractionContext, Permissions, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import find, find_all from jarvis_core.db.models import Ban, Unban
from dis_snek.ext.paginators import Paginator from naff import InteractionContext, Permissions
from dis_snek.models.discord.embed import EmbedField from naff.client.utils.misc_utils import find, find_all
from dis_snek.models.discord.user import User from naff.ext.paginators import Paginator
from dis_snek.models.snek.application_commands import ( from naff.models.discord.embed import EmbedField
from naff.models.discord.user import User
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
SlashCommandChoice, SlashCommandChoice,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Ban, Unban
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.cogs import ModcaseCog from jarvis.utils.cogs import ModcaseCog
@ -26,10 +25,6 @@ from jarvis.utils.permissions import admin_or_permissions
class BanCog(ModcaseCog): class BanCog(ModcaseCog):
"""JARVIS BanCog.""" """JARVIS BanCog."""
def __init__(self, bot: Snake):
super().__init__(bot)
self.logger = logging.getLogger(__name__)
async def discord_apply_ban( async def discord_apply_ban(
self, self,
ctx: InteractionContext, ctx: InteractionContext,

View file

@ -1,16 +1,14 @@
"""JARVIS KickCog.""" """JARVIS KickCog."""
import logging from jarvis_core.db.models import Kick
from naff import InteractionContext, Permissions
from dis_snek import InteractionContext, Permissions, Snake from naff.models.discord.embed import EmbedField
from dis_snek.models.discord.embed import EmbedField from naff.models.discord.user import User
from dis_snek.models.discord.user import User from naff.models.naff.application_commands import (
from dis_snek.models.snek.application_commands import (
OptionTypes, OptionTypes,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db.models import Kick
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.cogs import ModcaseCog from jarvis.utils.cogs import ModcaseCog
@ -20,10 +18,6 @@ from jarvis.utils.permissions import admin_or_permissions
class KickCog(ModcaseCog): class KickCog(ModcaseCog):
"""JARVIS KickCog.""" """JARVIS KickCog."""
def __init__(self, bot: Snake):
super().__init__(bot)
self.logger = logging.getLogger(__name__)
@slash_command(name="kick", description="Kick a user") @slash_command(name="kick", description="Kick a user")
@slash_option(name="user", description="User to kick", opt_type=OptionTypes.USER, required=True) @slash_option(name="user", description="User to kick", opt_type=OptionTypes.USER, required=True)
@slash_option( @slash_option(

View file

@ -2,26 +2,26 @@
import logging import logging
from typing import Union from typing import Union
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import get from jarvis_core.db.models import Lock, Permission
from dis_snek.models.discord.channel import GuildText, GuildVoice from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.enums import Permissions from naff.client.utils.misc_utils import get
from dis_snek.models.snek.application_commands import ( from naff.models.discord.channel import GuildText, GuildVoice
from naff.models.discord.enums import Permissions
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Lock, Permission
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class LockCog(Scale): class LockCog(Cog):
"""JARVIS LockCog.""" """JARVIS LockCog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)

View file

@ -1,25 +1,27 @@
"""JARVIS LockdownCog.""" """JARVIS LockdownCog."""
import logging import logging
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import find_all, get from jarvis_core.db.models import Lock, Lockdown, Permission
from dis_snek.models.discord.channel import GuildCategory, GuildChannel from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.enums import Permissions from naff.client.utils.misc_utils import find_all, get
from dis_snek.models.discord.guild import Guild from naff.models.discord.channel import GuildCategory, GuildChannel
from dis_snek.models.discord.user import Member from naff.models.discord.enums import Permissions
from dis_snek.models.snek.application_commands import ( from naff.models.discord.guild import Guild
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Lock, Lockdown, Permission
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
async def lock(bot: Snake, target: GuildChannel, admin: Member, reason: str, duration: int) -> None: async def lock(
bot: Client, target: GuildChannel, admin: Member, reason: str, duration: int
) -> None:
""" """
Lock an existing channel Lock an existing channel
@ -44,7 +46,7 @@ async def lock(bot: Snake, target: GuildChannel, admin: Member, reason: str, dur
).commit() ).commit()
async def lock_all(bot: Snake, guild: Guild, admin: Member, reason: str, duration: int) -> None: async def lock_all(bot: Client, guild: Guild, admin: Member, reason: str, duration: int) -> None:
""" """
Lock all channels Lock all channels
@ -64,7 +66,7 @@ async def lock_all(bot: Snake, guild: Guild, admin: Member, reason: str, duratio
await lock(bot, channel, admin, reason, duration) await lock(bot, channel, admin, reason, duration)
async def unlock_all(bot: Snake, guild: Guild, admin: Member) -> None: async def unlock_all(bot: Client, guild: Guild, admin: Member) -> None:
""" """
Unlock all locked channels Unlock all locked channels
@ -92,10 +94,10 @@ async def unlock_all(bot: Snake, guild: Guild, admin: Member) -> None:
await lockdown.commit() await lockdown.commit()
class LockdownCog(Scale): class LockdownCog(Cog):
"""JARVIS LockdownCog.""" """JARVIS LockdownCog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)

View file

@ -0,0 +1,332 @@
"""JARVIS Moderation Case management."""
from typing import TYPE_CHECKING, List, Optional
from jarvis_core.db import q
from jarvis_core.db.models import Modlog, Note, actions
from naff import Cog, InteractionContext, Permissions
from naff.ext.paginators import Paginator
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from rich.console import Console
from rich.table import Table
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
if TYPE_CHECKING:
from naff.models.discord.guild import Guild
ACTIONS_LOOKUP = {
"ban": actions.Ban,
"kick": actions.Kick,
"mute": actions.Mute,
"unban": actions.Unban,
"warning": actions.Warning,
}
class CaseCog(Cog):
"""JARVIS CaseCog."""
async def get_summary_embed(self, mod_case: Modlog, guild: "Guild") -> Embed:
"""
Get Moderation case summary embed.
Args:
mod_case: Moderation case
guild: Originating guild
"""
action_table = Table()
action_table.add_column(header="Type", justify="left", style="orange4", no_wrap=True)
action_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True)
action_table.add_column(header="Reason", justify="left", style="white")
note_table = Table()
note_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True)
note_table.add_column(header="Content", justify="left", style="white")
console = Console()
action_output = ""
action_output_extra = ""
for idx, action in enumerate(mod_case.actions):
parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent))
if not parent_action:
action.orphaned = True
action_table.add_row(action.action_type.title(), "[N/A]", "[N/A]")
else:
admin = await self.bot.fetch_user(parent_action.admin)
admin_text = "[N/A]"
if admin:
admin_text = f"{admin.username}#{admin.discriminator}"
action_table.add_row(action.action_type.title(), admin_text, parent_action.reason)
with console.capture() as cap:
console.print(action_table)
tmp_output = cap.get()
if len(tmp_output) >= 800:
action_output_extra = f"... and {len(mod_case.actions[idx:])} more actions"
break
action_output = tmp_output
note_output = ""
note_output_extra = ""
notes = sorted(mod_case.notes, key=lambda x: x.created_at)
for idx, note in enumerate(notes):
admin = await self.bot.fetch_user(note.admin)
admin_text = "[N/A]"
if admin:
admin_text = f"{admin.username}#{admin.discriminator}"
note_table.add_row(admin_text, note.content)
with console.capture() as cap:
console.print(note_table)
tmp_output = cap.get()
if len(tmp_output) >= 1000:
note_output_extra = f"... and {len(notes[idx:])} more notes"
break
note_output = tmp_output
status = "Open" if mod_case.open else "Closed"
user = await self.bot.fetch_user(mod_case.user)
username = "[N/A]"
user_text = "[N/A]"
if user:
username = f"{user.username}#{user.discriminator}"
user_text = user.mention
admin = await self.bot.fetch_user(mod_case.admin)
admin_text = "[N/A]"
if admin:
admin_text = admin.mention
action_output = f"```ansi\n{action_output}\n{action_output_extra}\n```"
note_output = f"```ansi\n{note_output}\n{note_output_extra}\n```"
fields = (
EmbedField(
name="Actions", value=action_output if mod_case.actions else "No Actions Found"
),
EmbedField(name="Notes", value=note_output if mod_case.notes else "No Notes Found"),
)
embed = build_embed(
title=f"Moderation Case [`{mod_case.nanoid}`]",
description=f"{status} case against {user_text} [**opened by {admin_text}**]",
fields=fields,
timestamp=mod_case.created_at,
)
icon_url = None
if user:
icon_url = user.avatar.url
embed.set_author(name=username, icon_url=icon_url)
embed.set_footer(text=str(mod_case.user))
await mod_case.commit()
return embed
async def get_action_embeds(self, mod_case: Modlog, guild: "Guild") -> List[Embed]:
"""
Get Moderation case action embeds.
Args:
mod_case: Moderation case
guild: Originating guild
"""
embeds = []
user = await self.bot.fetch_user(mod_case.user)
username = "[N/A]"
user_mention = "[N/A]"
avatar_url = None
if user:
username = f"{user.username}#{user.discriminator}"
avatar_url = user.avatar.url
user_mention = user.mention
for action in mod_case.actions:
if action.orphaned:
continue
parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent))
if not parent_action:
action.orphaned = True
continue
admin = await self.bot.fetch_user(parent_action.admin)
admin_text = "[N/A]"
if admin:
admin_text = admin.mention
fields = (EmbedField(name=action.action_type.title(), value=parent_action.reason),)
embed = build_embed(
title="Moderation Case Action",
description=f"{admin_text} initiated an action against {user_mention}",
fields=fields,
timestamp=parent_action.created_at,
)
embed.set_author(name=username, icon_url=avatar_url)
embeds.append(embed)
await mod_case.commit()
return embeds
cases = SlashCommand(name="cases", description="Manage moderation cases")
@cases.subcommand(sub_cmd_name="list", sub_cmd_description="List moderation cases")
@slash_option(
name="user",
description="User to filter cases to",
opt_type=OptionTypes.USER,
required=False,
)
@slash_option(
name="closed",
description="Include closed cases",
opt_type=OptionTypes.BOOLEAN,
required=False,
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _cases_list(
self, ctx: InteractionContext, user: Optional[Member] = None, closed: bool = False
) -> None:
query = q(guild=ctx.guild.id)
if not closed:
query.update(q(open=True))
if user:
query.update(q(user=user.id))
cases = await Modlog.find(query).sort("created_at", -1).to_list(None)
if len(cases) == 0:
await ctx.send("No cases to view", ephemeral=True)
return
pages = [await self.get_summary_embed(c, ctx.guild) for c in cases]
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
case = SlashCommand(name="case", description="Manage a moderation case")
show = case.group(name="show", description="Show information about a specific case")
@show.subcommand(sub_cmd_name="summary", sub_cmd_description="Summarize a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_show_summary(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@show.subcommand(sub_cmd_name="actions", sub_cmd_description="Get case actions")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_show_actions(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
pages = await self.get_action_embeds(case, ctx.guild)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
@case.subcommand(sub_cmd_name="close", sub_cmd_description="Show a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_close(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
case.open = False
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="repoen", sub_cmd_description="Reopen a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_reopen(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
case.open = True
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="note", sub_cmd_description="Add a note to a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@slash_option(
name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_note(self, ctx: InteractionContext, cid: str, note: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
if not case.open:
await ctx.send("Case is closed, please re-open to add a new comment", ephemeral=True)
return
if len(note) > 50:
await ctx.send("Note must be <= 50 characters", ephemeral=True)
return
note = Note(admin=ctx.author.id, content=note)
case.notes.append(note)
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="new", sub_cmd_description="Open a new case")
@slash_option(name="user", description="Target user", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_new(self, ctx: InteractionContext, user: Member, note: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, user=user.id, open=True))
if case:
await ctx.send(f"Case already open with ID `{case.nanoid}`", ephemeral=True)
return
if not isinstance(user, Member):
await ctx.send("User must be in this guild", ephemeral=True)
return
if len(note) > 50:
await ctx.send("Note must be <= 50 characters", ephemeral=True)
return
note = Note(admin=ctx.author.id, content=note)
case = Modlog(
user=user.id, guild=ctx.guild.id, admin=ctx.author.id, notes=[note], actions=[]
)
await case.commit()
await case.reload()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)

View file

@ -1,15 +1,16 @@
"""JARVIS MuteCog.""" """JARVIS MuteCog."""
import asyncio import asyncio
import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from dateparser import parse from dateparser import parse
from dateparser_data.settings import default_parsers from dateparser_data.settings import default_parsers
from dis_snek import InteractionContext, Permissions, Snake from jarvis_core.db.models import Mute
from dis_snek.models.discord.embed import EmbedField from naff import InteractionContext, Permissions
from dis_snek.models.discord.modal import InputText, Modal, TextStyles from naff.client.errors import Forbidden
from dis_snek.models.discord.user import Member from naff.models.discord.embed import EmbedField
from dis_snek.models.snek.application_commands import ( from naff.models.discord.modal import InputText, Modal, TextStyles
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
CommandTypes, CommandTypes,
OptionTypes, OptionTypes,
SlashCommandChoice, SlashCommandChoice,
@ -17,8 +18,7 @@ from dis_snek.models.snek.application_commands import (
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db.models import Mute
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.cogs import ModcaseCog from jarvis.utils.cogs import ModcaseCog
@ -28,10 +28,6 @@ from jarvis.utils.permissions import admin_or_permissions
class MuteCog(ModcaseCog): class MuteCog(ModcaseCog):
"""JARVIS MuteCog.""" """JARVIS MuteCog."""
def __init__(self, bot: Snake):
super().__init__(bot)
self.logger = logging.getLogger(__name__)
async def _apply_timeout( async def _apply_timeout(
self, ctx: InteractionContext, user: Member, reason: str, until: datetime self, ctx: InteractionContext, user: Member, reason: str, until: datetime
) -> None: ) -> None:
@ -125,8 +121,11 @@ class MuteCog(ModcaseCog):
f"`{old_until}` is in the past, which isn't allowed", ephemeral=True f"`{old_until}` is in the past, which isn't allowed", ephemeral=True
) )
return return
try:
embed = await self._apply_timeout(ctx, ctx.target, reason, until) embed = await self._apply_timeout(ctx, ctx.target, reason, until)
await response.send(embed=embed) await response.send(embed=embed)
except Forbidden:
await response.send("Unable to mute this user", ephemeral=True)
@slash_command(name="mute", description="Mute a user") @slash_command(name="mute", description="Mute a user")
@slash_option(name="user", description="User to mute", opt_type=OptionTypes.USER, required=True) @slash_option(name="user", description="User to mute", opt_type=OptionTypes.USER, required=True)
@ -179,8 +178,11 @@ class MuteCog(ModcaseCog):
return return
until = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) until = datetime.now(tz=timezone.utc) + timedelta(minutes=duration)
try:
embed = await self._apply_timeout(ctx, user, reason, until) embed = await self._apply_timeout(ctx, user, reason, until)
await ctx.send(embed=embed) await ctx.send(embed=embed)
except Forbidden:
await ctx.send("Unable to mute this user", ephemeral=True)
@slash_command(name="unmute", description="Unmute a user") @slash_command(name="unmute", description="Unmute a user")
@slash_option( @slash_option(

View file

@ -1,24 +1,24 @@
"""JARVIS PurgeCog.""" """JARVIS PurgeCog."""
import logging import logging
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.models.discord.channel import GuildText from jarvis_core.db.models import Autopurge, Purge
from dis_snek.models.snek.application_commands import ( from naff import Client, Cog, InteractionContext, Permissions
from naff.models.discord.channel import GuildText
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Autopurge, Purge
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class PurgeCog(Scale): class PurgeCog(Cog):
"""JARVIS PurgeCog.""" """JARVIS PurgeCog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)

View file

@ -1,29 +1,29 @@
"""JARVIS RolepingCog.""" """JARVIS RolepingCog."""
import logging import logging
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import find_all from jarvis_core.db.models import Roleping
from dis_snek.ext.paginators import Paginator from naff import Client, Cog, InteractionContext, Permissions
from dis_snek.models.discord.embed import EmbedField from naff.client.utils.misc_utils import find_all
from dis_snek.models.discord.role import Role from naff.ext.paginators import Paginator
from dis_snek.models.discord.user import Member from naff.models.discord.embed import EmbedField
from dis_snek.models.snek.application_commands import ( from naff.models.discord.role import Role
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Roleping
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class RolepingCog(Scale): class RolepingCog(Cog):
"""JARVIS RolepingCog.""" """JARVIS RolepingCog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)

View file

@ -1,19 +1,19 @@
"""JARVIS WarningCog.""" """JARVIS WarningCog."""
import logging from datetime import datetime, timedelta, timezone
from dis_snek import InteractionContext, Permissions, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import get_all from jarvis_core.db.models import Warning
from dis_snek.ext.paginators import Paginator from naff import InteractionContext, Permissions
from dis_snek.models.discord.embed import EmbedField from naff.client.utils.misc_utils import get_all
from dis_snek.models.discord.user import Member from naff.ext.paginators import Paginator
from dis_snek.models.snek.application_commands import ( from naff.models.discord.embed import EmbedField
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Warning
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.cogs import ModcaseCog from jarvis.utils.cogs import ModcaseCog
@ -24,10 +24,6 @@ from jarvis.utils.permissions import admin_or_permissions
class WarningCog(ModcaseCog): class WarningCog(ModcaseCog):
"""JARVIS WarningCog.""" """JARVIS WarningCog."""
def __init__(self, bot: Snake):
super().__init__(bot)
self.logger = logging.getLogger(__name__)
@slash_command(name="warn", description="Warn a user") @slash_command(name="warn", description="Warn a user")
@slash_option(name="user", description="User to warn", opt_type=OptionTypes.USER, required=True) @slash_option(name="user", description="User to warn", opt_type=OptionTypes.USER, required=True)
@slash_option( @slash_option(
@ -59,12 +55,14 @@ class WarningCog(ModcaseCog):
await ctx.send("User not in guild", ephemeral=True) await ctx.send("User not in guild", ephemeral=True)
return return
await ctx.defer() await ctx.defer()
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=duration)
await Warning( await Warning(
user=user.id, user=user.id,
reason=reason, reason=reason,
admin=ctx.author.id, admin=ctx.author.id,
guild=ctx.guild.id, guild=ctx.guild.id,
duration=duration, duration=duration,
expires_at=expires_at,
active=True, active=True,
).commit() ).commit()
embed = warning_embed(user, reason) embed = warning_embed(user, reason)

View file

@ -3,26 +3,26 @@ import logging
import re import re
from typing import Optional, Tuple from typing import Optional, Tuple
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import find from jarvis_core.db.models import Autoreact
from dis_snek.models.discord.channel import GuildText from naff import Client, Cog, InteractionContext, Permissions
from dis_snek.models.snek.application_commands import ( from naff.client.utils.misc_utils import find
from naff.models.discord.channel import GuildText
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Autoreact
from jarvis.data.unicode import emoji_list from jarvis.data.unicode import emoji_list
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class AutoReactCog(Scale): class AutoReactCog(Cog):
"""JARVIS Autoreact Cog.""" """JARVIS Autoreact Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.custom_emote = re.compile(r"^<:\w+:(\d+)>$") self.custom_emote = re.compile(r"^<:\w+:(\d+)>$")
@ -206,6 +206,6 @@ class AutoReactCog(Scale):
await ctx.send(message) await ctx.send(message)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add AutoReactCog to JARVIS""" """Add AutoReactCog to JARVIS"""
AutoReactCog(bot) AutoReactCog(bot)

View file

@ -1,24 +1,33 @@
"""JARVIS bot utility commands.""" """JARVIS bot utility commands."""
import logging import logging
import platform
from io import BytesIO from io import BytesIO
import psutil
from aiofile import AIOFile, LineReader from aiofile import AIOFile, LineReader
from dis_snek import MessageContext, Scale, Snake from naff import Client, Cog, PrefixedContext, prefixed_command
from dis_snek.models.discord.file import File from naff.models.discord.embed import EmbedField
from molter import msg_command from naff.models.discord.file import File
from rich.console import Console
from jarvis.utils import build_embed
from jarvis.utils.updates import update
class BotutilCog(Scale): class BotutilCog(Cog):
"""JARVIS Bot Utility Cog.""" """JARVIS Bot Utility Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.add_cog_check(self.is_owner)
@msg_command(name="tail") async def is_owner(self, ctx: PrefixedContext) -> bool:
async def _tail(self, ctx: MessageContext, count: int = 10) -> None: """Checks if author is bot owner."""
if ctx.author.id != self.bot.owner.id: return ctx.author.id == self.bot.owner.id
return
@prefixed_command(name="tail")
async def _tail(self, ctx: PrefixedContext, count: int = 10) -> None:
lines = [] lines = []
async with AIOFile("jarvis.log", "r") as af: async with AIOFile("jarvis.log", "r") as af:
async for line in LineReader(af): async for line in LineReader(af):
@ -35,11 +44,8 @@ class BotutilCog(Scale):
else: else:
await ctx.reply(content=f"```\n{log}\n```") await ctx.reply(content=f"```\n{log}\n```")
@msg_command(name="log") @prefixed_command(name="log")
async def _log(self, ctx: MessageContext) -> None: async def _log(self, ctx: PrefixedContext) -> None:
if ctx.author.id != self.bot.owner.id:
return
async with AIOFile("jarvis.log", "r") as af: async with AIOFile("jarvis.log", "r") as af:
with BytesIO() as file_bytes: with BytesIO() as file_bytes:
raw = await af.read_bytes() raw = await af.read_bytes()
@ -48,7 +54,67 @@ class BotutilCog(Scale):
log = File(file_bytes, file_name="jarvis.log") log = File(file_bytes, file_name="jarvis.log")
await ctx.reply(content="Here's the latest log", file=log) await ctx.reply(content="Here's the latest log", file=log)
@prefixed_command(name="crash")
async def _crash(self, ctx: PrefixedContext) -> None:
raise Exception("As you wish")
def setup(bot: Snake) -> None: @prefixed_command(name="sysinfo")
async def _sysinfo(self, ctx: PrefixedContext) -> None:
st_ts = int(self.bot.start_time.timestamp())
ut_ts = int(psutil.boot_time())
fields = (
EmbedField(name="Operation System", value=platform.system() or "Unknown", inline=False),
EmbedField(name="Version", value=platform.release() or "N/A", inline=False),
EmbedField(name="System Start Time", value=f"<t:{ut_ts}:F> (<t:{ut_ts}:R>)"),
EmbedField(name="Python Version", value=platform.python_version()),
EmbedField(name="Bot Start Time", value=f"<t:{st_ts}:F> (<t:{st_ts}:R>)"),
)
embed = build_embed(title="System Info", description="", fields=fields)
embed.set_image(url=self.bot.user.avatar.url)
await ctx.send(embed=embed)
@prefixed_command(name="update")
async def _update(self, ctx: PrefixedContext) -> None:
status = await update(self.bot)
if status:
console = Console()
with console.capture() as capture:
console.print(status.table)
self.logger.debug(capture.get())
self.logger.debug(len(capture.get()))
added = "\n".join(status.added)
removed = "\n".join(status.removed)
changed = "\n".join(status.changed)
fields = [
EmbedField(name="Old Commit", value=status.old_hash),
EmbedField(name="New Commit", value=status.new_hash),
]
if added:
fields.append(EmbedField(name="New Modules", value=f"```\n{added}\n```"))
if removed:
fields.append(EmbedField(name="Removed Modules", value=f"```\n{removed}\n```"))
if changed:
fields.append(EmbedField(name="Changed Modules", value=f"```\n{changed}\n```"))
embed = build_embed(
"Update Status", description="Updates have been applied", fields=fields
)
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
self.logger.info("Updates applied")
content = f"```ansi\n{capture.get()}\n```"
if len(content) < 3000:
await ctx.reply(content, embed=embed)
else:
await ctx.reply(f"Total Changes: {status.lines['total_lines']}", embed=embed)
else:
embed = build_embed(title="Update Status", description="No changes applied", fields=[])
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
await ctx.reply(embed=embed)
def setup(bot: Client) -> None:
"""Add BotutilCog to JARVIS""" """Add BotutilCog to JARVIS"""
BotutilCog(bot) BotutilCog(bot)

View file

@ -3,20 +3,20 @@ import logging
import re import re
import aiohttp import aiohttp
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.db import q
from dis_snek.ext.paginators import Paginator from jarvis_core.db.models import Guess
from dis_snek.models.discord.components import ActionRow, Button, ButtonStyles from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.embed import EmbedField from naff.ext.paginators import Paginator
from dis_snek.models.discord.user import Member, User from naff.models.discord.components import ActionRow, Button, ButtonStyles
from dis_snek.models.snek.application_commands import ( from naff.models.discord.embed import EmbedField
from naff.models.discord.user import Member, User
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import cooldown from naff.models.naff.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from jarvis_core.db import q
from jarvis_core.db.models import Guess
from jarvis.utils import build_embed from jarvis.utils import build_embed
@ -29,10 +29,10 @@ invites = re.compile(
) )
class CTCCog(Scale): class CTCCog(Cog):
"""JARVIS Complete the Code 2 Cog.""" """JARVIS Complete the Code 2 Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
@ -112,13 +112,11 @@ class CTCCog(Scale):
guesses = Guess.find().sort("correct", -1).sort("id", -1) guesses = Guess.find().sort("correct", -1).sort("id", -1)
fields = [] fields = []
async for guess in guesses: async for guess in guesses:
user = await ctx.guild.get_member(guess["user"])
if not user:
user = await self.bot.fetch_user(guess["user"]) user = await self.bot.fetch_user(guess["user"])
if not user: if not user:
user = "[redacted]" user = "[redacted]"
if isinstance(user, (Member, User)): if isinstance(user, (Member, User)):
user = user.name + "#" + user.discriminator user = user.username + "#" + user.discriminator
name = "Correctly" if guess["correct"] else "Incorrectly" name = "Correctly" if guess["correct"] else "Incorrectly"
name += " guessed by: " + user name += " guessed by: " + user
fields.append( fields.append(
@ -132,9 +130,9 @@ class CTCCog(Scale):
for i in range(0, len(fields), 5): for i in range(0, len(fields), 5):
embed = build_embed( embed = build_embed(
title="completethecodetwo.cards guesses", title="completethecodetwo.cards guesses",
description=f"{len(fields)} guesses so far", description=f"**{len(fields)} guesses so far**",
fields=fields[i : i + 5], fields=fields[i : i + 5],
url="https://completethecodetwo.cards", url="https://ctc2.zevaryx.com/gueses",
) )
embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png")
embed.set_footer( embed.set_footer(
@ -148,6 +146,6 @@ class CTCCog(Scale):
await paginator.send(ctx) await paginator.send(ctx)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add CTCCog to JARVIS""" """Add CTCCog to JARVIS"""
CTCCog(bot) CTCCog(bot)

View file

@ -3,37 +3,37 @@ import logging
import re import re
import aiohttp import aiohttp
from dis_snek import InteractionContext, Scale, Snake from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.embed import EmbedField from naff.models.discord.embed import EmbedField
from dis_snek.models.snek.application_commands import ( from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import cooldown from naff.models.naff.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from jarvis.config import get_config from jarvis.config import JarvisConfig
from jarvis.data.dbrand import shipping_lookup from jarvis.data.dbrand import shipping_lookup
from jarvis.utils import build_embed from jarvis.utils import build_embed
guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668] guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668]
class DbrandCog(Scale): class DbrandCog(Cog):
""" """
dbrand functions for JARVIS dbrand functions for JARVIS
Mostly support functions. Credit @cpixl for the shipping API Mostly support functions. Credit @cpixl for the shipping API
""" """
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.base_url = "https://dbrand.com/" self.base_url = "https://dbrand.com/"
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
self._session.headers.update({"Content-Type": "application/json"}) self._session.headers.update({"Content-Type": "application/json"})
self.api_url = get_config().urls["dbrand_shipping"] self.api_url = JarvisConfig.from_yaml().urls["dbrand_shipping"]
self.cache = {} self.cache = {}
def __del__(self): def __del__(self):
@ -197,6 +197,6 @@ class DbrandCog(Scale):
await ctx.send(embed=embed) await ctx.send(embed=embed)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add dbrandcog to JARVIS""" """Add dbrandcog to JARVIS"""
DbrandCog(bot) DbrandCog(bot)

View file

@ -8,20 +8,20 @@ import uuid as uuidpy
import ulid as ulidpy import ulid as ulidpy
from bson import ObjectId from bson import ObjectId
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.filters import invites, url
from dis_snek.models.discord.embed import EmbedField from jarvis_core.util import convert_bytesize, hash
from dis_snek.models.discord.message import Attachment from jarvis_core.util.http import get_size
from dis_snek.models.snek.application_commands import ( from naff import Client, Cog, InteractionContext
from naff.models.discord.embed import EmbedField
from naff.models.discord.message import Attachment
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommandChoice, SlashCommandChoice,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import cooldown from naff.models.naff.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from jarvis_core.filters import invites, url
from jarvis_core.util import convert_bytesize, hash
from jarvis_core.util.http import get_size
from jarvis.utils import build_embed from jarvis.utils import build_embed
@ -45,10 +45,10 @@ UUID_GET = {3: uuidpy.uuid3, 5: uuidpy.uuid5}
MAX_FILESIZE = 5 * (1024**3) # 5GB MAX_FILESIZE = 5 * (1024**3) # 5GB
class DevCog(Scale): class DevCog(Cog):
"""JARVIS Developer Cog.""" """JARVIS Developer Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -272,9 +272,9 @@ class DevCog(Scale):
output = subprocess.check_output( # noqa: S603, S607 output = subprocess.check_output( # noqa: S603, S607
["tokei", "-C", "--sort", "code"] ["tokei", "-C", "--sort", "code"]
).decode("UTF-8") ).decode("UTF-8")
await ctx.send(f"```\n{output}\n```") await ctx.send(f"```haskell\n{output}\n```")
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add DevCog to JARVIS""" """Add DevCog to JARVIS"""
DevCog(bot) DevCog(bot)

View file

@ -4,20 +4,20 @@ import logging
from datetime import datetime from datetime import datetime
import gitlab import gitlab
from dis_snek import InteractionContext, Scale, Snake from naff import Client, Cog, InteractionContext
from dis_snek.ext.paginators import Paginator from naff.ext.paginators import Paginator
from dis_snek.models.discord.embed import Embed, EmbedField from naff.models.discord.embed import Embed, EmbedField
from dis_snek.models.discord.modal import InputText, Modal, TextStyles from naff.models.discord.modal import InputText, Modal, TextStyles
from dis_snek.models.discord.user import Member from naff.models.discord.user import Member
from dis_snek.models.snek.application_commands import ( from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
SlashCommandChoice, SlashCommandChoice,
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import cooldown from naff.models.naff.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from jarvis.config import JarvisConfig from jarvis.config import JarvisConfig
from jarvis.utils import build_embed from jarvis.utils import build_embed
@ -25,10 +25,10 @@ from jarvis.utils import build_embed
guild_ids = [862402786116763668] guild_ids = [862402786116763668]
class GitlabCog(Scale): class GitlabCog(Cog):
"""JARVIS GitLab Cog.""" """JARVIS GitLab Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml() config = JarvisConfig.from_yaml()
@ -62,14 +62,16 @@ class GitlabCog(Scale):
labels = issue.labels labels = issue.labels
if labels: if labels:
labels = "\n".join(issue.labels) labels = "\n".join(issue.labels)
if not labels: else:
labels = "None" labels = "None"
fields = [ fields = [
EmbedField(name="State", value=issue.state[0].upper() + issue.state[1:]), EmbedField(name="State", value=issue.state.title()),
EmbedField(name="Assignee", value=assignee), EmbedField(name="Assignee", value=assignee),
EmbedField(name="Labels", value=labels), EmbedField(name="Labels", value=labels),
] ]
color = "#FC6D27"
if issue.labels:
color = self.project.labels.get(issue.labels[0]).color color = self.project.labels.get(issue.labels[0]).color
fields.append(EmbedField(name="Created At", value=created_at)) fields.append(EmbedField(name="Created At", value=created_at))
if issue.state == "closed": if issue.state == "closed":
@ -463,7 +465,7 @@ class GitlabCog(Scale):
await resp.send(embed=embed) await resp.send(embed=embed)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add GitlabCog to JARVIS if Gitlab token exists.""" """Add GitlabCog to JARVIS if Gitlab token exists."""
if JarvisConfig.from_yaml().gitlab_token: if JarvisConfig.from_yaml().gitlab_token:
GitlabCog(bot) GitlabCog(bot)

View file

@ -6,28 +6,30 @@ from io import BytesIO
import aiohttp import aiohttp
import cv2 import cv2
import numpy as np import numpy as np
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.util import convert_bytesize, unconvert_bytesize
from dis_snek.models.discord.embed import EmbedField from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.file import File from naff.models.discord.embed import EmbedField
from dis_snek.models.discord.message import Attachment from naff.models.discord.file import File
from dis_snek.models.snek.application_commands import ( from naff.models.discord.message import Attachment
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
slash_command, slash_command,
slash_option, slash_option,
) )
from jarvis_core.util import build_embed, convert_bytesize, unconvert_bytesize
from jarvis.utils import build_embed
MIN_ACCURACY = 0.80 MIN_ACCURACY = 0.80
class ImageCog(Scale): class ImageCog(Cog):
""" """
Image processing functions for JARVIS Image processing functions for JARVIS
May be categorized under util later May be categorized under util later
""" """
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
@ -83,7 +85,7 @@ class ImageCog(Scale):
if tgt_size > unconvert_bytesize(8, "MB"): if tgt_size > unconvert_bytesize(8, "MB"):
await ctx.send("Target too large to send. Please make target < 8MB", ephemeral=True) await ctx.send("Target too large to send. Please make target < 8MB", ephemeral=True)
return return
elif tgt_size < 1024: if tgt_size < 1024:
await ctx.send("Sizes < 1KB are extremely unreliable and are disabled", ephemeral=True) await ctx.send("Sizes < 1KB are extremely unreliable and are disabled", ephemeral=True)
return return
@ -151,6 +153,6 @@ class ImageCog(Scale):
) )
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add ImageCog to JARVIS""" """Add ImageCog to JARVIS"""
ImageCog(bot) ImageCog(bot)

425
jarvis/cogs/reddit.py Normal file
View file

@ -0,0 +1,425 @@
"""JARVIS Reddit cog."""
import asyncio
import logging
from typing import List, Optional
from asyncpraw import Reddit
from asyncpraw.models.reddit.submission import Submission
from asyncpraw.models.reddit.submission import Subreddit as Sub
from asyncprawcore.exceptions import Forbidden, NotFound, Redirect
from jarvis_core.db import q
from jarvis_core.db.models import Subreddit, SubredditFollow
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import get
from naff.models.discord.channel import ChannelTypes, GuildText
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.embed import Embed, EmbedField
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
SlashCommandChoice,
slash_option,
)
from naff.models.naff.command import check
from jarvis import const
from jarvis.config import JarvisConfig
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
DEFAULT_USER_AGENT = f"python:JARVIS:{const.__version__} (by u/zevaryx)"
class RedditCog(Cog):
"""JARVIS Reddit Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml()
config.reddit["user_agent"] = config.reddit.get("user_agent", DEFAULT_USER_AGENT)
self.api = Reddit(**config.reddit)
async def post_embeds(self, sub: Sub, post: Submission) -> Optional[List[Embed]]:
"""
Build a post embeds.
Args:
post: Post to build embeds
"""
url = "https://reddit.com" + post.permalink
await post.author.load()
author_url = f"https://reddit.com/u/{post.author.name}"
author_icon = post.author.icon_img
images = []
title = f"{post.title}"
fields = []
content = ""
og_post = None
if not post.is_self:
og_post = post # noqa: F841
post = await self.api.submission(post.crosspost_parent_list[0]["id"])
await post.load()
fields.append(EmbedField(name="Crossposted From", value=post.subreddit_name_prefixed))
content = f"> **{post.title}**"
if "url" in vars(post):
if any(post.url.endswith(x) for x in ["jpeg", "jpg", "png", "gif"]):
images = [post.url]
if "media_metadata" in vars(post):
for k, v in post.media_metadata.items():
if v["status"] != "valid" or v["m"] not in ["image/jpg", "image/png", "image/gif"]:
continue
ext = v["m"].split("/")[-1]
i_url = f"https://i.redd.it/{k}.{ext}"
images.append(i_url)
if len(images) == 4:
break
if "selftext" in vars(post) and post.selftext:
content += "\n\n" + post.selftext
if len(content) > 900:
content = content[:900] + "..."
content += f"\n\n[View this post]({url})"
if not images and not content:
self.logger.debug(f"Post {post.id} had neither content nor images?")
return None
color = "#FF4500"
if "primary_color" in vars(sub):
color = sub.primary_color
base_embed = build_embed(
title=title,
description=content,
fields=fields,
timestamp=post.created_utc,
url=url,
color=color,
)
base_embed.set_author(
name="u/" + post.author.name, url=author_url, icon_url=author_icon
)
base_embed.set_footer(
text="Reddit", icon_url="https://www.redditinc.com/assets/images/site/reddit-logo.png"
)
embeds = [base_embed]
if len(images) > 0:
embeds[0].set_image(url=images[0])
for image in images[1:4]:
embed = Embed(url=url)
embed.set_image(url=image)
embeds.append(embed)
return embeds
reddit = SlashCommand(name="reddit", description="Manage Reddit follows")
@reddit.subcommand(sub_cmd_name="follow", sub_cmd_description="Follow a Subreddit")
@slash_option(
name="name",
description="Subreddit display name",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="channel",
description="Channel to post to",
opt_type=OptionTypes.CHANNEL,
channel_types=[ChannelTypes.GUILD_TEXT],
required=True,
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _reddit_follow(self, ctx: InteractionContext, name: str, channel: GuildText) -> None:
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a text channel", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} on add")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
exists = await SubredditFollow.find_one(
q(display_name=subreddit.display_name, guild=ctx.guild.id)
)
if exists:
await ctx.send("Subreddit already being followed in this guild", ephemeral=True)
return
count = len([i async for i in SubredditFollow.find(q(guild=ctx.guild.id))])
if count >= 12:
await ctx.send("Cannot follow more than 12 Subreddits", ephemeral=True)
return
if subreddit.over18 and not channel.nsfw:
await ctx.send(
"Subreddit is nsfw, but channel is not. Mark the channel NSFW first.",
ephemeral=True,
)
return
sr = await Subreddit.find_one(q(display_name=subreddit.display_name))
if not sr:
sr = Subreddit(display_name=subreddit.display_name, over18=subreddit.over18)
await sr.commit()
srf = SubredditFollow(
display_name=subreddit.display_name,
channel=channel.id,
guild=ctx.guild.id,
admin=ctx.author.id,
)
await srf.commit()
await ctx.send(f"Now following `r/{name}` in {channel.mention}")
@reddit.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Subreddits")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _subreddit_unfollow(self, ctx: InteractionContext) -> None:
subs = SubredditFollow.find(q(guild=ctx.guild.id))
subreddits = []
async for sub in subs:
subreddits.append(sub)
if not subreddits:
await ctx.send("You need to follow a Subreddit first", ephemeral=True)
return
options = []
names = []
for idx, subreddit in enumerate(subreddits):
sub = await Subreddit.find_one(q(display_name=subreddit.display_name))
names.append(sub.display_name)
option = SelectOption(label=sub.display_name, value=str(idx))
options.append(option)
select = Select(
options=options, custom_id="to_delete", min_values=1, max_values=len(subreddits)
)
components = [ActionRow(select)]
block = "\n".join(x for x in names)
message = await ctx.send(
content=f"You are following the following subreddits:\n```\n{block}\n```\n\n"
"Please choose subreddits to unfollow",
components=components,
)
try:
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
for to_delete in context.context.values:
follow = get(subreddits, guild=ctx.guild.id, display_name=names[int(to_delete)])
try:
await follow.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
for row in components:
for component in row.components:
component.disabled = True
block = "\n".join(names[int(x)] for x in context.context.values)
await context.context.edit_origin(
content=f"Unfollowed the following:\n```\n{block}\n```", components=components
)
except asyncio.TimeoutError:
for row in components:
for component in row.components:
component.disabled = True
await message.edit(components=components)
@reddit.subcommand(sub_cmd_name="hot", sub_cmd_description="Get the hot post of a subreddit")
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_hot(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in hot")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.hot(limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(sub_cmd_name="top", sub_cmd_description="Get the top post of a subreddit")
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="time",
description="Top time",
opt_type=OptionTypes.STRING,
required=False,
choices=[
SlashCommandChoice(name="All", value="all"),
SlashCommandChoice(name="Day", value="day"),
SlashCommandChoice(name="Hour", value="hour"),
SlashCommandChoice(name="Month", value="month"),
SlashCommandChoice(name="Week", value="week"),
SlashCommandChoice(name="Year", value="year"),
],
)
async def _subreddit_top(self, ctx: InteractionContext, name: str, time: str = "all") -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in top")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.top(time_filter=time, limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(
sub_cmd_name="random", sub_cmd_description="Get a random post of a subreddit"
)
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_random(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in random")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = await subreddit.random()
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(
sub_cmd_name="rising", sub_cmd_description="Get a rising post of a subreddit"
)
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_rising(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in rising")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.rising(limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(sub_cmd_name="post", sub_cmd_description="Get a specific submission")
@slash_option(
name="sid", description="Submission ID", opt_type=OptionTypes.STRING, required=True
)
async def _reddit_post(self, ctx: InteractionContext, sid: str) -> None:
await ctx.defer()
try:
post = await self.api.submission(sid)
await post.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Submission {sid} raised {e.__class__.__name__} in post")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
embeds = await self.post_embeds(post.subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
def setup(bot: Client) -> None:
"""Add RedditCog to JARVIS"""
if JarvisConfig.from_yaml().reddit:
RedditCog(bot)

View file

@ -8,20 +8,20 @@ from typing import List
from bson import ObjectId from bson import ObjectId
from dateparser import parse from dateparser import parse
from dateparser_data.settings import default_parsers from dateparser_data.settings import default_parsers
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import get from jarvis_core.db.models import Reminder
from dis_snek.models.discord.channel import GuildChannel from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.components import ActionRow, Select, SelectOption from naff.client.utils.misc_utils import get
from dis_snek.models.discord.embed import Embed, EmbedField from naff.models.discord.channel import GuildChannel
from dis_snek.models.discord.modal import InputText, Modal, TextStyles from naff.models.discord.components import ActionRow, Select, SelectOption
from dis_snek.models.snek.application_commands import ( from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.modal import InputText, Modal, TextStyles
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_command, slash_command,
slash_option, slash_option,
) )
from jarvis_core.db import q
from jarvis_core.db.models import Reminder
from jarvis.utils import build_embed from jarvis.utils import build_embed
@ -33,10 +33,10 @@ invites = re.compile(
) )
class RemindmeCog(Scale): class RemindmeCog(Cog):
"""JARVIS Remind Me Cog.""" """JARVIS Remind Me Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -329,13 +329,13 @@ class RemindmeCog(Scale):
embed.set_thumbnail(url=ctx.author.display_avatar) embed.set_thumbnail(url=ctx.author.display_avatar)
await ctx.send(embed=embed, ephemeral=reminder.private) await ctx.send(embed=embed, ephemeral=reminder.private)
if reminder.remind_at <= datetime.now(tz=timezone.utc): if reminder.remind_at <= datetime.now(tz=timezone.utc) and not reminder.active:
try: try:
await reminder.delete() await reminder.delete()
except Exception: except Exception:
self.logger.debug("Ignoring deletion error") self.logger.debug("Ignoring deletion error")
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add RemindmeCog to JARVIS""" """Add RemindmeCog to JARVIS"""
RemindmeCog(bot) RemindmeCog(bot)

View file

@ -2,29 +2,29 @@
import asyncio import asyncio
import logging import logging
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import get from jarvis_core.db.models import Rolegiver
from dis_snek.models.discord.components import ActionRow, Select, SelectOption from naff import Client, Cog, InteractionContext, Permissions
from dis_snek.models.discord.embed import EmbedField from naff.client.utils.misc_utils import get
from dis_snek.models.discord.role import Role from naff.models.discord.components import ActionRow, Select, SelectOption
from dis_snek.models.snek.application_commands import ( from naff.models.discord.embed import EmbedField
from naff.models.discord.role import Role
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check, cooldown from naff.models.naff.command import check, cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from jarvis_core.db import q
from jarvis_core.db.models import Rolegiver
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class RolegiverCog(Scale): class RolegiverCog(Cog):
"""JARVIS Role Giver Cog.""" """JARVIS Role Giver Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -41,14 +41,23 @@ class RolegiverCog(Scale):
await ctx.send("Cannot add `@everyone` to rolegiver", ephemeral=True) await ctx.send("Cannot add `@everyone` to rolegiver", ephemeral=True)
return return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
return
setting = await Rolegiver.find_one(q(guild=ctx.guild.id)) setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if setting and role.id in setting.roles: if setting and setting.roles and role.id in setting.roles:
await ctx.send("Role already in rolegiver", ephemeral=True) await ctx.send("Role already in rolegiver", ephemeral=True)
return return
if not setting: if not setting:
setting = Rolegiver(guild=ctx.guild.id, roles=[]) setting = Rolegiver(guild=ctx.guild.id, roles=[])
setting.roles = setting.roles or []
if len(setting.roles) >= 20: if len(setting.roles) >= 20:
await ctx.send("You can only have 20 roles in the rolegiver", ephemeral=True) await ctx.send("You can only have 20 roles in the rolegiver", ephemeral=True)
return return
@ -378,6 +387,6 @@ class RolegiverCog(Scale):
await ctx.send("Rolegiver cleanup finished") await ctx.send("Rolegiver cleanup finished")
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add RolegiverCog to JARVIS""" """Add RolegiverCog to JARVIS"""
RolegiverCog(bot) RolegiverCog(bot)

View file

@ -3,29 +3,29 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
from dis_snek import InteractionContext, Scale, Snake from jarvis_core.db import q
from dis_snek.models.discord.channel import GuildText from jarvis_core.db.models import Setting
from dis_snek.models.discord.components import ActionRow, Button, ButtonStyles from naff import Client, Cog, InteractionContext
from dis_snek.models.discord.embed import EmbedField from naff.models.discord.channel import GuildText
from dis_snek.models.discord.enums import Permissions from naff.models.discord.components import ActionRow, Button, ButtonStyles
from dis_snek.models.discord.role import Role from naff.models.discord.embed import EmbedField
from dis_snek.models.snek.application_commands import ( from naff.models.discord.enums import Permissions
from naff.models.discord.role import Role
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Setting
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class SettingsCog(Scale): class SettingsCog(Cog):
"""JARVIS Settings Management Cog.""" """JARVIS Settings Management Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -105,6 +105,12 @@ class SettingsCog(Scale):
if role.id == ctx.guild.id: if role.id == ctx.guild.id:
await ctx.send("Cannot set verified to `@everyone`", ephemeral=True) await ctx.send("Cannot set verified to `@everyone`", ephemeral=True)
return return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
return
await ctx.defer() await ctx.defer()
await self.update_settings("verified", role.id, ctx.guild.id) await self.update_settings("verified", role.id, ctx.guild.id)
await ctx.send(f"Settings applied. New verified role is `{role.name}`") await ctx.send(f"Settings applied. New verified role is `{role.name}`")
@ -118,6 +124,12 @@ class SettingsCog(Scale):
if role.id == ctx.guild.id: if role.id == ctx.guild.id:
await ctx.send("Cannot set unverified to `@everyone`", ephemeral=True) await ctx.send("Cannot set unverified to `@everyone`", ephemeral=True)
return return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
return
await ctx.defer() await ctx.defer()
await self.update_settings("unverified", role.id, ctx.guild.id) await self.update_settings("unverified", role.id, ctx.guild.id)
await ctx.send(f"Settings applied. New unverified role is `{role.name}`") await ctx.send(f"Settings applied. New unverified role is `{role.name}`")
@ -169,14 +181,11 @@ class SettingsCog(Scale):
await ctx.send("Setting `massmention` unset") await ctx.send("Setting `massmention` unset")
@unset.subcommand(sub_cmd_name="verified", sub_cmd_description="Unset verified role") @unset.subcommand(sub_cmd_name="verified", sub_cmd_description="Unset verified role")
@slash_option(
name="role", description="Verified role", opt_type=OptionTypes.ROLE, required=True
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD)) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_verified(self, ctx: InteractionContext) -> None: async def _unset_verified(self, ctx: InteractionContext) -> None:
await ctx.defer() await ctx.defer()
await self.delete_settings("verified", ctx.guild.id) await self.delete_settings("verified", ctx.guild.id)
await ctx.send("Setting `massmention` unset") await ctx.send("Setting `verified` unset")
@unset.subcommand(sub_cmd_name="unverified", sub_cmd_description="Unset unverified role") @unset.subcommand(sub_cmd_name="unverified", sub_cmd_description="Unset unverified role")
@check(admin_or_permissions(Permissions.MANAGE_GUILD)) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
@ -210,7 +219,11 @@ class SettingsCog(Scale):
async for setting in settings: async for setting in settings:
value = setting.value value = setting.value
if setting.setting in ["unverified", "verified", "mute"]: if setting.setting in ["unverified", "verified", "mute"]:
try:
value = await ctx.guild.fetch_role(value) value = await ctx.guild.fetch_role(value)
except KeyError:
await setting.delete()
continue
if value: if value:
value = value.mention value = value.mention
else: else:
@ -269,6 +282,6 @@ class SettingsCog(Scale):
await message.edit(content="Guild settings not cleared", components=components) await message.edit(content="Guild settings not cleared", components=components)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add SettingsCog to JARVIS""" """Add SettingsCog to JARVIS"""
SettingsCog(bot) SettingsCog(bot)

View file

@ -1,20 +1,20 @@
"""JARVIS Starboard Cog.""" """JARVIS Starboard Cog."""
import logging import logging
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.models.discord.channel import GuildText from jarvis_core.db.models import Star, Starboard
from dis_snek.models.discord.components import ActionRow, Select, SelectOption from naff import Client, Cog, InteractionContext, Permissions
from dis_snek.models.discord.message import Message from naff.models.discord.channel import GuildText
from dis_snek.models.snek.application_commands import ( from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.message import Message
from naff.models.naff.application_commands import (
CommandTypes, CommandTypes,
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
context_menu, context_menu,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import Star, Starboard
from jarvis.utils import build_embed from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
@ -28,10 +28,10 @@ supported_images = [
] ]
class StarboardCog(Scale): class StarboardCog(Cog):
"""JARVIS Starboard Cog.""" """JARVIS Starboard Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -135,7 +135,7 @@ class StarboardCog(Scale):
if c and isinstance(c, GuildText): if c and isinstance(c, GuildText):
channel_list.append(c) channel_list.append(c)
else: else:
self.logger.warn( self.logger.warning(
f"Starboard {starboard.channel} no longer valid in {ctx.guild.name}" f"Starboard {starboard.channel} no longer valid in {ctx.guild.name}"
) )
to_delete.append(starboard) to_delete.append(starboard)
@ -318,6 +318,6 @@ class StarboardCog(Scale):
await ctx.send(f"Star {id} deleted from {starboard.mention}") await ctx.send(f"Star {id} deleted from {starboard.mention}")
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add StarboardCog to JARVIS""" """Add StarboardCog to JARVIS"""
StarboardCog(bot) StarboardCog(bot)

126
jarvis/cogs/temprole.py Normal file
View file

@ -0,0 +1,126 @@
"""JARVIS temporary role handler."""
import logging
from datetime import datetime, timezone
from dateparser import parse
from dateparser_data.settings import default_parsers
from jarvis_core.db.models import Temprole
from naff import Client, Cog, InteractionContext, Permissions
from naff.models.discord.embed import EmbedField
from naff.models.discord.role import Role
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
class TemproleCog(Cog):
"""JARVIS Temporary Role Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@slash_command(name="temprole", description="Give a user a temporary role")
@slash_option(
name="user", description="User to grant role", opt_type=OptionTypes.USER, required=True
)
@slash_option(
name="role", description="Role to grant", opt_type=OptionTypes.ROLE, required=True
)
@slash_option(
name="duration",
description="Duration of temp role (i.e. 2 hours)",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="reason",
description="Reason for temporary role",
opt_type=OptionTypes.STRING,
required=False,
)
@check(admin_or_permissions(Permissions.MANAGE_ROLES))
async def _temprole(
self, ctx: InteractionContext, user: Member, role: Role, duration: str, reason: str = None
) -> None:
await ctx.defer()
if not isinstance(user, Member):
await ctx.send("User not in guild", ephemeral=True)
return
if role.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` to users", ephemeral=True)
return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
return
base_settings = {
"PREFER_DATES_FROM": "future",
"TIMEZONE": "UTC",
"RETURN_AS_TIMEZONE_AWARE": True,
}
rt_settings = base_settings.copy()
rt_settings["PARSERS"] = [
x for x in default_parsers if x not in ["absolute-time", "timestamp"]
]
rt_duration = parse(duration, settings=rt_settings)
at_settings = base_settings.copy()
at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"]
at_duration = parse(duration, settings=at_settings)
if rt_duration:
duration = rt_duration
elif at_duration:
duration = at_duration
else:
self.logger.debug(f"Failed to parse duration: {duration}")
await ctx.send(f"`{duration}` is not a parsable date, please try again", ephemeral=True)
return
if duration < datetime.now(tz=timezone.utc):
await ctx.send(
f"`{duration}` is in the past. Past durations aren't allowed", ephemeral=True
)
return
await user.add_role(role, reason=reason)
await Temprole(
guild=ctx.guild.id, user=user.id, role=role.id, admin=ctx.author.id, expires_at=duration
).commit()
ts = int(duration.timestamp())
fields = (
EmbedField(name="Role", value=role.mention),
EmbedField(name="Valid Until", value=f"<t:{ts}:F> (<t:{ts}:R>)"),
)
embed = build_embed(
title="Role granted",
description=f"Role temporarily granted to {user.mention}",
fields=fields,
)
embed.set_author(
name=f"{user.username}#{user.discriminator}", icon_url=user.display_avatar.url
)
await ctx.send(embed=embed)
def setup(bot: Client) -> None:
"""Add TemproleCog to JARVIS"""
TemproleCog(bot)

View file

@ -3,27 +3,27 @@ import asyncio
import logging import logging
import tweepy import tweepy
from dis_snek import InteractionContext, Permissions, Scale, Snake from jarvis_core.db import q
from dis_snek.client.utils.misc_utils import get from jarvis_core.db.models import TwitterAccount, TwitterFollow
from dis_snek.models.discord.channel import GuildText from naff import Client, Cog, InteractionContext, Permissions
from dis_snek.models.discord.components import ActionRow, Select, SelectOption from naff.client.utils.misc_utils import get
from dis_snek.models.snek.application_commands import ( from naff.models.discord.channel import GuildText
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.naff.application_commands import (
OptionTypes, OptionTypes,
SlashCommand, SlashCommand,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import check from naff.models.naff.command import check
from jarvis_core.db import q
from jarvis_core.db.models import TwitterAccount, TwitterFollow
from jarvis.config import JarvisConfig from jarvis.config import JarvisConfig
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class TwitterCog(Scale): class TwitterCog(Cog):
"""JARVIS Twitter Cog.""" """JARVIS Twitter Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml() config = JarvisConfig.from_yaml()
@ -63,7 +63,7 @@ class TwitterCog(Scale):
self, ctx: InteractionContext, handle: str, channel: GuildText, retweets: bool = True self, ctx: InteractionContext, handle: str, channel: GuildText, retweets: bool = True
) -> None: ) -> None:
handle = handle.lower() handle = handle.lower()
if len(handle) > 15: if len(handle) > 15 or len(handle) < 4:
await ctx.send("Invalid Twitter handle", ephemeral=True) await ctx.send("Invalid Twitter handle", ephemeral=True)
return return
@ -80,16 +80,16 @@ class TwitterCog(Scale):
) )
return return
count = len([i async for i in TwitterFollow.find(q(guild=ctx.guild.id))])
if count >= 12:
await ctx.send("Cannot follow more than 12 Twitter accounts", ephemeral=True)
return
exists = await TwitterFollow.find_one(q(twitter_id=account.id, guild=ctx.guild.id)) exists = await TwitterFollow.find_one(q(twitter_id=account.id, guild=ctx.guild.id))
if exists: if exists:
await ctx.send("Twitter account already being followed in this guild", ephemeral=True) await ctx.send("Twitter account already being followed in this guild", ephemeral=True)
return return
count = len([i async for i in TwitterFollow.find(q(guild=ctx.guild.id))])
if count >= 12:
await ctx.send("Cannot follow more than 12 Twitter accounts", ephemeral=True)
return
ta = await TwitterAccount.find_one(q(twitter_id=account.id)) ta = await TwitterAccount.find_one(q(twitter_id=account.id))
if not ta: if not ta:
ta = TwitterAccount( ta = TwitterAccount(
@ -243,6 +243,7 @@ class TwitterCog(Scale):
await message.edit(components=components) await message.edit(components=components)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add TwitterCog to JARVIS""" """Add TwitterCog to JARVIS"""
if JarvisConfig.from_yaml().twitter:
TwitterCog(bot) TwitterCog(bot)

View file

@ -1,6 +1,5 @@
"""JARVIS Utility Cog.""" """JARVIS Utility Cog."""
import logging import logging
import platform
import re import re
import secrets import secrets
import string import string
@ -9,14 +8,14 @@ from io import BytesIO
import numpy as np import numpy as np
from dateparser import parse from dateparser import parse
from dis_snek import InteractionContext, Scale, Snake, const from naff import Client, Cog, InteractionContext, const
from dis_snek.models.discord.channel import GuildCategory, GuildText, GuildVoice from naff.models.discord.channel import GuildCategory, GuildText, GuildVoice
from dis_snek.models.discord.embed import EmbedField from naff.models.discord.embed import EmbedField
from dis_snek.models.discord.file import File from naff.models.discord.file import File
from dis_snek.models.discord.guild import Guild from naff.models.discord.guild import Guild
from dis_snek.models.discord.role import Role from naff.models.discord.role import Role
from dis_snek.models.discord.user import Member, User from naff.models.discord.user import Member, User
from dis_snek.models.snek.application_commands import ( from naff.models.naff.application_commands import (
CommandTypes, CommandTypes,
OptionTypes, OptionTypes,
SlashCommandChoice, SlashCommandChoice,
@ -24,12 +23,12 @@ from dis_snek.models.snek.application_commands import (
slash_command, slash_command,
slash_option, slash_option,
) )
from dis_snek.models.snek.command import cooldown from naff.models.naff.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets from naff.models.naff.cooldowns import Buckets
from PIL import Image from PIL import Image
from tzlocal import get_localzone from tzlocal import get_localzone
import jarvis from jarvis import const as jconst
from jarvis.data import pigpen from jarvis.data import pigpen
from jarvis.data.robotcamo import emotes, hk, names from jarvis.data.robotcamo import emotes, hk, names
from jarvis.utils import build_embed, get_repo_hash from jarvis.utils import build_embed, get_repo_hash
@ -37,14 +36,14 @@ from jarvis.utils import build_embed, get_repo_hash
JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA") JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA")
class UtilCog(Scale): class UtilCog(Cog):
""" """
Utility functions for JARVIS Utility functions for JARVIS
Mostly system utility functions, but may change over time Mostly system utility functions, but may change over time
""" """
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -55,10 +54,12 @@ class UtilCog(Scale):
desc = f"All systems online\nConnected to **{len(self.bot.guilds)}** guilds" desc = f"All systems online\nConnected to **{len(self.bot.guilds)}** guilds"
color = "#3498db" color = "#3498db"
fields = [] fields = []
uptime = int(self.bot.start_time.timestamp())
fields.append(EmbedField(name="dis-snek", value=const.__version__)) fields.append(EmbedField(name="Version", value=jconst.__version__, inline=True))
fields.append(EmbedField(name="Version", value=jarvis.__version__, inline=False)) fields.append(EmbedField(name="naff", value=const.__version__, inline=True))
fields.append(EmbedField(name="Git Hash", value=get_repo_hash()[:7], inline=False)) fields.append(EmbedField(name="Git Hash", value=get_repo_hash()[:7], inline=True))
fields.append(EmbedField(name="Online Since", value=f"<t:{uptime}:F>", inline=False))
num_domains = len(self.bot.phishing_domains) num_domains = len(self.bot.phishing_domains)
fields.append( fields.append(
EmbedField( EmbedField(
@ -153,6 +154,7 @@ class UtilCog(Scale):
EmbedField(name="Position", value=str(role.position), inline=True), EmbedField(name="Position", value=str(role.position), inline=True),
EmbedField(name="Mentionable", value="Yes" if role.mentionable else "No", inline=True), EmbedField(name="Mentionable", value="Yes" if role.mentionable else "No", inline=True),
EmbedField(name="Member Count", value=str(len(role.members)), inline=True), EmbedField(name="Member Count", value=str(len(role.members)), inline=True),
EmbedField(name="Created At", value=f"<t:{int(role.created_at.timestamp())}:F>"),
] ]
embed = build_embed( embed = build_embed(
title="", title="",
@ -190,18 +192,15 @@ class UtilCog(Scale):
user_roles = user.roles user_roles = user.roles
if user_roles: if user_roles:
user_roles = sorted(user.roles, key=lambda x: -x.position) user_roles = sorted(user.roles, key=lambda x: -x.position)
format_string = "%a, %b %-d, %Y %-I:%M %p"
if platform.system() == "Windows":
format_string = "%a, %b %#d, %Y %#I:%M %p"
fields = [ fields = [
EmbedField( EmbedField(
name="Joined", name="Joined",
value=user.joined_at.strftime(format_string), value=f"<t:{int(user.joined_at.timestamp())}:F>",
), ),
EmbedField( EmbedField(
name="Registered", name="Registered",
value=user.created_at.strftime(format_string), value=f"<t:{int(user.created_at.timestamp())}:F>",
), ),
EmbedField( EmbedField(
name=f"Roles [{len(user_roles)}]", name=f"Roles [{len(user_roles)}]",
@ -267,6 +266,7 @@ class UtilCog(Scale):
EmbedField(name="Threads", value=str(threads), inline=True), EmbedField(name="Threads", value=str(threads), inline=True),
EmbedField(name="Members", value=str(members), inline=True), EmbedField(name="Members", value=str(members), inline=True),
EmbedField(name="Roles", value=str(roles), inline=True), EmbedField(name="Roles", value=str(roles), inline=True),
EmbedField(name="Created At", value=f"<t:{int(guild.created_at.timestamp())}:F>"),
] ]
if len(role_list) < 1024: if len(role_list) < 1024:
fields.append(EmbedField(name="Role List", value=role_list, inline=False)) fields.append(EmbedField(name="Role List", value=role_list, inline=False))
@ -375,6 +375,6 @@ class UtilCog(Scale):
await ctx.send(embed=embed, ephemeral=private) await ctx.send(embed=embed, ephemeral=private)
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add UtilCog to JARVIS""" """Add UtilCog to JARVIS"""
UtilCog(bot) UtilCog(bot)

View file

@ -3,13 +3,13 @@ import asyncio
import logging import logging
from random import randint from random import randint
from dis_snek import InteractionContext, Scale, Snake
from dis_snek.models.discord.components import Button, ButtonStyles, spread_to_rows
from dis_snek.models.snek.application_commands import slash_command
from dis_snek.models.snek.command import cooldown
from dis_snek.models.snek.cooldowns import Buckets
from jarvis_core.db import q from jarvis_core.db import q
from jarvis_core.db.models import Setting from jarvis_core.db.models import Setting
from naff import Client, Cog, InteractionContext
from naff.models.discord.components import Button, ButtonStyles, spread_to_rows
from naff.models.naff.application_commands import slash_command
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
def create_layout() -> list: def create_layout() -> list:
@ -30,10 +30,10 @@ def create_layout() -> list:
return spread_to_rows(*buttons, max_in_row=3) return spread_to_rows(*buttons, max_in_row=3)
class VerifyCog(Scale): class VerifyCog(Cog):
"""JARVIS Verify Cog.""" """JARVIS Verify Cog."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@ -45,7 +45,13 @@ class VerifyCog(Scale):
if not role: if not role:
message = await ctx.send("This guild has not enabled verification", ephemeral=True) message = await ctx.send("This guild has not enabled verification", ephemeral=True)
return return
if await ctx.guild.fetch_role(role.value) in ctx.author.roles: verified_role = await ctx.guild.fetch_role(role.value)
if not verified_role:
await ctx.send("This guild has not enabled verification", ephemeral=True)
await role.delete()
return
if verified_role in ctx.author.roles:
await ctx.send("You are already verified.", ephemeral=True) await ctx.send("You are already verified.", ephemeral=True)
return return
components = create_layout() components = create_layout()
@ -69,12 +75,20 @@ class VerifyCog(Scale):
for component in row.components: for component in row.components:
component.disabled = True component.disabled = True
setting = await Setting.find_one(q(guild=ctx.guild.id, setting="verified")) setting = await Setting.find_one(q(guild=ctx.guild.id, setting="verified"))
try:
role = await ctx.guild.fetch_role(setting.value) role = await ctx.guild.fetch_role(setting.value)
await ctx.author.add_role(role, reason="Verification passed") await ctx.author.add_role(role, reason="Verification passed")
except AttributeError:
self.logger.warning("Verified role deleted before verification finished")
setting = await Setting.find_one(q(guild=ctx.guild.id, setting="unverified")) setting = await Setting.find_one(q(guild=ctx.guild.id, setting="unverified"))
if setting: if setting:
try:
role = await ctx.guild.fetch_role(setting.value) role = await ctx.guild.fetch_role(setting.value)
await ctx.author.remove_role(role, reason="Verification passed") await ctx.author.remove_role(role, reason="Verification passed")
except AttributeError:
self.logger.warning(
"Unverified role deleted before verification finished"
)
await response.context.edit_origin( await response.context.edit_origin(
content=f"Welcome, {ctx.author.mention}. Please enjoy your stay.", content=f"Welcome, {ctx.author.mention}. Please enjoy your stay.",
@ -94,6 +108,6 @@ class VerifyCog(Scale):
self.logger.debug(f"User {ctx.author.id} failed to verify before timeout") self.logger.debug(f"User {ctx.author.id} failed to verify before timeout")
def setup(bot: Snake) -> None: def setup(bot: Client) -> None:
"""Add VerifyCog to JARVIS""" """Add VerifyCog to JARVIS"""
VerifyCog(bot) VerifyCog(bot)

View file

@ -1,96 +1,17 @@
"""Load the config for JARVIS""" """Load the config for JARVIS"""
import os
from jarvis_core.config import Config as CConfig from jarvis_core.config import Config as CConfig
from pymongo import MongoClient
from yaml import load
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
class JarvisConfig(CConfig): class JarvisConfig(CConfig):
REQUIRED = ["token", "mongo", "urls"] REQUIRED = ("token", "mongo", "urls", "redis")
OPTIONAL = { OPTIONAL = {
"sync": False, "sync": False,
"log_level": "WARNING", "log_level": "WARNING",
"scales": None, "cogs": None,
"events": True, "events": True,
"gitlab_token": None, "gitlab_token": None,
"max_messages": 1000, "max_messages": 1000,
"twitter": None, "twitter": None,
"reddit": None,
"rook_token": None,
} }
class Config(object):
"""Config singleton object for JARVIS"""
def __new__(cls, *args: list, **kwargs: dict):
"""Get the singleton config, or creates a new one."""
it = cls.__dict__.get("it")
if it is not None:
return it
cls.__it__ = it = object.__new__(cls)
it.init(*args, **kwargs)
return it
def init(
self,
token: str,
mongo: dict,
urls: dict,
sync: bool = False,
log_level: str = "WARNING",
cogs: list = None,
events: bool = True,
gitlab_token: str = None,
max_messages: int = 1000,
twitter: dict = None,
) -> None:
"""Initialize the config object."""
self.token = token
self.mongo = mongo
self.urls = urls
self.log_level = log_level
self.cogs = cogs
self.events = events
self.max_messages = max_messages
self.gitlab_token = gitlab_token
self.twitter = twitter
self.sync = sync or os.environ.get("SYNC_COMMANDS", False)
self.__db_loaded = False
self.__mongo = MongoClient(**self.mongo["connect"])
def get_db_config(self) -> None:
"""Load the database config objects."""
if not self.__db_loaded:
db = self.__mongo[self.mongo["database"]]
items = db.config.find()
for item in items:
setattr(self, item["key"], item["value"])
self.__db_loaded = True
@classmethod
def from_yaml(cls, y: dict) -> "Config":
"""Load the yaml config file."""
return cls(**y)
def get_config(path: str = "config.yaml") -> Config:
"""Get the config from the specified yaml file."""
if Config.__dict__.get("it"):
return Config()
with open(path) as f:
raw = f.read()
y = load(raw, Loader=Loader)
config = Config.from_yaml(y)
config.get_db_config()
return config
def reload_config() -> None:
"""Force reload of the config singleton on next call."""
if "it" in Config.__dict__:
Config.__dict__.pop("it")

7
jarvis/const.py Normal file
View file

@ -0,0 +1,7 @@
"""JARVIS constants."""
from importlib.metadata import version as _v
try:
__version__ = _v("jarvis")
except Exception:
__version__ = "0.0.0"

View file

@ -50,32 +50,32 @@ emotes = {
} }
names = { names = {
852317928572715038: "rcA", 852317928572715038: "rcLetterA",
852317954975727679: "rcB", 852317954975727679: "rcLetterB",
852317972424818688: "rcC", 852317972424818688: "rcLetterC",
852317990238421003: "rcD", 852317990238421003: "rcLetterD",
852318044503539732: "rcE", 852318044503539732: "rcLetterE",
852318058353786880: "rcF", 852318058353786880: "rcLetterF",
852318073994477579: "rcG", 852318073994477579: "rcLetterG",
852318105832259614: "rcH", 852318105832259614: "rcLetterH",
852318122278125580: "rcI", 852318122278125580: "rcLetterI",
852318145074167818: "rcJ", 852318145074167818: "rcLetterJ",
852318159952412732: "rcK", 852318159952412732: "rcLetterK",
852318179358408704: "rcL", 852318179358408704: "rcLetterL",
852318241555873832: "rcM", 852318241555873832: "rcLetterM",
852318311115128882: "rcN", 852318311115128882: "rcLetterN",
852318329951223848: "rcO", 852318329951223848: "rcLetterO",
852318344643477535: "rcP", 852318344643477535: "rcLetterP",
852318358920757248: "rcQ", 852318358920757248: "rcLetterQ",
852318385638211594: "rcR", 852318385638211594: "rcLetterR",
852318401166311504: "rcS", 852318401166311504: "rcLetterS",
852318421524938773: "rcT", 852318421524938773: "rcLetterT",
852318435181854742: "rcU", 852318435181854742: "rcLetterU",
852318453204647956: "rcV", 852318453204647956: "rcLetterV",
852318470267731978: "rcW", 852318470267731978: "rcLetterW",
852318484749877278: "rcX", 852318484749877278: "rcLetterX",
852318504564555796: "rcY", 852318504564555796: "rcLetterY",
852318519449092176: "rcZ", 852318519449092176: "rcLetterZ",
860663352740151316: "rc1", 860663352740151316: "rc1",
860662785243348992: "rc2", 860662785243348992: "rc2",
860662950011469854: "rc3", 860662950011469854: "rc3",

View file

@ -3,14 +3,11 @@ from datetime import datetime, timezone
from pkgutil import iter_modules from pkgutil import iter_modules
import git import git
from dis_snek.models.discord.embed import Embed, EmbedField from naff.models.discord.embed import Embed, EmbedField
from dis_snek.models.discord.guild import AuditLogEntry from naff.models.discord.guild import AuditLogEntry
from dis_snek.models.discord.user import Member from naff.models.discord.user import Member
import jarvis.cogs from jarvis.config import JarvisConfig
from jarvis.config import get_config
__all__ = ["cachecog", "permissions"]
def build_embed( def build_embed(
@ -64,11 +61,11 @@ def modlog_embed(
return embed return embed
def get_extensions(path: str = jarvis.cogs.__path__) -> list: def get_extensions(path: str) -> list:
"""Get JARVIS cogs.""" """Get JARVIS cogs."""
config = get_config() config = JarvisConfig.from_yaml()
vals = config.cogs or [x.name for x in iter_modules(path)] vals = config.cogs or [x.name for x in iter_modules(path)]
return ["jarvis.cogs.{}".format(x) for x in vals] return [f"jarvis.cogs.{x}" for x in vals]
def update() -> int: def update() -> int:

View file

@ -1,64 +1,28 @@
"""Cog wrapper for command caching.""" """Cog wrapper for command caching."""
from datetime import datetime, timedelta, timezone import logging
from datetime import timedelta
from dis_snek import InteractionContext, Scale, Snake
from dis_snek.client.utils.misc_utils import find
from dis_snek.models.discord.embed import EmbedField
from dis_snek.models.snek.tasks.task import Task
from dis_snek.models.snek.tasks.triggers import IntervalTrigger
from jarvis_core.db import q from jarvis_core.db import q
from jarvis_core.db.models import ( from jarvis_core.db.models import Action, Ban, Kick, Modlog, Mute, Setting, Warning
Action, from naff import Client, Cog, InteractionContext
Ban, from naff.models.discord.components import ActionRow, Button, ButtonStyles
Kick, from naff.models.discord.embed import EmbedField
Modlog,
Mute,
Note,
Setting,
Warning,
)
from jarvis.utils import build_embed from jarvis.utils import build_embed
MODLOG_LOOKUP = {"Ban": Ban, "Kick": Kick, "Mute": Mute, "Warning": Warning} MODLOG_LOOKUP = {"Ban": Ban, "Kick": Kick, "Mute": Mute, "Warning": Warning}
IGNORE_COMMANDS = {"Ban": ["bans"], "Kick": [], "Mute": ["unmute"], "Warning": ["warnings"]}
class CacheCog(Scale): class ModcaseCog(Cog):
"""Cog wrapper for command caching."""
def __init__(self, bot: Snake):
self.bot = bot
self.cache = {}
self._expire_interaction.start()
def check_cache(self, ctx: InteractionContext, **kwargs: dict) -> dict:
"""Check the cache."""
if not kwargs:
kwargs = {}
return find(
lambda x: x["command"] == ctx.subcommand_name # noqa: W503
and x["user"] == ctx.author.id # noqa: W503
and x["guild"] == ctx.guild.id # noqa: W503
and all(x[k] == v for k, v in kwargs.items()), # noqa: W503
self.cache.values(),
)
@Task.create(IntervalTrigger(minutes=1))
async def _expire_interaction(self) -> None:
keys = list(self.cache.keys())
for key in keys:
if self.cache[key]["timeout"] <= datetime.now(tz=timezone.utc) + timedelta(minutes=1):
del self.cache[key]
class ModcaseCog(Scale):
"""Cog wrapper for moderation case logging.""" """Cog wrapper for moderation case logging."""
def __init__(self, bot: Snake): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.add_scale_postrun(self.log) self.logger = logging.getLogger(__name__)
self.add_cog_postrun(self.log)
async def log(self, ctx: InteractionContext, *args: list, **kwargs: dict) -> None: async def log(self, ctx: InteractionContext, *_args: list, **kwargs: dict) -> None:
""" """
Log a moderation activity in a moderation case. Log a moderation activity in a moderation case.
@ -67,34 +31,33 @@ class ModcaseCog(Scale):
""" """
name = self.__name__.replace("Cog", "") name = self.__name__.replace("Cog", "")
if name not in ["Lock", "Lockdown", "Purge", "Roleping"]: if name in MODLOG_LOOKUP and ctx.invoke_target not in IGNORE_COMMANDS[name]:
user = kwargs.pop("user", None) user = kwargs.pop("user", None)
if not user and not ctx.target_id: if not user and not ctx.target_id:
self.logger.warn(f"Admin action {name} missing user, exiting") self.logger.warning("Admin action %s missing user, exiting", name)
return return
elif ctx.target_id: if ctx.target_id:
user = ctx.target user = ctx.target
coll = MODLOG_LOOKUP.get(name, None) coll = MODLOG_LOOKUP.get(name, None)
if not coll: if not coll:
self.logger.warn(f"Unsupported action {name}, exiting") self.logger.warning("Unsupported action %s, exiting", name)
return return
action = await coll.find_one(q(user=user.id, guild=ctx.guild_id, active=True)) action = await coll.find_one(
q(user=user.id, guild=ctx.guild_id, active=True), sort=[("_id", -1)]
)
if not action: if not action:
self.logger.warn(f"Missing action {name}, exiting") self.logger.warning("Missing action %s, exiting", name)
return return
action = Action(action_type=name.lower(), parent=action.id)
note = Note(admin=self.bot.user.id, content="Moderation case opened automatically")
await Modlog(user=user.id, admin=ctx.author.id, actions=[action], notes=[note]).commit()
notify = await Setting.find_one(q(guild=ctx.guild.id, setting="notify", value=True)) notify = await Setting.find_one(q(guild=ctx.guild.id, setting="notify", value=True))
if notify and name not in ["Kick", "Ban"]: # Ignore Kick and Ban, as these are unique if notify and name not in ("Kick", "Ban"): # Ignore Kick and Ban, as these are unique
fields = [ fields = (
EmbedField(name="Action Type", value=name, inline=False), EmbedField(name="Action Type", value=name, inline=False),
EmbedField( EmbedField(
name="Reason", value=kwargs.get("reason", None) or "N/A", inline=False name="Reason", value=kwargs.get("reason", None) or "N/A", inline=False
), ),
] )
embed = build_embed( embed = build_embed(
title="Admin action taken", title="Admin action taken",
description=f"Admin action has been taken against you in {ctx.guild.name}", description=f"Admin action has been taken against you in {ctx.guild.name}",
@ -103,4 +66,54 @@ class ModcaseCog(Scale):
guild_url = f"https://discord.com/channels/{ctx.guild.id}" guild_url = f"https://discord.com/channels/{ctx.guild.id}"
embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url, url=guild_url) embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url, url=guild_url)
embed.set_thumbnail(url=ctx.guild.icon.url) embed.set_thumbnail(url=ctx.guild.icon.url)
try:
await user.send(embed=embed) await user.send(embed=embed)
except Exception:
self.logger.debug("User not warned of action due to closed DMs")
modlog = await Modlog.find_one(q(user=user.id, guild=ctx.guild.id, open=True))
if modlog:
m_action = Action(action_type=name.lower(), parent=action.id)
modlog.actions.append(m_action)
await modlog.commit()
return
lookup_key = f"{user.id}|{ctx.guild.id}"
async with self.bot.redis.lock("lock|" + lookup_key):
if await self.bot.redis.get(lookup_key):
self.logger.debug(f"User {user.id} in {ctx.guild.id} already has pending case")
return
modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="modlog"))
if not modlog:
return
channel = await ctx.guild.fetch_channel(modlog.value)
if not channel:
self.logger.warn(
f"Guild {ctx.guild.id} modlog channel no longer exists, deleting"
)
await modlog.delete()
return
embed = build_embed(
title="Recent Action Taken",
description=f"Would you like to open a moderation case for {user.mention}?",
fields=[],
)
embed.set_author(
name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url
)
components = [
ActionRow(
Button(style=ButtonStyles.RED, emoji="✖️", custom_id="modcase|no"),
Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="modcase|yes"),
)
]
message = await channel.send(embed=embed, components=components)
await self.bot.redis.set(
lookup_key, f"{name.lower()}|{action.id}", ex=timedelta(days=7)
)
await self.bot.redis.set(f"msg|{message.id}", user.id, ex=timedelta(days=7))

View file

@ -1,7 +1,8 @@
"""JARVIS bot-specific embeds.""" """JARVIS bot-specific embeds."""
from dis_snek.models.discord.embed import Embed, EmbedField from naff.models.discord.embed import Embed, EmbedField
from dis_snek.models.discord.user import Member from naff.models.discord.user import Member
from jarvis_core.util import build_embed
from jarvis.utils import build_embed
def warning_embed(user: Member, reason: str) -> Embed: def warning_embed(user: Member, reason: str) -> Embed:
@ -12,7 +13,7 @@ def warning_embed(user: Member, reason: str) -> Embed:
user: User to warn user: User to warn
reason: Warning reason reason: Warning reason
""" """
fields = [EmbedField(name="Reason", value=reason, inline=False)] fields = (EmbedField(name="Reason", value=reason, inline=False),)
embed = build_embed( embed = build_embed(
title="Warning", description=f"{user.mention} has been warned", fields=fields title="Warning", description=f"{user.mention} has been warned", fields=fields
) )

View file

@ -1,7 +1,7 @@
"""Permissions wrappers.""" """Permissions wrappers."""
from dis_snek import InteractionContext, Permissions from naff import InteractionContext, Permissions
from jarvis.config import get_config from jarvis.config import JarvisConfig
def user_is_bot_admin() -> bool: def user_is_bot_admin() -> bool:
@ -9,8 +9,9 @@ def user_is_bot_admin() -> bool:
async def predicate(ctx: InteractionContext) -> bool: async def predicate(ctx: InteractionContext) -> bool:
"""Command check predicate.""" """Command check predicate."""
if getattr(get_config(), "admins", None): cfg = JarvisConfig.from_yaml()
return ctx.author.id in get_config().admins if getattr(cfg, "admins", None):
return ctx.author.id in cfg.admins
else: else:
return False return False

217
jarvis/utils/updates.py Normal file
View file

@ -0,0 +1,217 @@
"""JARVIS update handler."""
import asyncio
import logging
from dataclasses import dataclass
from importlib import import_module
from inspect import getmembers, isclass
from pkgutil import iter_modules
from types import FunctionType, ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
import git
from naff.client.errors import ExtensionNotFound
from naff.client.utils.misc_utils import find, find_all
from naff.models.naff.application_commands import SlashCommand
from naff.models.naff.cog import Cog
from rich.table import Table
import jarvis.cogs
if TYPE_CHECKING:
from naff.client.client import Client
_logger = logging.getLogger(__name__)
@dataclass
class UpdateResult:
"""JARVIS update result."""
old_hash: str
new_hash: str
table: Table
added: List[str]
removed: List[str]
changed: List[str]
lines: Dict[str, int]
def get_all_commands(module: ModuleType = jarvis.cogs) -> Dict[str, Callable]:
"""Get all SlashCommands from a specified module."""
commands = {}
def validate_ires(entry: Any) -> bool:
return isclass(entry) and issubclass(entry, Cog) and entry is not Cog
def validate_cog(cog: FunctionType) -> bool:
return isinstance(cog, SlashCommand)
for item in iter_modules(module.__path__):
new_module = import_module(f"{module.__name__}.{item.name}")
if item.ispkg:
if cmds := get_all_commands(new_module):
commands.update(cmds)
else:
inspect_result = getmembers(new_module)
cogs = []
for _, val in inspect_result:
if validate_ires(val):
cogs.append(val)
for cog in cogs:
values = cog.__dict__.values()
commands[cog.__module__] = find_all(lambda x: isinstance(x, SlashCommand), values)
return {k: v for k, v in commands.items() if v}
def get_git_changes(repo: git.Repo) -> dict:
"""Get all Git changes"""
logger = _logger
logger.debug("Getting all git changes")
current_hash = repo.head.ref.object.hexsha
tracking = repo.head.ref.tracking_branch()
file_changes = {}
for commit in tracking.commit.iter_items(repo, f"{repo.head.ref.path}..{tracking.path}"):
if commit.hexsha == current_hash:
break
files = commit.stats.files
file_changes.update(
{key: {"insertions": 0, "deletions": 0, "lines": 0} for key in files.keys()}
)
for file, stats in files.items():
if file not in file_changes:
file_changes[file] = {"insertions": 0, "deletions": 0, "lines": 0}
for key, val in stats.items():
file_changes[file][key] += val
logger.debug("Found %i changed files", len(file_changes))
table = Table(title="File Changes")
table.add_column("File", justify="left", style="white", no_wrap=True)
table.add_column("Insertions", justify="center", style="green")
table.add_column("Deletions", justify="center", style="red")
table.add_column("Lines", justify="center", style="magenta")
i_total = 0
d_total = 0
l_total = 0
for file, stats in file_changes.items():
i_total += stats["insertions"]
d_total += stats["deletions"]
l_total += stats["lines"]
table.add_row(
file,
str(stats["insertions"]),
str(stats["deletions"]),
str(stats["lines"]),
)
logger.debug("%i insertions, %i deletions, %i total", i_total, d_total, l_total)
table.add_row("Total", str(i_total), str(d_total), str(l_total))
return {
"table": table,
"lines": {"inserted_lines": i_total, "deleted_lines": d_total, "total_lines": l_total},
}
async def update(bot: "Client") -> Optional[UpdateResult]:
"""
Update JARVIS and return an UpdateResult.
Args:
bot: Bot instance
Returns:
UpdateResult object
"""
logger = _logger
repo = git.Repo(".")
current_hash = repo.head.object.hexsha
origin = repo.remotes.origin
origin.fetch()
remote_hash = origin.refs[repo.active_branch.name].object.hexsha
if current_hash != remote_hash:
logger.info("Updating from %s to %s", current_hash, remote_hash)
current_commands = get_all_commands()
changes = get_git_changes(repo)
origin.pull()
await asyncio.sleep(3)
new_commands = get_all_commands()
logger.info("Checking if any modules need reloaded...")
reloaded = []
loaded = []
unloaded = []
logger.debug("Checking for removed cogs")
for module in current_commands.keys():
if module not in new_commands:
logger.debug("Module %s removed after update", module)
bot.drop_cog(module)
unloaded.append(module)
logger.debug("Checking for new/modified commands")
for module, commands in new_commands.items():
logger.debug("Processing %s", module)
if module not in current_commands:
bot.load_cog(module)
loaded.append(module)
elif len(current_commands[module]) != len(commands):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
else:
for command in commands:
old_command = find(
lambda x: x.resolved_name == command.resolved_name, current_commands[module]
)
# Extract useful info
old_args = old_command.options
if old_args:
old_arg_names = [x.name for x in old_args]
new_args = command.options
if new_args:
new_arg_names = [x.name for x in new_args]
# No changes
if not old_args and not new_args:
continue
# Check if number arguments have changed
if len(old_args) != len(new_args):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
elif any(x not in old_arg_names for x in new_arg_names) or any(
x not in new_arg_names for x in old_arg_names
):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
elif any(new_args[idx].type != x.type for idx, x in enumerate(old_args)):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
return UpdateResult(
old_hash=current_hash,
new_hash=remote_hash,
added=loaded,
removed=unloaded,
changed=reloaded,
**changes,
)
return None

762
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,12 @@
[tool.poetry] [tool.poetry]
name = "jarvis" name = "jarvis"
version = "2.0.0b1" version = "2.0.0b2"
description = "J.A.R.V.I.S. admin bot" description = "J.A.R.V.I.S. admin bot"
authors = ["Zevaryx <zevaryx@gmail.com>"] authors = ["Zevaryx <zevaryx@gmail.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
PyYAML = "^6.0" PyYAML = "^6.0"
dis-snek = "*"
GitPython = "^3.1.26" GitPython = "^3.1.26"
mongoengine = "^0.23.1" mongoengine = "^0.23.1"
opencv-python = "^4.5.5" opencv-python = "^4.5.5"
@ -22,7 +21,11 @@ aiohttp = "^3.8.1"
pastypy = "^1.0.1" pastypy = "^1.0.1"
dateparser = "^1.1.1" dateparser = "^1.1.1"
aiofile = "^3.7.4" aiofile = "^3.7.4"
molter = "^0.11.0" asyncpraw = "^7.5.0"
rook = "^0.1.170"
rich = "^12.3.0"
jurigged = "^0.5.0"
aioredis = "^2.0.1"
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]