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