Re-write client to remove monofile
This commit is contained in:
parent
f5517db6ae
commit
43e50cb753
8 changed files with 1035 additions and 989 deletions
989
jarvis/client.py
989
jarvis/client.py
|
@ -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
38
jarvis/client/__init__.py
Normal 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
107
jarvis/client/errors.py
Normal 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)
|
115
jarvis/client/events/__init__.py
Normal file
115
jarvis/client/events/__init__.py
Normal 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()
|
144
jarvis/client/events/components.py
Normal file
144
jarvis/client/events/components.py
Normal 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)
|
163
jarvis/client/events/member.py
Normal file
163
jarvis/client/events/member.py
Normal 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)
|
434
jarvis/client/events/message.py
Normal file
434
jarvis/client/events/message.py
Normal 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
34
jarvis/client/tasks.py
Normal 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")
|
Loading…
Add table
Reference in a new issue