diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82f9275 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b2b792 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12 + +WORKDIR /app +COPY . . + +RUN pip install uv +RUN uv sync --no-dev + +ENTRYPOINT [ "/bin/bash" ] +CMD [ "run.sh" ] diff --git a/compose.yml b/compose.yml index 826c3a7..79c788a 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,13 @@ name: "payphone" services: + bot: + build: . + env_file: + - .env + environment: + - IS_DEBUG=false + database: image: postgres:16.3 environment: diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..0f1bb37 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# Check for updates. +git pull + +# Create the enviroment file. +if [ ! -f ".env" ]; then + # If the destination file doesn't exist, copy the source file to the destination + cp ".env.example" ".env" + + if [ -z "$EDITOR" ]; then + EDITOR="nano" + fi + + $EDITOR .env +else + echo ".env file already exists. Make sure to check for updates in the .env.example file." +fi + +# Build image and create the container. +docker compose down + +docker compose build + +docker compose up -d + +echo "Everything's set up and running!" +echo "You can stop the bot running 'docker compose down'." diff --git a/payphone/bot.py b/payphone/bot.py index 2dc7568..cf4a90f 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -17,7 +17,7 @@ from payphone.core import config, constants, exceptions, formatting, help_comman # TODO: Make it an AutoShardedBot on production. class Payphone(commands.AutoShardedBot): - redis: aredis.Redis + redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis") def __init__(self) -> None: intents = discord.Intents.default() @@ -41,7 +41,6 @@ class Payphone(commands.AutoShardedBot): await setup.init() - self.redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis") await self.redis.ping() await self.redis.delete( @@ -130,12 +129,12 @@ class Payphone(commands.AutoShardedBot): return bool(int(is_banned)) is_banned = await models.Ban.filter( - expressions.Q(user_id=user_id) - & expressions.Q( + expressions.Q(user_id=user_id), + expressions.Q( expires_at=None, - expires_at__lt=datetime.now(timezone.utc), + expires_at__gt=datetime.now(timezone.utc), join_type="OR", - ) + ), ).exists() await self.redis.set(key, int(is_banned), ex=300) @@ -208,24 +207,41 @@ class Payphone(commands.AutoShardedBot): await self.redis.set(key, "0", ex=300) - async def get_prefix(self, message: discord.Message) -> str | list[str]: + async def get_prefix( + self, message: discord.Message | None = None, server_id: int | None = None + ) -> str | list[str]: """Returns the prefix for the message. The prefix is case-insensitive, meaning that it'll adapt to the message to be valid not depending of the case. In case that the prefix is invalid, the default or set prefix in its original case will be returned. + This method can also be used on a server's raw ID. Either `message` or `server_id` must be + set, but not both. + Args: - message (discord.Message): The message to check the prefix for. + message (discord.Message | None): The message to check the prefix for, or None. + server_id (int | None): The server's ID to get the prefix from. Returns: str: The prefix for the message. + + Raises: + ValueError: Either `message` or `server_id` must be set, but not both. """ - if message.guild is None: + if message is not None and server_id is not None: + raise ValueError( + "Either `message` or `server_id` must be set, but not both." + ) + + if message is None and server_id is None: + raise ValueError("Either `message` or `server_id` must be set.") + + if message is not None and message.guild is None: return ["p.", "P."] - server_id = message.guild.id + server_id = server_id if server_id is not None else message.guild.id key = constants.PREFIX_TEMPLATE % server_id diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py index ddd6b42..6db275b 100644 --- a/payphone/cogs/admin/cog.py +++ b/payphone/cogs/admin/cog.py @@ -1,8 +1,10 @@ +import discord from discord.ext import commands from tortoise import transactions from payphone import bot +from payphone.db import models, utils from payphone.cogs import base @@ -21,6 +23,8 @@ class Admin(base.Cog): Settings: - `prefix`: Set a custom prefix. + - `enable`: Enable a (or all) channel(s) to run commands in it. Non-enabled channels will pass to be disabled. + - `disable`: Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. You can use `{prefix}help config ` to get help about a specific configuration. """ @@ -50,6 +54,71 @@ class Admin(base.Cog): await ctx.send(f"✅ The prefix was set to `{prefix}`!") + @config.command(extras={"help_name": "Enable Channel"}) + @commands.guild_only() + @commands.has_guild_permissions(manage_channels=True) + @transactions.atomic() + async def enable( + self, ctx: commands.Context, channel: discord.TextChannel | str | None + ) -> None: + """ + Enable a channel to run commands in it. Non-enabled channels will pass to be disabled. + + Options: + - `[]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled. + + Permissions: + - Manage Channels + """ + + server_id = ctx.guild.id + channel = channel or ctx.channel + + if isinstance(channel, str) and channel.lower() == "all": + await models.Channel.filter(server_id=server_id).update(enabled=None) + + await ctx.send( + "✅ All channels were enabled! You can now run commands in any channel." + ) + + else: + row = await utils.get_channel(channel) + + row.enabled = True + await row.save() + + await ctx.send( + f"✅ {channel.mention} was enabled! Other non-enabled channels were disabled." + ) + + @config.command(extras={"help_name": "Disable Channel"}) + @commands.guild_only() + @commands.has_guild_permissions(manage_channels=True) + @transactions.atomic() + async def disable( + self, ctx: commands.Context, channel: discord.TextChannel | None + ) -> None: + """ + Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. + + Options: + - `[]`: The channel to disable. If no channel is specified, the current channel will be disabled. + + Permissions: + - Manage Channels + """ + + channel = channel or ctx.channel + + row = await utils.get_channel(channel) + + row.enabled = False + await row.save() + + await ctx.send( + f"✅ {channel.mention} was disabled. Commands cannot be ran in this channel anymore." + ) + def setup(b: bot.Payphone) -> None: """Load the cog into the bot. diff --git a/payphone/cogs/calls/cog.py b/payphone/cogs/calls/cog.py index 9141aa8..e15506c 100644 --- a/payphone/cogs/calls/cog.py +++ b/payphone/cogs/calls/cog.py @@ -1,5 +1,7 @@ from datetime import datetime, timezone, timedelta +import asyncio + import secrets import discord @@ -14,7 +16,8 @@ import aiohttp from payphone import bot from payphone.db import models, utils from payphone.cogs import base -from payphone.core import constants, checks +from payphone.cogs.calls import embeds +from payphone.core import constants, checks, censorship, formatting class Calls(base.Cog): @@ -69,6 +72,14 @@ class Calls(base.Cog): await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, *hung_up_channel_ids) + @hangup_calls.before_loop + async def before_hangup_calls(self): + """Wait until the bot is ready to start looping. + """ + + await self.bot.wait_until_ready() + await asyncio.wait(5) + @commands.command(aliases=["c"]) @commands.guild_only() @checks.is_unbanned() @@ -537,6 +548,7 @@ class Calls(base.Cog): if call.ended_at is None: call.ended_at = datetime.now(timezone.utc) + await call.save() updated_channels = [] @@ -583,7 +595,7 @@ class Calls(base.Cog): channel_id = ctx.channel.id call = await models.Call.get(channels__id=channel_id).prefetch_related( - "channels" + "channels", "call_rss" ) other_channel = find(lambda c: c.id != channel_id, call.channels) @@ -643,9 +655,10 @@ class Calls(base.Cog): user, profile = await utils.get_user(message.author) - used_masks = await models.CallUserChannelRelationship.filter( + call_rss = await models.CallUserChannelRelationship.filter( call=call, channel=channel - ).values_list("mask", flat=True) + ).select_related("profile") + used_masks = [rs.mask for rs in call_rss] call_rs, _ = await models.CallUserChannelRelationship.get_or_create( { @@ -660,28 +673,50 @@ class Calls(base.Cog): channel=channel, ) - async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) + # Since .select_related() can't be used and profile was already fetched, we need to + # manually set it. + call_rs.profile = profile - mask = constants.Mask(call_rs.mask) + blocked_word = censorship.moderate(message.content) - username: str - avatar_url: str + webhook_message = None + cleaned_content = formatting.clean(message, call_rss) - if call_rs.is_masked: - username = mask.name.lower().replace("_", " ").title() - avatar_url = f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png" + if blocked_word: + try: + await self.bot.get_partial_messageable(channel_id).send( + f"Your message wasn't delivered!\nBlocked word: `{blocked_word}`." + ) - else: - username = profile.name - avatar_url = profile.avatar_url + except errors.DiscordException: + pass - webhook_message = await webhook.send( - message.content, - username=username, - avatar_url=avatar_url, - wait=True, - ) + prefix = await self.bot.get_prefix(server_id=other_channel.server_id) + + try: + await self.bot.get_partial_messageable(other_channel.id).send( + f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** said something that's not allowed. If you think they're doing something inappropiate, use `{prefix}report`." + ) + + except errors.DiscordException: + pass + + else: + async with aiohttp.ClientSession() as session: + webhook = other_channel.webhook(session) + + embed = None + + if message.reference: + embed = await embeds.ReplyEmbed(message.reference, call_id=call.id) + + webhook_message = await webhook.send( + cleaned_content, + username=call_rs.display_name, + avatar_url=call_rs.display_avatar, + embed=embed, + wait=True, + ) message_row = await models.Message.create( id=message.id, @@ -690,13 +725,14 @@ class Calls(base.Cog): user=user, profile=profile, mask=call_rs.mask if call_rs.is_masked else None, - propagation_id=webhook_message.id, - propagation_channel=other_channel, + propagation_id=webhook_message.id if webhook_message else None, + propagation_channel=other_channel if webhook_message else None, ) await models.MessageContent.create( message=message_row, - content=message.content, + content=cleaned_content, + was_propagated=not bool(blocked_word), ) @commands.Cog.listener() @@ -736,7 +772,24 @@ class Calls(base.Cog): async with aiohttp.ClientSession() as session: webhook = other_channel.webhook(session) - await webhook.delete_message(message.propagation_id) + try: + await webhook.delete_message(message.propagation_id) + + except errors.DiscordException: + # This message was already deleted. + pass + + for replying_message in await models.Message.filter( + replying_to_id=message_id + ).exclude(propagation_channel_id=channel_id): + try: + await webhook.edit_message( + replying_message.propagation_id, embed=None + ) + + except errors.DiscordException: + # This message was deleted. + pass @commands.Cog.listener() @commands.guild_only() @@ -761,6 +814,9 @@ class Calls(base.Cog): channel_id = payload.channel_id content = payload.data["content"] + if censorship.moderate(content) is not None: + return + is_on_call = await self.bot.redis.sismember( constants.CHANNELS_ONCALL_SET, str(channel_id) ) @@ -783,7 +839,14 @@ class Calls(base.Cog): async with aiohttp.ClientSession() as session: webhook = other_channel.webhook(session) - await webhook.edit_message(message.propagation_id, content=content) + try: + await webhook.edit_message(message.propagation_id, content=content) + + except errors.DiscordException: + # This message was deleted. + pass + + # TODO: Edit replies. await models.MessageContent.create(message=message, content=content) diff --git a/payphone/cogs/calls/embeds.py b/payphone/cogs/calls/embeds.py index 4c1d6b5..1afb0cc 100644 --- a/payphone/cogs/calls/embeds.py +++ b/payphone/cogs/calls/embeds.py @@ -1,12 +1,64 @@ import discord +from tortoise import expressions -async def ReplyEmbed(reference: discord.MessageReference) -> discord.Embed: +from payphone.db import models +from payphone.core import constants, formatting + + +async def ReplyEmbed( + reference: discord.MessageReference, call_id: int +) -> discord.Embed | None: """Generates the embed sent on calls to represent replies. Args: reference (discord.MessageReference): The message reference. + call_id (int): The ID of the call the message was sent in. Returns: - discord.Embed: The generated embed. + discord.Embed | None: The generated embed. """ + + message = ( + await models.Message.get_or_none( + expressions.Q(id=reference.message_id) + | expressions.Q(propagation_id=reference.message_id), + call_id=call_id, + ) + .prefetch_related("contents") + .select_related("profile", "channel", "propagation_channel") + ) + + is_this_channel = message.id == reference.message_id + + if message is None: + return None + + last_content: models.MessageContent | None = None + + for content in message.contents: + if last_content is None: + last_content = content + continue + + if last_content.message_id < content.message_id: + last_content = content + + author_name = message.display_name if is_this_channel else message.profile.name + author_avatar_url = ( + message.display_avatar if is_this_channel else message.profile.avatar_url + ) + + embed = discord.Embed( + color=constants.EMBED_COLOR, + description=formatting.trim(last_content.content) + if last_content.was_propagated or is_this_channel + else "*This message was censored.*", + author=discord.EmbedAuthor( + name=f"Replied to {author_name}", + icon_url=author_avatar_url, + url=message.jump_url(is_this_channel), + ), + ) + + return embed diff --git a/payphone/cogs/mods/cog.py b/payphone/cogs/mods/cog.py index 6692f47..4abb18b 100644 --- a/payphone/cogs/mods/cog.py +++ b/payphone/cogs/mods/cog.py @@ -1,4 +1,7 @@ -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta + +import sys +import asyncio import discord from discord import errors @@ -169,6 +172,32 @@ class Mods(base.Cog): ) @commands.command() + @checks.is_moderator() + @transactions.atomic() + async def reports(self, ctx: commands.Context) -> None: + """ + Shows the next report to review. + """ + + report = ( + await models.Report.filter(status=models.ReportStatus.ON_REVIEW) + .order_by("id") + .limit(1) + .first() + .select_related("call") + .prefetch_related("call__call_rss", "call__call_rss__profile") + ) + + if report is None: + await ctx.send("✅ You're up to date! No reports left to check.") + return + + await ctx.send( + embed=embeds.ReportEmbed(self.bot, report), + view=views.ReportView(self.bot, report), + ) + + @commands.command(aliases=["mod"]) @commands.is_owner() @transactions.atomic() async def moderator( @@ -199,6 +228,73 @@ class Mods(base.Cog): f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`." ) + @commands.command() + @commands.is_owner() + @transactions.atomic() + async def shutdown( + self, ctx: commands.Context, seconds: int = 60, *, reason: str | None + ) -> None: + """ + Notifies all the in-call users of the scheduled shutdown and shuts down Payphone after x seconds. + + Options: + - `[]`: The amount of seconds to wait before shutdown. + - `[]`: The reason of the shutdown. + """ + + oncall_channel_ids = list( + await self.bot.redis.smembers(constants.CHANNELS_ONCALL_SET) + ) + searching_channel_ids = list( + await self.bot.redis.smembers(constants.CHANNELS_SEARCH_QUEUE) + ) + + channel_ids = [int(c) for c in oncall_channel_ids] + [ + int(c) for c in searching_channel_ids + ] + + await ctx.send(f"⚡ Notifying `{len(channel_ids)}` channels of the shutdown.") + + for channel_id in channel_ids: + try: + await self.bot.get_partial_messageable(channel_id).send( + f"⚡ Payphone will be shutdown due to a maintenance restart." + + (" Reason: " + reason if reason is not None else "") + ) + + except errors.DiscordException: + pass + + await ctx.send( + f"⚡ All channels were notified of the shutdown. Payphone will shut down ." + ) + + await asyncio.sleep(seconds) + + oncall_channel_ids = list( + await self.bot.redis.smembers(constants.CHANNELS_ONCALL_SET) + ) + searching_channel_ids = list( + await self.bot.redis.smembers(constants.CHANNELS_SEARCH_QUEUE) + ) + + channel_ids = [int(c) for c in oncall_channel_ids] + [ + int(c) for c in searching_channel_ids + ] + + for channel_id in channel_ids: + try: + await self.bot.get_partial_messageable(channel_id).send( + "⚡ The call will be ended due to a scheduled shutdown." + ) + + except errors.DiscordException: + pass + + await ctx.send("⚡ Payphone was shut down.") + + sys.exit() + def setup(b: bot.Payphone) -> None: """Load the cog into the bot. diff --git a/payphone/cogs/mods/embeds.py b/payphone/cogs/mods/embeds.py index 5167e7c..a28642f 100644 --- a/payphone/cogs/mods/embeds.py +++ b/payphone/cogs/mods/embeds.py @@ -1,8 +1,9 @@ import discord +from discord.utils import find from payphone import bot from payphone.db import models -from payphone.core import constants +from payphone.core import constants, formatting class BanNotificationEmbed(discord.Embed): @@ -44,3 +45,44 @@ class BanNotificationEmbed(discord.Embed): ), thumbnail=bot.user.display_avatar.url, ) + + +class ReportEmbed(discord.Embed): + """The report embed shown to moderators.""" + + def __init__(self, bot: bot.Payphone, report: models.Report) -> None: + """Initializes the embed. + + Args: + bot (bot.Payphone): The instance of the bot. + report (models.Report): The report. + """ + + reported_by_profile: models.Profile = find( + lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss + ).profile + + super().__init__( + color=constants.EMBED_COLOR, + title="Report", + description=f"{reported_by_profile.link} reported a call.", + fields=[ + discord.EmbedField(name="Reason", value=report.reason), + discord.EmbedField( + name="Participants", + value=( + f"Channel 1: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id == report.call.call_rss[0].channel_id, report.call.call_rss)))}\n" + f"Channel 2: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id == report.call.call_rss[0].channel_id, report.call.call_rss)))}" + ), + ), + discord.EmbedField( + name="Duration", + value=f"{formatting.time_to_text(report.call.ended_at - report.call.started_at)}, from to .", + ), + ], + author=bot.embed_author, + footer=discord.EmbedFooter( + text=f"Call ID: {report.call.id} | Report ID: {report.id}" + ), + thumbnail=bot.user.display_avatar.url, + ) diff --git a/payphone/cogs/mods/modals.py b/payphone/cogs/mods/modals.py index c118c85..288db5a 100644 --- a/payphone/cogs/mods/modals.py +++ b/payphone/cogs/mods/modals.py @@ -1,9 +1,13 @@ +from datetime import datetime, timezone + import discord +from discord import errors from tortoise import transactions from payphone import bot from payphone.db import models +from payphone.core import formatting from payphone.cogs.mods import embeds, views @@ -36,10 +40,88 @@ class BanNotificationModal(discord.ui.Modal): await interaction.response.defer() ban.appeal = self.children[0].value - ban.status = models.BanStatus.APPEALED + ban.status = models.BanStatus.ON_REVIEW await ban.save() await interaction.message.edit( embed=embeds.BanNotificationEmbed(self.bot, ban), view=views.BanNotificationView(self.bot, ban), ) + + +class BanUserModal(discord.ui.Modal): + """Modal sent to ban a user.""" + + def __init__( + self, bot: bot.Payphone, profile: models.Profile, report_id: int | None = None + ) -> None: + """Initializes the modal. + + Args: + bot (bot.Bot): The bot. + report_id (int, optional): The ID of the report, if any. + profile (models.Profile): The profile of the user to ban. + """ + + self.bot = bot + self.profile = profile + self.report_id = report_id + + super().__init__( + discord.ui.InputText( + style=discord.InputTextStyle.long, + label="Reason", + max_length=500, + required=False, + ), + discord.ui.InputText( + label="Duration", placeholder="30d", value="30d", required=False + ), + title=f"Ban {profile.name}", + ) + + @transactions.atomic() + async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() + + duration = None + + if self.children[1].value: + try: + duration = formatting.parse_timedelta(self.children[1].value) + + except ValueError: + await interaction.response.send_message( + f"❌ The ban's duration (`{self.children[1].value}`) is not a valid time. Time must be formatted like `28d 3h 5s`." + ) + return + + ban = await self.bot.ban( + self.profile.user_id, + datetime.now(timezone.utc) + duration if duration is not None else None, + self.children[0].value, + interaction.user.id, + ) + + try: + notification_message = await ( + await self.bot.get_or_fetch_user(self.profile.user_id) + ).send( + embed=embeds.BanNotificationEmbed(self.bot, ban), + view=views.BanNotificationView(self.bot, ban), + ) + + ban.notification_message_id = notification_message.id + await ban.save() + + except errors.DiscordException: + pass + + message = f"✅ **{self.profile.name}** (User ID: {self.profile.user_id}) was banned by {interaction.user.mention}" + + if ban.expires_at is not None: + message += f" until ()" + + message += "." + + await interaction.channel.send(message) diff --git a/payphone/cogs/mods/views.py b/payphone/cogs/mods/views.py index c207187..d8feb31 100644 --- a/payphone/cogs/mods/views.py +++ b/payphone/cogs/mods/views.py @@ -1,8 +1,13 @@ import discord +from discord.ext import pages +from discord.utils import find + +from tortoise import transactions from payphone import bot from payphone.db import models -from payphone.cogs.mods import modals +from payphone.core import constants +from payphone.cogs.mods import modals, views, embeds class BanNotificationView(discord.ui.View): @@ -19,7 +24,7 @@ class BanNotificationView(discord.ui.View): ban (models.Ban): The ban to appeal. """ - super().__init__() + super().__init__(timeout=None) self.bot = bot @@ -45,3 +50,179 @@ class BanNotificationView(discord.ui.View): """Sends the ban appeal modal.""" await interaction.response.send_modal(modals.BanNotificationModal(self.bot)) + + +class ReportView(discord.ui.View): + """The view sent together with the report embed.""" + + def __init__(self, bot: bot.Payphone, report: models.Report) -> None: + """Initializes the view. + + Args: + bot (bot.Payphone): The bot. + report (models.Report): The report. + """ + + super().__init__(timeout=300, disable_on_timeout=True) + + self.bot = bot + self.report = report + + option_labels = set( + f"Ban {rs.profile.name} [ID: {rs.user_id}]" for rs in report.call.call_rss + ) + + actions = discord.ui.Select( + placeholder="Actions", + options=[discord.SelectOption(label=label) for label in option_labels], + ) + + async def actions_callback(interaction: discord.Interaction) -> None: + # interaction.data = {'values': ['Ban Nyeki (JPP)'], 'custom_id': 'staff_call_report:actions', 'component_type': 3} + value = interaction.data["values"][0] + + user_id = int(value.rsplit("[ID: ", 1)[1][:-1]) + + await interaction.response.send_modal( + modals.BanUserModal( + bot, + find( + lambda rs: rs.user_id == user_id, report.call.call_rss + ).profile, + report.id, + ) + ) + + actions.callback = actions_callback + + self.add_item(actions) + + @discord.ui.button(label="Message Logs", style=discord.ButtonStyle.gray, row=2) + async def message_logs( + self, _button: discord.ui.Button, interaction: discord.Interaction + ) -> None: + report = self.report + + messages = ( + await models.Message.filter(call=report.call) + .order_by("id") + .prefetch_related("contents") + ) + + logs = [] + + reporter_channel_id = find( + lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss + ).channel_id + + profiles_mapping = {rs.profile_id: rs.profile for rs in report.call.call_rss} + + last_user_id = None + last_channel_id = None + + current_block = "" + + for message in messages: + is_new_block = ( + message.user_id != last_user_id or last_channel_id != message.channel_id + ) + + if is_new_block: + logs.append(current_block) + current_block = "" + + current_block += f"{"+" if message.channel_id == reporter_channel_id else "-"} {profiles_mapping[message.profile_id].name}:" + + for content in message.contents: + current_block += ( + f"\n{"!" if not content.was_propagated else " "} {content.content}" + ) + + last_user_id = message.user_id + last_channel_id = message.channel_id + + log_pages = [] + + for current_block in logs: + if len(log_pages) > 0 and len(log_pages[-1]) + len(current_block) < 1000: + log_pages[-1] += "\n\n" + current_block.replace( + "```", + f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", + ) + + else: + log_pages.append( + current_block.replace( + "```", + f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", + ) + ) + + await pages.Paginator( + [f"```diff\n{page}\n```" for page in log_pages], + timeout=300, + ).respond(interaction, ephemeral=True) + + @discord.ui.button(label="Accept", style=discord.ButtonStyle.green, row=2) + @transactions.atomic() + async def accept( + self, _button: discord.ui.Button, interaction: discord.Interaction + ) -> None: + report = self.report + + report.status = models.ReportStatus.ACCEPTED + await report.save() + + report = ( + await models.Report.filter(status=models.ReportStatus.ON_REVIEW) + .order_by("id") + .limit(1) + .first() + .select_related("call") + .prefetch_related("call__call_rss", "call__call_rss__profile") + ) + + if report is None: + await interaction.response.edit_message( + content="✅ You're up to date! No reports left to check.", + embed=None, + view=None + ) + return + + await interaction.response.edit_message( + embed=embeds.ReportEmbed(self.bot, report), + view=views.ReportView(self.bot, report), + ) + + @discord.ui.button(label="Decline", style=discord.ButtonStyle.red, row=2) + @transactions.atomic() + async def decline( + self, _button: discord.ui.Button, interaction: discord.Interaction + ) -> None: + report = self.report + + report.status = models.ReportStatus.DECLINED + await report.save() + + report = ( + await models.Report.filter(status=models.ReportStatus.ON_REVIEW) + .order_by("id") + .limit(1) + .first() + .select_related("call") + .prefetch_related("call__call_rss", "call__call_rss__profile") + ) + + if report is None: + await interaction.response.edit_message( + content="✅ You're up to date! No reports left to check.", + embed=None, + view=None + ) + return + + await interaction.response.edit_message( + embed=embeds.ReportEmbed(self.bot, report), + view=views.ReportView(self.bot, report), + ) diff --git a/payphone/cogs/users/cog.py b/payphone/cogs/users/cog.py index 6168ba0..dcfe898 100644 --- a/payphone/cogs/users/cog.py +++ b/payphone/cogs/users/cog.py @@ -130,6 +130,7 @@ class Users(base.Cog): call_rss = ( await models.CallUserChannelRelationship.filter(call_id__in=call_ids) .select_related("profile") + .order_by("-id") .all() ) calls_rss_map = {} diff --git a/payphone/core/censorship.py b/payphone/core/censorship.py index b757b52..2dc28c4 100644 --- a/payphone/core/censorship.py +++ b/payphone/core/censorship.py @@ -1,36 +1,6 @@ -# TODO: Rewrite the whole file. - -from enum import Enum - -from dataclasses import dataclass - import re -class ViolationLevel(Enum): - # Warn the user about what they have js said. - WARN = 0 - - # Block the message from being sent - BLOCK = 1 - - # This includes ending the call and creating a report for the call. - REPORT = 2 - - -def get_words(text: str) -> list: - """Returns all words in a string. - - Args: - text (str): The complete text. - - Returns: - list: All the words in the text. - """ - - return list(re.findall(r"\b\w+(?:-\w+)*\b", text, re.IGNORECASE)) - - def create_pattern(text: str) -> str: """Creates regex patterns for words or phrases with possible variations @@ -59,381 +29,233 @@ def create_pattern(text: str) -> str: for r in replacements: text = text.replace(r[0], f"({r[1]})") - return f"^{text}$" + return f"({text})" -blocked_words = { - "nsfw": { - ( - "anal", - "bdsm", - "boner", - "boob", - "clit", - "cock", - "cum", - "dick", - "dildo", - "erotic", - "handjob", - "hentai", - "horny", - "jizz", - "kinky", - "masturbate", - "nipple", - "orgasm", - "penis", - "porn", - "pussy", - "queef", - "sex", - "shemale", - "slut", - "spank", - "sperm", - "tit", - "vagina", - "vulva", - "whore", - "ballsack", - "blowjob", - "busty", - "clitoris", - "cockring", - "cunt", - "deepthroat", - "dominatrix", - "ejaculation", - "fetish", - "gangbang", - "genital", - "jizzed", - "lube", - "milf", - "nudity", - "pecker", - "pussylick", - "semen", - "shemale", - "slutty", - "threesome", - "titfuck", - "titties", - "vibrator", - "wank", - "assfuck", - "assfucker", - "asslick", - "asslicker", - "balllicker", - "balls", - "bang", - "bastard", - "bimbo", - "blowjobs", - "buttfuck", - "buttfucker", - "clitty", - "cockhead", - "cockmunch", - "cumdump", - "cumshot", - "cunilingus", - "cunnilingus", - "deepthroating", - "dildoing", - "doggystyle", - "ejaculate", - "facial", - "felching", - "fisting", - "gangbang", - "handjob", - "hardon", - "hentai", - "jackoff", - "jerking", - "jilling", - "muffdiver", - "nipple", - "nutsack", - "peckerhead", - "pegging", - "pissflaps", - "porno", - "prostate", - "pube", - "pussyfucker", - "pussylicker", - "rimjob", - "rimming", - "scat", - "shag", - "shitfuck", - "shitstorm", - "spunk", - "sucking", - "taint", - "teabagging", - "tit", - "tittyfuck", - "tosser", - "tranny", - "twat", - "wanking", - "pornographic", - "nude", - "explicit content", - "adult movie", - "naked", - ): { - "level": ViolationLevel.BLOCK, - "message": "Keep NSFW conversations in age restricted channels!", - } - }, - "suicide": { - ("kms", "kill myself", "end my life"): { - "level": ViolationLevel.WARN, - "message": "Hey! You're not alone. Need someone to talk to? [Click here to find a free, confidential helpline]().", - }, - ("kys", "kill yourself", "kill your self"): { - "level": ViolationLevel.BLOCK, - "message": "Do not encourage (nor joke about encouraging) self harm or similar.", - }, - }, - "homophobia": { - ("faggot", "fag", "dyke"): { - "level": ViolationLevel.BLOCK, - "message": "Homophobic language is not tolerated.", - } - }, - "racism": { - ( - "nigger", - "nigga", - "chink", - "gook", - "spic", - "wetback", - "cracker", - "honky", - "kike", - ): { - "level": ViolationLevel.BLOCK, - "message": "Racist language is not tolerated.", - } - }, - "drug_related": { - ( - "weed", - "cocaine", - "heroin", - "meth", - "ecstasy", - "lsd", - "marijuana", - "opioid", - "crack", - ): { - "level": ViolationLevel.BLOCK, - "message": "Drug-related content is not allowed. Please refrain from discussing illegal substances.", - } - }, - "self_harm": { - ( - "self-harm", - "self harm", - "self-injury", - "self injury", - "self-mutilation", - "self mutilation", - "hurting myself", - ): { - "level": ViolationLevel.WARN, - "message": "Please seek help if you are struggling. [Click here to find a free, confidential helpline]().", - } - }, - "sensitive_topics": { - ( - "abortion", - "rape", - "molest", - "pedophile", - "incest", - "abuse", - "trafficking", - "domestic violence", - "child abuse", - "neglect", - "victim", - ): { - "level": ViolationLevel.WARN, - "message": "Sensitive topics are not suitable for this bot. Please discuss these issues with appropriate professionals.", - } - }, - "religious_slurs": { - ( - "heathen", - "infidel", - "heretic", - "blasphemer", - "pagan", - "zealot", - "cultist", - "fanatic", - "apostate", - "idolater", - ): { - "level": ViolationLevel.BLOCK, - "message": "Religious slurs are not allowed.", - } - }, - "body_shaming": { - ( - "fatty", - "lardass", - "twig", - "beanpole", - "chubby", - "porker", - "skeletor", - "chunk", - "blubber", - "skinny", - ): { - "level": ViolationLevel.BLOCK, - "message": "Body shaming is not allowed.", - } - }, -} - - -for key, item in blocked_words.items(): - for texts in list(item.keys()): - patterns = [] - - for i, text in enumerate(texts): - # Singular - pattern = create_pattern(text) - patterns.append((text, re.compile(pattern))) - - # Basic plural - pattern = create_pattern(text + "s") - patterns.append((text, re.compile(pattern))) - - # Reassign as tuple. - v = blocked_words[key][texts] - del blocked_words[key][texts] - blocked_words[key][tuple(patterns)] = v - - -@dataclass -class Violation: - word: str - pattern: re.Pattern - message: str - level: ViolationLevel - - -def allowed_gif_url(url: str) -> bool: - """Returns whether a URL is a valid GIF URL from an allowed source or not. - - Args: - url (str): The URL. - - Returns: - bool - """ - - url = url.strip().lower() - - allowed_prefixes = [ - "https://tenor.com/view/", - "https://giphy.com/gifs/", +BLOCKED_WORDS = [ + create_pattern(word) + for word in [ + "anal", + "bdsm", + "boner", + "boob", + "clit", + "cock", + "cum", + "dick", + "dildo", + "erotic", + "handjob", + "hentai", + "horny", + "jizz", + "kinky", + "masturbate", + "nipple", + "orgasm", + "penis", + "porn", + "pussy", + "queef", + "sex", + "shemale", + "slut", + "spank", + "sperm", + "tit", + "vagina", + "vulva", + "whore", + "ballsack", + "blowjob", + "busty", + "clitoris", + "cockring", + "cunt", + "deepthroat", + "dominatrix", + "ejaculation", + "fetish", + "gangbang", + "genital", + "jizzed", + "lube", + "milf", + "nudity", + "pecker", + "pussylick", + "semen", + "shemale", + "slutty", + "threesome", + "titfuck", + "titties", + "vibrator", + "wank", + "assfuck", + "assfucker", + "asslick", + "asslicker", + "balllicker", + "balls", + "bang", + "bastard", + "bimbo", + "blowjobs", + "buttfuck", + "buttfucker", + "clitty", + "cockhead", + "cockmunch", + "cumdump", + "cumshot", + "cunilingus", + "cunnilingus", + "deepthroating", + "dildoing", + "doggystyle", + "ejaculate", + "facial", + "felching", + "fisting", + "gangbang", + "handjob", + "hardon", + "hentai", + "jackoff", + "jerking", + "jilling", + "muffdiver", + "nipple", + "nutsack", + "peckerhead", + "pegging", + "pissflaps", + "porno", + "prostate", + "pube", + "pussyfucker", + "pussylicker", + "rimjob", + "rimming", + "scat", + "shag", + "shitfuck", + "shitstorm", + "spunk", + "sucking", + "taint", + "teabagging", + "tit", + "titfuck", + "tityfuck", + "toser", + "trany", + "twat", + "wanking", + "pornographic", + "nude", + "explicit content", + "adult movie", + "naked", + "kms", + "kys", + "kill myself", + "kill yourself", + "kill your self", + "end my life", + "end your life", + "unlive", + "fagot", + "fag", + "dyke", + "nigger", + "nigga", + "chink", + "gook", + "spic", + "wetback", + "cracker", + "honky", + "kike", + "weed", + "cocaine", + "heroin", + "meth", + "ecstasy", + "lsd", + "marijuana", + "opioid", + "crack", + "self-harm", + "self harm", + "self-injury", + "self injury", + "self-mutilation", + "self mutilation", + "hurting myself", + "abortion", + "rape", + "molest", + "pedophile", + "incest", + "abuse", + "trafficking", + "domestic violence", + "child abuse", + "neglect", + "victim", + "heathen", + "infidel", + "heretic", + "blasphemer", + "pagan", + "zealot", + "cultist", + "fanatic", + "apostate", + "idolater", + "fatty", + "lardass", + "twig", + "beanpole", + "chubby", + "porker", + "skeletor", + "chunk", + "blubber", + "skinny", ] +] - for prefix in allowed_prefixes: - if url.startswith(prefix): - return True - - return False +PATTERN = re.compile("|".join(BLOCKED_WORDS), re.IGNORECASE) +URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE) +INVITES_PATTERN = re.compile(r"discord.gg/[^\s]+", re.IGNORECASE) -url_pattern = re.compile(r"https?://[^\s]+") -invites_pattern = re.compile(r"discord.gg/[^\s]+") - - -def moderate( - message: str, allowed_categories: list[str] = [] -) -> tuple[bool, list[Violation]]: - """Checks for non allowed patterns and returns them. +def moderate(message: str) -> str: + """Returns the first censored word or pattern found. Args: - message (str): The raw message. - allowed_categories (List[str], optional): Categories allowed to be skipped. Defaults to []. + message (str): The message to moderate. Returns: - Tuple[bool, List[Violation]]: Whether the message is clean or not (first item) and all the violations found in the message (second item). + str | None: The blocked pattern found as it was found. """ - found_violations = [] - msg_words = get_words(message) + match = PATTERN.match(message) - for category, words_info in blocked_words.items(): - if category in allowed_categories: - continue + if match is not None: + return match.string - for words, info in words_info.items(): - for word, pattern in words: - found = False - word_count = len(word.split()) + match = URL_PATTERN.match(message) - for msg_word in map( - lambda i: " ".join(msg_words[i : i + word_count]), - range(len(msg_words)), - ): - m = pattern.search(msg_word.lower()) # pylint: disable=no-member + if match is not None: + return match.string - if m is not None: - found_violations.append( - Violation( - word=msg_word, - pattern=pattern, - message=info["message"], - level=info["level"], - ) - ) - found = True - break + match = INVITES_PATTERN.match(message) - if found: - continue + if match is not None: + return message[match.start] - urls = [url for url in re.findall(url_pattern, message) if not allowed_gif_url(url)] - invites = [i for i in re.findall(invites_pattern, message)] - - for url in urls: - found_violations.append( - Violation( - word=url, - pattern=url_pattern, - message="Links aren't allowed on Payphone.", - level=ViolationLevel.BLOCK, - ) - ) - - for invite in invites: - found_violations.append( - Violation( - word=invite, - pattern=invites_pattern, - message="Server invites aren't allowed on Payphone.", - level=ViolationLevel.BLOCK, - ) - ) - - # Determine if the message is allowed to be sent - is_allowed = len(found_violations) == 0 - - return is_allowed, found_violations + return None diff --git a/payphone/core/config.py b/payphone/core/config.py index aa29f9a..baa1156 100644 --- a/payphone/core/config.py +++ b/payphone/core/config.py @@ -10,7 +10,7 @@ IS_DEBUG = os.environ.get("IS_DEBUG", "false") == "true" TORTOISE_ORM = { "connections": { - "default": f"postgres://payphone:payphone@{"localhost" if IS_DEBUG else "postgres"}/payphone", + "default": f"postgres://payphone:payphone@{"localhost" if IS_DEBUG else "database"}/payphone", }, "apps": { "models": { diff --git a/payphone/core/formatting.py b/payphone/core/formatting.py index bc17cd9..9814218 100644 --- a/payphone/core/formatting.py +++ b/payphone/core/formatting.py @@ -1,3 +1,13 @@ +from datetime import timedelta + +import re + +import discord + +from payphone.db import models +from payphone.core import constants + + def trim(text: str, length: int = 100) -> str: """Trims a string if it's too long. @@ -33,7 +43,7 @@ def text_join(texts: list[str], join_word: str = "and") -> str: def monospace_grid(values: list[str], space: int = 14) -> str: - """Creates a monospace grid + """Creates a monospace grid. Args: values (list[str]): All the values that the list must contain @@ -44,3 +54,116 @@ def monospace_grid(values: list[str], space: int = 14) -> str: """ return "```" + "".join([v + " " * (space - len(v)) for v in values]) + "```" + + +def clean( + message: discord.Message, call_rss: list[models.CallUserChannelRelationship] +) -> str: + """Cleans the message. + + Args: + message (discord.Message): The message to clean. + call_rss (list[models.CallUserChannelRelationship]): The call's relationships. + + Returns: + str: The cleaned message content. + """ + + cleaned_message = message.content + + if message.mentions: + for call_rs in call_rss: + cleaned_message = cleaned_message.replace( + f"<@{call_rs.user_id}>", + f"@**{call_rs.display_name}**" + if message.channel.id == call_rs.channel_id + else f"@**{call_rs.profile.name}**", + ) + + for user in message.mentions: + cleaned_message = cleaned_message.replace(user.mention, "@*Not in call*") + + for channel in message.channel_mentions: + cleaned_message = cleaned_message.replace( + f"<#{channel.id}>", f"#**{channel.name}**" + ) + + for role in message.role_mentions: + cleaned_message = cleaned_message.replace(f"<@&{role.id}>", f"@**{role.name}**") + + cleaned_message = cleaned_message.replace( + "@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone" + ).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here") + + return cleaned_message + + +def time_to_text(time_diff: timedelta) -> str: + """Converts a timedelta to its string representation. + + Args: + time_diff (timedelta): The timedelta. + + Returns: + str: The string representation. + """ + + days = time_diff.days + hours, remainder = divmod(time_diff.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + parts = [] + if days > 0: + parts.append(f"{days} days") + if hours > 0: + parts.append(f"{hours} hours") + if minutes > 0: + parts.append(f"{minutes} mins") + if seconds > 0: + parts.append(f"{seconds} secs") + + return " ".join(parts) + + +def parse_timedelta(text: str) -> timedelta: + """Parses a timedelta string formatted as 30h 45m to a timedelta object. + + Args: + text (str): The formatted string. + + Raises: + ValueError: The string is invalid. + + Returns: + datetime.timedelta: The timedelta object. + """ + + pattern = re.compile( + r"(?:(\d+)y)?\s*" # years + r"(?:(\d+)mo)?\s*" # months + r"(?:(\d+)w)?\s*" # weeks + r"(?:(\d+)d)?\s*" # days + r"(?:(\d+)h)?\s*" # hours + r"(?:(\d+)m)?\s*" # minutes + r"(?:(\d+)s)?" # seconds + ) + matches = pattern.match(text.strip()) + + if not matches: + raise ValueError("Invalid time string format") + + years, months, weeks, days, hours, minutes, seconds = matches.groups() + + years = int(years) if years else 0 + months = int(months) if months else 0 + weeks = int(weeks) if weeks else 0 + days = int(days) if days else 0 + hours = int(hours) if hours else 0 + minutes = int(minutes) if minutes else 0 + seconds = int(seconds) if seconds else 0 + + days += weeks * 7 + days += months * 30 + days += years * 365 + + return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) diff --git a/payphone/core/help_command.py b/payphone/core/help_command.py index 7f7eced..f7bd20b 100644 --- a/payphone/core/help_command.py +++ b/payphone/core/help_command.py @@ -4,7 +4,36 @@ import discord from discord.ext import commands from payphone.cogs import base -from payphone.core import formatting, constants, typing_utils +from payphone.core import formatting, config, constants, typing_utils + + +class HelpView(discord.ui.View): + """The view sent with the help command.""" + + def __init__(self): + super().__init__( + discord.ui.Button( + label="Credits", + style=discord.ButtonStyle.gray, + url="https://payphone.nyeki.dev", + ), + discord.ui.Button( + label="Donate", + style=discord.ButtonStyle.gray, + url="https://ko-fi.com/payphonedev", + ), + discord.ui.Button( + label="Vote", + style=discord.ButtonStyle.gray, + url="https://top.gg/bot/1212940413795827763/vote", + ), + discord.ui.Button( + label="Server", + style=discord.ButtonStyle.gray, + url=config.SERVER_INVITE, + ), + timeout=None, + ) class HelpCommand(commands.HelpCommand): @@ -41,7 +70,7 @@ class HelpCommand(commands.HelpCommand): ), ) - await self.context.send(embed=embed) + await self.context.send(embed=embed, view=HelpView()) async def send_command_help(self, command: commands.Command) -> None: command_name = ( diff --git a/payphone/db/models.py b/payphone/db/models.py index 9d2991c..ba0396f 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -27,12 +27,14 @@ class Call(models.Model): channels: fields.BackwardFKRelation["Channel"] call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] report: fields.BackwardOneToOneRelation["Report"] + messages: fields.BackwardFKRelation["Message"] class Profile(models.Model): user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( "models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE ) + user_id: int server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField( "models.Server", related_name="profiles", @@ -45,6 +47,26 @@ class Profile(models.Model): call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] + @property + def jump_url(self) -> str: + """The URL that'll open the user in Discord. + + Returns: + str: The URL. + """ + + return f"discord://-/users/{self.user_id}" + + @property + def link(self) -> str: + """A markdown link that opens the user in Discord. + + Returns: + str: The markdown link. + """ + + return f"[{self.name}]({self.jump_url})" + class Meta: unique_together = [("user_id", "server_id")] @@ -172,7 +194,8 @@ class Message(models.Model): "models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE ) user = fields.ForeignKeyRelation("models.User", on_delete=fields.OnDelete.CASCADE) - profile = fields.ForeignKeyField( + user_id: int + profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField( "models.Profile", on_delete=fields.OnDelete.CASCADE ) replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField( @@ -243,6 +266,25 @@ class Message(models.Model): return f"<:{mask_name}:{self.mask}>" + def jump_url(self, is_this_channel: bool = True) -> str | None: + """Returns the jump URL for this message. + + Args: + is_this_channel (bool, optional): Whether the message sent in this channel or not. + Defaults to True. + + Returns: + str | None: The URL, or None if the message was not propagated. + """ + + if not is_this_channel: + return f"https://discord.com/channels/{self.channel.server_id}/{self.channel.id}/{self.id}" + + if self.propagation_id is not None: + return f"https://discord.com/channels/{self.propagation_channel.server_id}/{self.propagation_channel.id}/{self.propagation_id}" + + return None + class MessageContent(models.Model): message = fields.ForeignKeyField( @@ -269,7 +311,7 @@ class Report(models.Model): reason: str = fields.CharField(max_length=500) priority: int = fields.IntField() status: ReportStatus = fields.IntEnumField( - ReportStatus, default=ReportStatus.ON_REVIEW + ReportStatus, default=ReportStatus.ON_REVIEW, db_index=True ) created_at: datetime = fields.DatetimeField(auto_now_add=True) diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..f4bfc38 --- /dev/null +++ b/run.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Retry migrations until success. +until uv run --no-dev aerich upgrade +do + echo "Retrying migrations..." +done + +uv run --no-dev run.py