Re-write client to remove monofile

This commit is contained in:
Zeva Rose 2022-08-30 23:32:23 -06:00
parent f5517db6ae
commit 43e50cb753
8 changed files with 1035 additions and 989 deletions

View file

@ -1,989 +0,0 @@
"""Custom JARVIS client."""
import asyncio
import logging
import re
import traceback
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from aiohttp import ClientSession
from jarvis_core.db import q
from jarvis_core.db.models import (
Action,
Autopurge,
Autoreact,
Modlog,
Note,
Reminder,
Roleping,
Setting,
Star,
Warning,
)
from jarvis_core.filters import invites, url
from jarvis_core.util.ansi import RESET, Fore, Format, fmt
from naff import listen
from naff.api.events.discord import (
MemberAdd,
MemberRemove,
MemberUpdate,
MessageCreate,
MessageDelete,
MessageUpdate,
)
from naff.api.events.internal import Button
from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException
from naff.client.utils.misc_utils import find_all, get
from naff.models.discord.channel import DMChannel
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.enums import AuditLogEventType, Permissions
from naff.models.discord.message import Message
from naff.models.discord.user import Member
from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from naff.models.naff.tasks.task import Task
from naff.models.naff.tasks.triggers import IntervalTrigger
from nafftrack.client import StatsClient
from pastypy import AsyncPaste as Paste
from jarvis import const
from jarvis.tracking import jarvis_info, malicious_tracker, warnings_tracker
from jarvis.utils import build_embed
from jarvis.utils.embeds import warning_embed
if TYPE_CHECKING:
from aioredis import Redis
DEFAULT_GUILD = 862402786116763668
DEFAULT_ERROR_CHANNEL = 943395824560394250
DEFAULT_SITE = "https://paste.zevs.me"
ERROR_MSG = """
Command Information:
Guild: {guild_name}
Name: {invoked_name}
Args:
{arg_str}
Callback:
Args:
{callback_args}
Kwargs:
{callback_kwargs}
"""
KEY_FMT = fmt(Fore.GRAY)
VAL_FMT = fmt(Fore.WHITE)
CMD_FMT = fmt(Fore.GREEN, Format.BOLD)
class Jarvis(StatsClient):
def __init__(self, redis: "Redis", *args, **kwargs): # noqa: ANN002 ANN003
super().__init__(*args, **kwargs)
self.redis = redis
self.logger = logging.getLogger(__name__)
self.phishing_domains = []
self.pre_run_callback = self._prerun
self.synced = False
async def _chunk_all(self) -> None:
"""Chunk all guilds."""
for guild in self.guilds:
self.logger.debug(f"Chunking guild {guild.name} <{guild.id}>")
await guild.chunk_guild()
@Task.create(IntervalTrigger(minutes=1))
async def _update_domains(self) -> None:
self.logger.debug("Updating phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/recent/60")
response.raise_for_status()
data = await response.json()
self.logger.debug(f"Found {len(data)} changes to phishing domains")
if len(data) == 0:
return
add = 0
sub = 0
for update in data:
if update["type"] == "add":
for domain in update["domains"]:
if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete":
for domain in update["domains"]:
if domain in self.phishing_domains:
sub -= 1
self.phishing_domains.remove(domain)
self.logger.debug(f"{add} additions, {sub} removals")
async def _prerun(self, ctx: Context, *args, **kwargs) -> None:
name = ctx.invoke_target
cargs = ""
if isinstance(ctx, InteractionContext) and ctx.target_id:
kwargs["context target"] = ctx.target
cargs = " ".join(f"{k}:{v}" for k, v in kwargs.items())
elif isinstance(ctx, PrefixedContext):
cargs = " ".join(args)
self.logger.debug(f"Running command `{name}` with args: {cargs or 'None'}")
async def _sync_domains(self) -> None:
self.logger.debug("Loading phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/all")
response.raise_for_status()
self.phishing_domains = await response.json()
self.logger.info(f"Protected from {len(self.phishing_domains)} phishing domains")
@listen()
async def on_startup(self) -> None:
"""NAFF on_startup override. Prometheus info generated here."""
jarvis_info.info({"version": const.__version__})
@listen()
async def on_ready(self) -> None:
"""NAFF on_ready override."""
try:
if not self.synced:
await self._sync_domains()
self._update_domains.start()
asyncio.create_task(self._chunk_all())
self.synced = True
except Exception as e:
self.logger.error("Failed to load anti-phishing", exc_info=e)
self.logger.info("Logged in as {}".format(self.user)) # noqa: T001
self.logger.info("Connected to {} guild(s)".format(len(self.guilds))) # noqa: T001
self.logger.info("Current version: {}".format(const.__version__))
self.logger.info( # noqa: T001
"https://discord.com/api/oauth2/authorize?client_id="
"{}&permissions=8&scope=bot%20applications.commands".format(self.user.id)
)
self.logger.debug("Hitting Reminders for faster loads")
_ = await Reminder.find().to_list(None)
async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None:
"""NAFF on_error override."""
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
out = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
self.logger.error(out, exc_info=error)
else:
self.logger.error(f"Ignoring exception in {source}", exc_info=error)
async def on_command_error(
self, ctx: Context, error: Exception, *args: list, **kwargs: dict
) -> None:
"""NAFF on_command_error override."""
name = ctx.invoke_target
self.logger.debug(f"Handling error in {name}: {error}")
if isinstance(error, CommandOnCooldown):
await ctx.send(str(error), ephemeral=True)
return
elif isinstance(error, CommandCheckFailure):
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
guild = await self.fetch_guild(DEFAULT_GUILD)
channel = await guild.fetch_channel(DEFAULT_ERROR_CHANNEL)
error_time = datetime.now(tz=timezone.utc).strftime("%d-%m-%Y %H:%M-%S.%f UTC")
timestamp = int(datetime.now(tz=timezone.utc).timestamp())
timestamp = f"<t:{timestamp}:T>"
arg_str = ""
if isinstance(ctx, InteractionContext) and ctx.target_id:
ctx.kwargs["context target"] = ctx.target
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
arg_str += f" {k}: "
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f"{v}\n"
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f" - {v}"
callback_args = "\n".join(f" - {i}" for i in args) if args else " None"
callback_kwargs = (
"\n".join(f" {k}: {v}" for k, v in kwargs.items()) if kwargs else " None"
)
full_message = ERROR_MSG.format(
guild_name=ctx.guild.name,
error_time=error_time,
invoked_name=name,
arg_str=arg_str,
callback_args=callback_args,
callback_kwargs=callback_kwargs,
)
tb = traceback.format_exception(error)
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
tb[-1] = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
error_message = "".join(traceback.format_exception(error))
if len(full_message + error_message) >= 1800:
error_message = "\n ".join(error_message.split("\n"))
full_message += "Exception: |\n " + error_message
paste = Paste(content=full_message, site=DEFAULT_SITE)
key = await paste.save()
self.logger.debug(f"Large traceback, saved to Pasty {paste.id}, {key=}")
await channel.send(
f"JARVIS encountered an error at {timestamp}. Log too big to send over Discord."
f"\nPlease see log at {paste.url}"
)
else:
await channel.send(
f"JARVIS encountered an error at {timestamp}:"
f"\n```yaml\n{full_message}\n```"
f"\nException:\n```py\n{error_message}\n```"
)
await ctx.send("Whoops! Encountered an error. The error has been logged.", ephemeral=True)
try:
return await super().on_command_error(ctx, error, *args, **kwargs)
except Exception as e:
self.logger.error("Uncaught exception", exc_info=e)
# Modlog
async def on_command(self, ctx: Context) -> None:
"""NAFF on_command override."""
name = ctx.invoke_target
if not isinstance(ctx.channel, DMChannel) and name not in ["pw"]:
modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=ctx.guild.id, setting="log_ignore"))
if modlog and (ignore and ctx.channel.id not in ignore.value):
channel = await ctx.guild.fetch_channel(modlog.value)
args = []
if isinstance(ctx, InteractionContext) and ctx.target_id:
args.append(f"{KEY_FMT}context target:{VAL_FMT}{ctx.target}{RESET}")
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
if isinstance(v, str):
v = v.replace("`", "\\`")
if len(v) > 100:
v = v[:97] + "..."
args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}")
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
args.append(f"{VAL_FMT}{v}{RESET}")
args = " ".join(args)
fields = [
EmbedField(
name="Command",
value=f"```ansi\n{CMD_FMT}{ctx.invoke_target}{RESET} {args}\n```",
inline=False,
),
]
embed = build_embed(
title="Command Invoked",
description=f"{ctx.author.mention} invoked a command in {ctx.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=ctx.author.username, icon_url=ctx.author.display_avatar.url)
embed.set_footer(
text=f"{ctx.author.user.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
if channel:
await channel.send(embeds=embed)
else:
self.logger.warning(
f"Activitylog channel no longer exists in {ctx.guild.name}, removing"
)
await modlog.delete()
# Events
# Member
@listen()
async def on_member_add(self, event: MemberAdd) -> None:
"""Handle on_member_add event."""
user = event.member
guild = event.guild
unverified = await Setting.find_one(q(guild=guild.id, setting="unverified"))
if unverified:
self.logger.debug(f"Applying unverified role to {user.id} in {guild.id}")
role = await guild.fetch_role(unverified.value)
if role not in user.roles:
await user.add_role(role, reason="User just joined and is unverified")
@listen()
async def on_member_remove(self, event: MemberRemove) -> None:
"""Handle on_member_remove event."""
user = event.member
guild = event.guild
log = await Setting.find_one(q(guild=guild.id, setting="activitylog"))
if log:
self.logger.debug(f"User {user.id} left {guild.id}")
channel = await guild.fetch_channel(log.value)
embed = build_embed(
title="Member Left",
description=f"{user.username}#{user.discriminator} left {guild.name}",
fields=[],
)
embed.set_author(name=user.username, icon_url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await channel.send(embeds=embed)
async def process_verify(self, before: Member, after: Member) -> Embed:
"""Process user verification."""
auditlog = await after.guild.fetch_audit_log(
user_id=before.id, action_type=AuditLogEventType.MEMBER_ROLE_UPDATE
)
audit_event = get(auditlog.events, reason="Verification passed")
if audit_event:
admin_mention = "[N/A]"
admin_text = "[N/A]"
if admin := await after.guild.fet_member(audit_event.user_id):
admin_mention = admin.mention
admin_text = f"{admin.username}#{admin.discriminator}"
fields = (
EmbedField(name="Moderator", value=f"{admin_mention} ({admin_text})"),
EmbedField(name="Reason", value=audit_event.reason),
)
embed = build_embed(
title="User Verified",
description=f"{after.mention} was verified",
fields=fields,
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rolechange(self, before: Member, after: Member) -> Embed:
"""Process role changes."""
if before.roles == after.roles:
return
new_roles = []
removed_roles = []
for role in before.roles:
if role not in after.roles:
removed_roles.append(role)
for role in after.roles:
if role not in before.roles:
new_roles.append(role)
new_text = "\n".join(role.mention for role in new_roles) or "None"
removed_text = "\n".join(role.mention for role in removed_roles) or "None"
fields = (
EmbedField(name="Added Roles", value=new_text),
EmbedField(name="Removed Roles", value=removed_text),
)
embed = build_embed(
title="User Roles Changed",
description=f"{after.mention} had roles changed",
fields=fields,
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rename(self, before: Member, after: Member) -> None:
"""Process name change."""
if (
before.nickname == after.nickname
and before.discriminator == after.discriminator
and before.username == after.username
):
return
fields = (
EmbedField(
name="Before",
value=f"{before.display_name} ({before.username}#{before.discriminator})",
),
EmbedField(
name="After", value=f"{after.display_name} ({after.username}#{after.discriminator})"
),
)
embed = build_embed(
title="User Renamed",
description=f"{after.mention} changed their name",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
@listen()
async def on_member_update(self, event: MemberUpdate) -> None:
"""Handle on_member_update event."""
before = event.before
after = event.after
if (before.display_name == after.display_name and before.roles == after.roles) or (
not after or not before
):
return
log = await Setting.find_one(q(guild=before.guild.id, setting="activitylog"))
if log:
channel = await before.guild.fetch_channel(log.value)
await asyncio.sleep(0.5) # Wait for audit log
embed = None
if before._role_ids != after._role_ids:
verified = await Setting.find_one(q(guild=before.guild.id, setting="verified"))
v_role = None
if verified:
v_role = await before.guild.fetch_role(verified.value)
if not v_role:
self.logger.debug(f"Guild {before.guild.id} verified role no longer exists")
await verified.delete()
else:
if not before.has_role(v_role) and after.has_role(v_role):
embed = await self.process_verify(before, after)
embed = embed or await self.process_rolechange(before, after)
embed = embed or await self.process_rename(before, after)
if embed:
await channel.send(embeds=embed)
# Message
async def autopurge(self, message: Message) -> None:
"""Handle autopurge events."""
autopurge = await Autopurge.find_one(q(guild=message.guild.id, channel=message.channel.id))
if autopurge:
if not message.author.has_permission(Permissions.ADMINISTRATOR):
self.logger.debug(
f"Autopurging message {message.guild.id}/{message.channel.id}/{message.id}"
)
await message.delete(delay=autopurge.delay)
async def autoreact(self, message: Message) -> None:
"""Handle autoreact events."""
autoreact = await Autoreact.find_one(
q(
guild=message.guild.id,
channel=message.channel.id,
)
)
if autoreact:
self.logger.debug(
f"Autoreacting to message {message.guild.id}/{message.channel.id}/{message.id}"
)
for reaction in autoreact.reactions:
await message.add_reaction(reaction)
if autoreact.thread:
name = message.content.replace("\n", " ")
name = re.sub(r"<:\w+:(\d+)>", "", name)
if len(name) > 100:
name = name[:97] + "..."
await message.create_thread(name=message.content, reason="Autoreact")
async def checks(self, message: Message) -> None:
"""Other message checks."""
# #tech
# channel = find(lambda x: x.id == 599068193339736096, message._mention_ids)
# if channel and message.author.id == 293795462752894976:
# await channel.send(
# content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif" # noqa: E501
# )
content = re.sub(r"\s+", "", message.content)
match = invites.search(content)
setting = await Setting.find_one(q(guild=message.guild.id, setting="noinvite"))
if not setting:
setting = Setting(guild=message.guild.id, setting="noinvite", value=True)
await setting.commit()
if match:
guild_invites = await message.guild.fetch_invites()
if message.guild.vanity_url_code:
guild_invites.append(message.guild.vanity_url_code)
allowed = [x.code for x in guild_invites] + [
"dbrand",
"VtgZntXcnZ",
"gPfYGbvTCE",
]
if (m := match.group(1)) not in allowed and setting.value:
self.logger.debug(f"Removing non-allowed invite `{m}` from {message.guild.id}")
try:
await message.delete()
except Exception:
self.logger.debug("Message deleted before action taken")
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Sent an invite link",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Sent an invite link")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def massmention(self, message: Message) -> None:
"""Handle massmention events."""
massmention = await Setting.find_one(
q(
guild=message.guild.id,
setting="massmention",
)
)
if (
massmention
and massmention.value > 0 # noqa: W503
and len(message._mention_ids + message._mention_roles) # noqa: W503
- (1 if message.author.id in message._mention_ids else 0) # noqa: W503
> massmention.value # noqa: W503
):
self.logger.debug(
f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Mass Mention",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Mass Mention")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def roleping(self, message: Message) -> None:
"""Handle roleping events."""
try:
if message.author.has_permission(Permissions.MANAGE_GUILD):
return
except Exception as e:
self.logger.error("Failed to get permissions, pretending check failed", exc_info=e)
if await Roleping.collection.count_documents(q(guild=message.guild.id, active=True)) == 0:
return
rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None)
# Get all role IDs involved with message
roles = [x.id async for x in message.mention_roles]
async for mention in message.mention_users:
roles += [x.id for x in mention.roles]
if not roles:
return
# Get all roles that are rolepinged
roleping_ids = [r.role for r in rolepings]
# Get roles in rolepings
role_in_rolepings = find_all(lambda x: x in roleping_ids, roles)
# Check if the user has the role, so they are allowed to ping it
user_missing_role = any(x.id not in roleping_ids for x in message.author.roles)
# Admins can ping whoever
user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR)
# Check if user in a bypass list
def check_has_role(roleping: Roleping) -> bool:
return any(role.id in roleping.bypass["roles"] for role in message.author.roles)
user_has_bypass = False
for roleping in rolepings:
if message.author.id in roleping.bypass["users"]:
user_has_bypass = True
break
if check_has_role(roleping):
user_has_bypass = True
break
if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass:
self.logger.debug(
f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Pinged a blocked role/user with a blocked role",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def phishing(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
if (m := match.group("domain")) in self.phishing_domains:
self.logger.debug(
f"Phishing url `{m}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Phishing URL",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Phishing URL")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
try:
await message.delete()
except Exception:
self.logger.warn("Failed to delete malicious message")
tracker = malicious_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
return True
return False
async def malicious_url(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
async with ClientSession() as session:
resp = await session.post(
"https://anti-fish.bitflow.dev/check",
json={"message": match.string},
headers={
"Application-Name": "JARVIS",
"Application-Link": "https://git.zevaryx.com/stark-industries/jarvis",
},
)
if resp.status != 200:
break
data = await resp.json()
if data["match"]:
self.logger.debug(
f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Unsafe URL",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
reasons = ", ".join(f"{m['source']}: {m['type']}" for m in data["matches"])
embed = warning_embed(message.author, reasons)
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
try:
await message.delete()
except Exception:
self.logger.warn("Failed to delete malicious message")
tracker = malicious_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
return True
return False
@listen()
async def on_message(self, event: MessageCreate) -> None:
"""Handle on_message event. Calls other event handlers."""
message = event.message
if not isinstance(message.channel, DMChannel) and not message.author.bot:
await self.autoreact(message)
await self.massmention(message)
await self.roleping(message)
await self.autopurge(message)
await self.checks(message)
if not await self.phishing(message):
await self.malicious_url(message)
@listen()
async def on_message_edit(self, event: MessageUpdate) -> None:
"""Process on_message_edit events."""
before = event.before
after = event.after
if not after.author.bot:
modlog = await Setting.find_one(q(guild=after.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=after.guild.id, setting="log_ignore"))
if modlog and (ignore and after.channel.id not in ignore.value):
if not before or before.content == after.content or before.content is None:
return
try:
channel = before.guild.get_channel(modlog.value)
fields = [
EmbedField(
"Original Message",
before.content if before.content else "N/A",
False,
),
EmbedField(
"New Message",
after.content if after.content else "N/A",
False,
),
]
embed = build_embed(
title="Message Edited",
description=f"{after.author.mention} edited a message in {before.channel.mention}",
fields=fields,
color="#fc9e3f",
timestamp=after.edited_timestamp,
url=after.jump_url,
)
embed.set_author(
name=after.author.username,
icon_url=after.author.display_avatar.url,
url=after.jump_url,
)
embed.set_footer(
text=f"{after.author.username}#{after.author.discriminator} | {after.author.id}"
)
await channel.send(embeds=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {before.guild.id}/{before.channel.id}/{before.id}: {e}"
)
if not isinstance(after.channel, DMChannel) and not after.author.bot:
await self.massmention(after)
await self.roleping(after)
await self.checks(after)
await self.roleping(after)
await self.checks(after)
if not await self.phishing(after):
await self.malicious_url(after)
@listen()
async def on_message_delete(self, event: MessageDelete) -> None:
"""Process on_message_delete events."""
message = event.message
modlog = await Setting.find_one(q(guild=message.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=message.guild.id, setting="log_ignore"))
if modlog and (ignore and message.channel.id not in ignore.value):
try:
content = message.content or "N/A"
except AttributeError:
content = "N/A"
fields = [EmbedField("Original Message", content, False)]
try:
if message.attachments:
value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments])
fields.append(
EmbedField(
name="Attachments",
value=value,
inline=False,
)
)
if message.sticker_items:
value = "\n".join([f"Sticker: {x.name}" for x in message.sticker_items])
fields.append(
EmbedField(
name="Stickers",
value=value,
inline=False,
)
)
if message.embeds:
value = str(len(message.embeds)) + " embeds"
fields.append(
EmbedField(
name="Embeds",
value=value,
inline=False,
)
)
channel = message.guild.get_channel(modlog.value)
embed = build_embed(
title="Message Deleted",
description=f"{message.author.mention}'s message was deleted from {message.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(
name=message.author.username,
icon_url=message.author.display_avatar.url,
url=message.jump_url,
)
embed.set_footer(
text=(
f"{message.author.username}#{message.author.discriminator} | "
f"{message.author.id}"
)
)
await channel.send(embeds=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}"
)
async def _handle_modcase_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("modcase|"):
return # Failsafe
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
if not context.author.has_permission(Permissions.MODERATE_MEMBERS):
return
user_key = f"msg|{context.message.id}"
action_key = ""
if context.custom_id == "modcase|yes":
if user_id := await self.redis.get(user_key):
action_key = f"{user_id}|{context.guild.id}"
if (user := await context.guild.fetch_member(user_id)) and (
action_data := await self.redis.get(action_key)
):
name, parent = action_data.split("|")[:2]
action = Action(action_type=name, parent=parent)
note = Note(
admin=context.author.id, content="Moderation case opened via message"
)
modlog = await Modlog.find_one(
q(user=user.id, guild=context.guild.id, open=True)
)
if modlog:
self.logger.debug("User already has active case in guild")
await context.send(
f"User already has open case: {modlog.nanoid}", ephemeral=True
)
else:
modlog = Modlog(
user=user.id,
admin=context.author.id,
guild=context.guild.id,
actions=[action],
notes=[note],
)
await modlog.commit()
fields = (
EmbedField(name="Admin", value=context.author.mention),
EmbedField(name="Opening Action", value=f"{name} {parent}"),
)
embed = build_embed(
title="Moderation Case Opened",
description=f"Moderation case opened against {user.mention}",
fields=fields,
)
embed.set_author(
name=user.username + "#" + user.discriminator,
icon_url=user.display_avatar.url,
)
await context.message.edit(embeds=embed)
elif not user:
self.logger.debug("User no longer in guild")
await context.send("User no longer in guild", ephemeral=True)
else:
self.logger.warn("Unable to get action data ( %s )", action_key)
await context.send("Unable to get action data", ephemeral=True)
for row in context.message.components:
for component in row.components:
component.disabled = True
await context.message.edit(components=context.message.components)
msg = "Cancelled" if context.custom_id == "modcase|no" else "Moderation case opened"
await context.send(msg)
await self.redis.delete(user_key)
await self.redis.delete(action_key)
async def _handle_delete_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("delete|"):
return # Failsafe
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
uid = context.custom_id.split("|")[1]
if (
not context.author.has_permission(Permissions.MANAGE_MESSAGES)
and not context.author.has_permission(Permissions.ADMINISTRATOR)
and not str(context.author.id) == uid
):
await context.send("I'm afraid I can't let you do that", ephemeral=True)
return # User does not have perms to delete
if star := await Star.find_one(q(star=context.message.id, guild=context.guild.id)):
await star.delete()
await context.message.delete()
await context.send("Message deleted", ephemeral=True)
async def _handle_copy_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("copy|"):
return
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
what, rid = context.custom_id.split("|")[1:]
if what == "rme":
reminder = await Reminder.find_one(q(_id=rid))
if reminder:
new_reminder = Reminder(
user=context.author.id,
channel=context.channel.id,
guild=context.guild.id,
message=reminder.message,
remind_at=reminder.remind_at,
private=reminder.private,
active=reminder.active,
)
await new_reminder.commit()
await context.send("Reminder copied!", ephemeral=True)
@listen()
async def on_button(self, event: Button) -> None:
"""Process button events."""
await self._handle_modcase_button(event)
await self._handle_delete_button(event)
await self._handle_copy_button(event)

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

@ -0,0 +1,38 @@
"""Custom JARVIS client."""
import logging
from typing import TYPE_CHECKING
from jarvis_core.util.ansi import Fore, Format, fmt
from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from nafftrack.client import StatsClient
from jarvis.client.errors import ErrorMixin
from jarvis.client.events import EventMixin
from jarvis.client.tasks import TaskMixin
if TYPE_CHECKING:
from aioredis import Redis
KEY_FMT = fmt(Fore.GRAY)
VAL_FMT = fmt(Fore.WHITE)
CMD_FMT = fmt(Fore.GREEN, Format.BOLD)
class Jarvis(StatsClient, ErrorMixin, EventMixin, TaskMixin):
def __init__(self, redis: "Redis", *args, **kwargs): # noqa: ANN002 ANN003
super().__init__(*args, **kwargs)
self.redis = redis
self.logger = logging.getLogger(__name__)
self.phishing_domains = []
self.pre_run_callback = self._prerun
self.synced = False
async def _prerun(self, ctx: Context, *args, **kwargs) -> None:
name = ctx.invoke_target
cargs = ""
if isinstance(ctx, InteractionContext) and ctx.target_id:
kwargs["context target"] = ctx.target
cargs = " ".join(f"{k}:{v}" for k, v in kwargs.items())
elif isinstance(ctx, PrefixedContext):
cargs = " ".join(args)
self.logger.debug(f"Running command `{name}` with args: {cargs or 'None'}")

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

@ -0,0 +1,107 @@
"""JARVIS error handling mixin."""
import traceback
from datetime import datetime, timezone
from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException
from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from pastypy import AsyncPaste as Paste
DEFAULT_GUILD = 862402786116763668
DEFAULT_ERROR_CHANNEL = 943395824560394250
DEFAULT_SITE = "https://paste.zevs.me"
ERROR_MSG = """
Command Information:
Guild: {guild_name}
Name: {invoked_name}
Args:
{arg_str}
Callback:
Args:
{callback_args}
Kwargs:
{callback_kwargs}
"""
class ErrorMixin:
async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None:
"""NAFF on_error override."""
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
out = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
self.logger.error(out, exc_info=error)
else:
self.logger.error(f"Ignoring exception in {source}", exc_info=error)
async def on_command_error(
self, ctx: Context, error: Exception, *args: list, **kwargs: dict
) -> None:
"""NAFF on_command_error override."""
name = ctx.invoke_target
self.logger.debug(f"Handling error in {name}: {error}")
if isinstance(error, CommandOnCooldown):
await ctx.send(str(error), ephemeral=True)
return
elif isinstance(error, CommandCheckFailure):
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
guild = await self.fetch_guild(DEFAULT_GUILD)
channel = await guild.fetch_channel(DEFAULT_ERROR_CHANNEL)
error_time = datetime.now(tz=timezone.utc).strftime("%d-%m-%Y %H:%M-%S.%f UTC")
timestamp = int(datetime.now(tz=timezone.utc).timestamp())
timestamp = f"<t:{timestamp}:T>"
arg_str = ""
if isinstance(ctx, InteractionContext) and ctx.target_id:
ctx.kwargs["context target"] = ctx.target
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
arg_str += f" {k}: "
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f"{v}\n"
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f" - {v}"
callback_args = "\n".join(f" - {i}" for i in args) if args else " None"
callback_kwargs = (
"\n".join(f" {k}: {v}" for k, v in kwargs.items()) if kwargs else " None"
)
full_message = ERROR_MSG.format(
guild_name=ctx.guild.name,
error_time=error_time,
invoked_name=name,
arg_str=arg_str,
callback_args=callback_args,
callback_kwargs=callback_kwargs,
)
tb = traceback.format_exception(error)
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
tb[-1] = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
error_message = "".join(traceback.format_exception(error))
if len(full_message + error_message) >= 1800:
error_message = "\n ".join(error_message.split("\n"))
full_message += "Exception: |\n " + error_message
paste = Paste(content=full_message, site=DEFAULT_SITE)
key = await paste.save()
self.logger.debug(f"Large traceback, saved to Pasty {paste.id}, {key=}")
await channel.send(
f"JARVIS encountered an error at {timestamp}. Log too big to send over Discord."
f"\nPlease see log at {paste.url}"
)
else:
await channel.send(
f"JARVIS encountered an error at {timestamp}:"
f"\n```yaml\n{full_message}\n```"
f"\nException:\n```py\n{error_message}\n```"
)
await ctx.send("Whoops! Encountered an error. The error has been logged.", ephemeral=True)
try:
return await super().on_command_error(ctx, error, *args, **kwargs)
except Exception as e:
self.logger.error("Uncaught exception", exc_info=e)

View file

@ -0,0 +1,115 @@
"""JARVIS event mixin."""
import asyncio
from aiohttp import ClientSession
from jarvis_core.db import q
from jarvis_core.db.models import Reminder, Setting
from jarvis_core.util.ansi import RESET, Fore, Format, fmt
from naff import listen
from naff.models.discord.channel import DMChannel
from naff.models.discord.embed import EmbedField
from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from jarvis import const
from jarvis.client.events.components import ComponentEventMixin
from jarvis.client.events.member import MemberEventMixin
from jarvis.client.events.message import MessageEventMixin
from jarvis.tracking import jarvis_info
from jarvis.utils import build_embed
KEY_FMT = fmt(Fore.GRAY)
VAL_FMT = fmt(Fore.WHITE)
CMD_FMT = fmt(Fore.GREEN, Format.BOLD)
class EventMixin(MemberEventMixin, MessageEventMixin, ComponentEventMixin):
async def _chunk_all(self) -> None:
"""Chunk all guilds."""
for guild in self.guilds:
self.logger.debug(f"Chunking guild {guild.name} <{guild.id}>")
await guild.chunk_guild()
async def _sync_domains(self) -> None:
self.logger.debug("Loading phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/all")
response.raise_for_status()
self.phishing_domains = await response.json()
self.logger.info(f"Protected from {len(self.phishing_domains)} phishing domains")
@listen()
async def on_startup(self) -> None:
"""NAFF on_startup override. Prometheus info generated here."""
jarvis_info.info({"version": const.__version__})
@listen()
async def on_ready(self) -> None:
"""NAFF on_ready override."""
try:
if not self.synced:
await self._sync_domains()
self._update_domains.start()
asyncio.create_task(self._chunk_all())
self.synced = True
except Exception as e:
self.logger.error("Failed to load anti-phishing", exc_info=e)
self.logger.info("Logged in as {}".format(self.user)) # noqa: T001
self.logger.info("Connected to {} guild(s)".format(len(self.guilds))) # noqa: T001
self.logger.info("Current version: {}".format(const.__version__))
self.logger.info( # noqa: T001
"https://discord.com/api/oauth2/authorize?client_id="
"{}&permissions=8&scope=bot%20applications.commands".format(self.user.id)
)
self.logger.debug("Hitting Reminders for faster loads")
_ = await Reminder.find().to_list(None)
# Modlog
async def on_command(self, ctx: Context) -> None:
"""NAFF on_command override."""
name = ctx.invoke_target
if not isinstance(ctx.channel, DMChannel) and name not in ["pw"]:
modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=ctx.guild.id, setting="log_ignore"))
if modlog and (ignore and ctx.channel.id not in ignore.value):
channel = await ctx.guild.fetch_channel(modlog.value)
args = []
if isinstance(ctx, InteractionContext) and ctx.target_id:
args.append(f"{KEY_FMT}context target:{VAL_FMT}{ctx.target}{RESET}")
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
if isinstance(v, str):
v = v.replace("`", "\\`")
if len(v) > 100:
v = v[:97] + "..."
args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}")
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
args.append(f"{VAL_FMT}{v}{RESET}")
args = " ".join(args)
fields = [
EmbedField(
name="Command",
value=f"```ansi\n{CMD_FMT}{ctx.invoke_target}{RESET} {args}\n```",
inline=False,
),
]
embed = build_embed(
title="Command Invoked",
description=f"{ctx.author.mention} invoked a command in {ctx.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=ctx.author.username, icon_url=ctx.author.display_avatar.url)
embed.set_footer(
text=f"{ctx.author.user.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
if channel:
await channel.send(embeds=embed)
else:
self.logger.warning(
f"Activitylog channel no longer exists in {ctx.guild.name}, removing"
)
await modlog.delete()

View file

@ -0,0 +1,144 @@
"""JARVIS component event mixin."""
from jarvis_core.db import q
from jarvis_core.db.models import Action, Modlog, Note, Reminder, Star
from naff import listen
from naff.api.events.internal import Button
from naff.models.discord.embed import EmbedField
from naff.models.discord.enums import Permissions
from jarvis.utils import build_embed
class ComponentEventMixin:
async def _handle_modcase_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("modcase|"):
return # Failsafe
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
if not context.author.has_permission(Permissions.MODERATE_MEMBERS):
return
user_key = f"msg|{context.message.id}"
action_key = ""
if context.custom_id == "modcase|yes":
if user_id := await self.redis.get(user_key):
action_key = f"{user_id}|{context.guild.id}"
if (user := await context.guild.fetch_member(user_id)) and (
action_data := await self.redis.get(action_key)
):
name, parent = action_data.split("|")[:2]
action = Action(action_type=name, parent=parent)
note = Note(
admin=context.author.id, content="Moderation case opened via message"
)
modlog = await Modlog.find_one(
q(user=user.id, guild=context.guild.id, open=True)
)
if modlog:
self.logger.debug("User already has active case in guild")
await context.send(
f"User already has open case: {modlog.nanoid}", ephemeral=True
)
else:
modlog = Modlog(
user=user.id,
admin=context.author.id,
guild=context.guild.id,
actions=[action],
notes=[note],
)
await modlog.commit()
fields = (
EmbedField(name="Admin", value=context.author.mention),
EmbedField(name="Opening Action", value=f"{name} {parent}"),
)
embed = build_embed(
title="Moderation Case Opened",
description=f"Moderation case opened against {user.mention}",
fields=fields,
)
embed.set_author(
name=user.username + "#" + user.discriminator,
icon_url=user.display_avatar.url,
)
await context.message.edit(embeds=embed)
elif not user:
self.logger.debug("User no longer in guild")
await context.send("User no longer in guild", ephemeral=True)
else:
self.logger.warn("Unable to get action data ( %s )", action_key)
await context.send("Unable to get action data", ephemeral=True)
for row in context.message.components:
for component in row.components:
component.disabled = True
await context.message.edit(components=context.message.components)
msg = "Cancelled" if context.custom_id == "modcase|no" else "Moderation case opened"
await context.send(msg)
await self.redis.delete(user_key)
await self.redis.delete(action_key)
async def _handle_delete_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("delete|"):
return # Failsafe
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
uid = context.custom_id.split("|")[1]
if (
not context.author.has_permission(Permissions.MANAGE_MESSAGES)
and not context.author.has_permission(Permissions.ADMINISTRATOR)
and not str(context.author.id) == uid
):
await context.send("I'm afraid I can't let you do that", ephemeral=True)
return # User does not have perms to delete
if star := await Star.find_one(q(star=context.message.id, guild=context.guild.id)):
await star.delete()
await context.message.delete()
await context.send("Message deleted", ephemeral=True)
async def _handle_copy_button(self, event: Button) -> None:
context = event.context
if not context.custom_id.startswith("copy|"):
return
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
what, rid = context.custom_id.split("|")[1:]
if what == "rme":
reminder = await Reminder.find_one(q(_id=rid))
if reminder:
new_reminder = Reminder(
user=context.author.id,
channel=context.channel.id,
guild=context.guild.id,
message=reminder.message,
remind_at=reminder.remind_at,
private=reminder.private,
active=reminder.active,
)
await new_reminder.commit()
await context.send("Reminder copied!", ephemeral=True)
@listen()
async def on_button(self, event: Button) -> None:
"""Process button events."""
await self._handle_modcase_button(event)
await self._handle_delete_button(event)
await self._handle_copy_button(event)

View file

@ -0,0 +1,163 @@
"""JARVIS member event mixin."""
import asyncio
from jarvis_core.db import q
from jarvis_core.db.models import Setting
from naff import listen
from naff.api.events.discord import MemberAdd, MemberRemove, MemberUpdate
from naff.client.utils.misc_utils import get
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.enums import AuditLogEventType
from naff.models.discord.user import Member
from jarvis.utils import build_embed
class MemberEventMixin:
# Events
# Member
@listen()
async def on_member_add(self, event: MemberAdd) -> None:
"""Handle on_member_add event."""
user = event.member
guild = event.guild
unverified = await Setting.find_one(q(guild=guild.id, setting="unverified"))
if unverified:
self.logger.debug(f"Applying unverified role to {user.id} in {guild.id}")
role = await guild.fetch_role(unverified.value)
if role not in user.roles:
await user.add_role(role, reason="User just joined and is unverified")
@listen()
async def on_member_remove(self, event: MemberRemove) -> None:
"""Handle on_member_remove event."""
user = event.member
guild = event.guild
log = await Setting.find_one(q(guild=guild.id, setting="activitylog"))
if log:
self.logger.debug(f"User {user.id} left {guild.id}")
channel = await guild.fetch_channel(log.value)
embed = build_embed(
title="Member Left",
description=f"{user.username}#{user.discriminator} left {guild.name}",
fields=[],
)
embed.set_author(name=user.username, icon_url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await channel.send(embeds=embed)
async def process_verify(self, before: Member, after: Member) -> Embed:
"""Process user verification."""
auditlog = await after.guild.fetch_audit_log(
user_id=before.id, action_type=AuditLogEventType.MEMBER_ROLE_UPDATE
)
audit_event = get(auditlog.events, reason="Verification passed")
if audit_event:
admin_mention = "[N/A]"
admin_text = "[N/A]"
if admin := await after.guild.fet_member(audit_event.user_id):
admin_mention = admin.mention
admin_text = f"{admin.username}#{admin.discriminator}"
fields = (
EmbedField(name="Moderator", value=f"{admin_mention} ({admin_text})"),
EmbedField(name="Reason", value=audit_event.reason),
)
embed = build_embed(
title="User Verified",
description=f"{after.mention} was verified",
fields=fields,
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rolechange(self, before: Member, after: Member) -> Embed:
"""Process role changes."""
if before.roles == after.roles:
return
new_roles = []
removed_roles = []
for role in before.roles:
if role not in after.roles:
removed_roles.append(role)
for role in after.roles:
if role not in before.roles:
new_roles.append(role)
new_text = "\n".join(role.mention for role in new_roles) or "None"
removed_text = "\n".join(role.mention for role in removed_roles) or "None"
fields = (
EmbedField(name="Added Roles", value=new_text),
EmbedField(name="Removed Roles", value=removed_text),
)
embed = build_embed(
title="User Roles Changed",
description=f"{after.mention} had roles changed",
fields=fields,
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rename(self, before: Member, after: Member) -> None:
"""Process name change."""
if (
before.nickname == after.nickname
and before.discriminator == after.discriminator
and before.username == after.username
):
return
fields = (
EmbedField(
name="Before",
value=f"{before.display_name} ({before.username}#{before.discriminator})",
),
EmbedField(
name="After", value=f"{after.display_name} ({after.username}#{after.discriminator})"
),
)
embed = build_embed(
title="User Renamed",
description=f"{after.mention} changed their name",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
@listen()
async def on_member_update(self, event: MemberUpdate) -> None:
"""Handle on_member_update event."""
before = event.before
after = event.after
if (before.display_name == after.display_name and before.roles == after.roles) or (
not after or not before
):
return
log = await Setting.find_one(q(guild=before.guild.id, setting="activitylog"))
if log:
channel = await before.guild.fetch_channel(log.value)
await asyncio.sleep(0.5) # Wait for audit log
embed = None
if before._role_ids != after._role_ids:
verified = await Setting.find_one(q(guild=before.guild.id, setting="verified"))
v_role = None
if verified:
v_role = await before.guild.fetch_role(verified.value)
if not v_role:
self.logger.debug(f"Guild {before.guild.id} verified role no longer exists")
await verified.delete()
else:
if not before.has_role(v_role) and after.has_role(v_role):
embed = await self.process_verify(before, after)
embed = embed or await self.process_rolechange(before, after)
embed = embed or await self.process_rename(before, after)
if embed:
await channel.send(embeds=embed)

View file

@ -0,0 +1,434 @@
"""JARVIS message event mixin"""
import re
from datetime import datetime, timedelta, timezone
from aiohttp import ClientSession
from jarvis_core.db import q
from jarvis_core.db.models import Autopurge, Autoreact, Roleping, Setting, Warning
from jarvis_core.filters import invites, url
from naff import listen
from naff.api.events.discord import MessageCreate, MessageDelete, MessageUpdate
from naff.client.utils.misc_utils import find_all
from naff.models.discord.channel import DMChannel
from naff.models.discord.embed import EmbedField
from naff.models.discord.enums import Permissions
from naff.models.discord.message import Message
from jarvis.tracking import malicious_tracker, warnings_tracker
from jarvis.utils import build_embed
from jarvis.utils.embeds import warning_embed
class MessageEventMixin:
# Message
async def autopurge(self, message: Message) -> None:
"""Handle autopurge events."""
autopurge = await Autopurge.find_one(q(guild=message.guild.id, channel=message.channel.id))
if autopurge:
if not message.author.has_permission(Permissions.ADMINISTRATOR):
self.logger.debug(
f"Autopurging message {message.guild.id}/{message.channel.id}/{message.id}"
)
await message.delete(delay=autopurge.delay)
async def autoreact(self, message: Message) -> None:
"""Handle autoreact events."""
autoreact = await Autoreact.find_one(
q(
guild=message.guild.id,
channel=message.channel.id,
)
)
if autoreact:
self.logger.debug(
f"Autoreacting to message {message.guild.id}/{message.channel.id}/{message.id}"
)
for reaction in autoreact.reactions:
await message.add_reaction(reaction)
if autoreact.thread:
name = message.content.replace("\n", " ")
name = re.sub(r"<:\w+:(\d+)>", "", name)
if len(name) > 100:
name = name[:97] + "..."
await message.create_thread(name=message.content, reason="Autoreact")
async def checks(self, message: Message) -> None:
"""Other message checks."""
# #tech
# channel = find(lambda x: x.id == 599068193339736096, message._mention_ids)
# if channel and message.author.id == 293795462752894976:
# await channel.send(
# content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif" # noqa: E501
# )
content = re.sub(r"\s+", "", message.content)
match = invites.search(content)
setting = await Setting.find_one(q(guild=message.guild.id, setting="noinvite"))
if not setting:
setting = Setting(guild=message.guild.id, setting="noinvite", value=True)
await setting.commit()
if match:
guild_invites = await message.guild.fetch_invites()
if message.guild.vanity_url_code:
guild_invites.append(message.guild.vanity_url_code)
allowed = [x.code for x in guild_invites] + [
"dbrand",
"VtgZntXcnZ",
"gPfYGbvTCE",
]
if (m := match.group(1)) not in allowed and setting.value:
self.logger.debug(f"Removing non-allowed invite `{m}` from {message.guild.id}")
try:
await message.delete()
except Exception:
self.logger.debug("Message deleted before action taken")
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Sent an invite link",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Sent an invite link")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def massmention(self, message: Message) -> None:
"""Handle massmention events."""
massmention = await Setting.find_one(
q(
guild=message.guild.id,
setting="massmention",
)
)
if (
massmention
and massmention.value > 0 # noqa: W503
and len(message._mention_ids + message._mention_roles) # noqa: W503
- (1 if message.author.id in message._mention_ids else 0) # noqa: W503
> massmention.value # noqa: W503
):
self.logger.debug(
f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Mass Mention",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Mass Mention")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def roleping(self, message: Message) -> None:
"""Handle roleping events."""
try:
if message.author.has_permission(Permissions.MANAGE_GUILD):
return
except Exception as e:
self.logger.error("Failed to get permissions, pretending check failed", exc_info=e)
if await Roleping.collection.count_documents(q(guild=message.guild.id, active=True)) == 0:
return
rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None)
# Get all role IDs involved with message
roles = [x.id async for x in message.mention_roles]
async for mention in message.mention_users:
roles += [x.id for x in mention.roles]
if not roles:
return
# Get all roles that are rolepinged
roleping_ids = [r.role for r in rolepings]
# Get roles in rolepings
role_in_rolepings = find_all(lambda x: x in roleping_ids, roles)
# Check if the user has the role, so they are allowed to ping it
user_missing_role = any(x.id not in roleping_ids for x in message.author.roles)
# Admins can ping whoever
user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR)
# Check if user in a bypass list
def check_has_role(roleping: Roleping) -> bool:
return any(role.id in roleping.bypass["roles"] for role in message.author.roles)
user_has_bypass = False
for roleping in rolepings:
if message.author.id in roleping.bypass["users"]:
user_has_bypass = True
break
if check_has_role(roleping):
user_has_bypass = True
break
if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass:
self.logger.debug(
f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Pinged a blocked role/user with a blocked role",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
async def phishing(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
if (m := match.group("domain")) in self.phishing_domains:
self.logger.debug(
f"Phishing url `{m}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Phishing URL",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
embed = warning_embed(message.author, "Phishing URL")
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
try:
await message.delete()
except Exception:
self.logger.warn("Failed to delete malicious message")
tracker = malicious_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
return True
return False
async def malicious_url(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
async with ClientSession() as session:
resp = await session.post(
"https://anti-fish.bitflow.dev/check",
json={"message": match.string},
headers={
"Application-Name": "JARVIS",
"Application-Link": "https://git.zevaryx.com/stark-industries/jarvis",
},
)
if resp.status != 200:
break
data = await resp.json()
if data["match"]:
self.logger.debug(
f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Unsafe URL",
user=message.author.id,
).commit()
tracker = warnings_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
reasons = ", ".join(f"{m['source']}: {m['type']}" for m in data["matches"])
embed = warning_embed(message.author, reasons)
try:
await message.channel.send(embeds=embed)
except Exception:
self.logger.warn("Failed to send warning embed")
try:
await message.delete()
except Exception:
self.logger.warn("Failed to delete malicious message")
tracker = malicious_tracker.labels(
guild_id=message.guild.id, guild_name=message.guild.name
)
tracker.inc()
return True
return False
@listen()
async def on_message(self, event: MessageCreate) -> None:
"""Handle on_message event. Calls other event handlers."""
message = event.message
if not isinstance(message.channel, DMChannel) and not message.author.bot:
await self.autoreact(message)
await self.massmention(message)
await self.roleping(message)
await self.autopurge(message)
await self.checks(message)
if not await self.phishing(message):
await self.malicious_url(message)
@listen()
async def on_message_edit(self, event: MessageUpdate) -> None:
"""Process on_message_edit events."""
before = event.before
after = event.after
if not after.author.bot:
modlog = await Setting.find_one(q(guild=after.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=after.guild.id, setting="log_ignore"))
if modlog and (ignore and after.channel.id not in ignore.value):
if not before or before.content == after.content or before.content is None:
return
try:
channel = before.guild.get_channel(modlog.value)
fields = [
EmbedField(
"Original Message",
before.content if before.content else "N/A",
False,
),
EmbedField(
"New Message",
after.content if after.content else "N/A",
False,
),
]
embed = build_embed(
title="Message Edited",
description=f"{after.author.mention} edited a message in {before.channel.mention}",
fields=fields,
color="#fc9e3f",
timestamp=after.edited_timestamp,
url=after.jump_url,
)
embed.set_author(
name=after.author.username,
icon_url=after.author.display_avatar.url,
url=after.jump_url,
)
embed.set_footer(
text=f"{after.author.username}#{after.author.discriminator} | {after.author.id}"
)
await channel.send(embeds=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {before.guild.id}/{before.channel.id}/{before.id}: {e}"
)
if not isinstance(after.channel, DMChannel) and not after.author.bot:
await self.massmention(after)
await self.roleping(after)
await self.checks(after)
await self.roleping(after)
await self.checks(after)
if not await self.phishing(after):
await self.malicious_url(after)
@listen()
async def on_message_delete(self, event: MessageDelete) -> None:
"""Process on_message_delete events."""
message = event.message
modlog = await Setting.find_one(q(guild=message.guild.id, setting="activitylog"))
ignore = await Setting.find_one(q(guild=message.guild.id, setting="log_ignore"))
if modlog and (ignore and message.channel.id not in ignore.value):
try:
content = message.content or "N/A"
except AttributeError:
content = "N/A"
fields = [EmbedField("Original Message", content, False)]
try:
if message.attachments:
value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments])
fields.append(
EmbedField(
name="Attachments",
value=value,
inline=False,
)
)
if message.sticker_items:
value = "\n".join([f"Sticker: {x.name}" for x in message.sticker_items])
fields.append(
EmbedField(
name="Stickers",
value=value,
inline=False,
)
)
if message.embeds:
value = str(len(message.embeds)) + " embeds"
fields.append(
EmbedField(
name="Embeds",
value=value,
inline=False,
)
)
channel = message.guild.get_channel(modlog.value)
embed = build_embed(
title="Message Deleted",
description=f"{message.author.mention}'s message was deleted from {message.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(
name=message.author.username,
icon_url=message.author.display_avatar.url,
url=message.jump_url,
)
embed.set_footer(
text=(
f"{message.author.username}#{message.author.discriminator} | "
f"{message.author.id}"
)
)
await channel.send(embeds=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}"
)

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

@ -0,0 +1,34 @@
"""JARVIS task mixin."""
from aiohttp import ClientSession
from naff.models.naff.tasks.task import Task
from naff.models.naff.tasks.triggers import IntervalTrigger
class TaskMixin:
@Task.create(IntervalTrigger(minutes=1))
async def _update_domains(self) -> None:
self.logger.debug("Updating phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/recent/60")
response.raise_for_status()
data = await response.json()
self.logger.debug(f"Found {len(data)} changes to phishing domains")
if len(data) == 0:
return
add = 0
sub = 0
for update in data:
if update["type"] == "add":
for domain in update["domains"]:
if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete":
for domain in update["domains"]:
if domain in self.phishing_domains:
sub -= 1
self.phishing_domains.remove(domain)
self.logger.debug(f"{add} additions, {sub} removals")