feat(bot):

This commit is contained in:
Nyeki.py
2024-10-17 01:11:37 -03:00
parent 5011ab5f90
commit c745df0a9d
19 changed files with 1273 additions and 439 deletions
+162
View File
@@ -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/
+10
View File
@@ -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" ]
+7
View File
@@ -1,6 +1,13 @@
name: "payphone"
services:
bot:
build: .
env_file:
- .env
environment:
- IS_DEBUG=false
database:
image: postgres:16.3
environment:
+28
View File
@@ -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'."
+26 -10
View File
@@ -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
+69
View File
@@ -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 <setting>` 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:
- `[<channel>]`: 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:
- `[<channel>]`: 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.
+84 -21
View File
@@ -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,26 +673,48 @@ class Calls(base.Cog):
channel=channel,
)
# Since .select_related() can't be used and profile was already fetched, we need to
# manually set it.
call_rs.profile = profile
blocked_word = censorship.moderate(message.content)
webhook_message = None
cleaned_content = formatting.clean(message, call_rss)
if blocked_word:
try:
await self.bot.get_partial_messageable(channel_id).send(
f"Your message wasn't delivered!\nBlocked word: `{blocked_word}`."
)
except errors.DiscordException:
pass
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)
mask = constants.Mask(call_rs.mask)
embed = None
username: str
avatar_url: str
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"
else:
username = profile.name
avatar_url = profile.avatar_url
if message.reference:
embed = await embeds.ReplyEmbed(message.reference, call_id=call.id)
webhook_message = await webhook.send(
message.content,
username=username,
avatar_url=avatar_url,
cleaned_content,
username=call_rs.display_name,
avatar_url=call_rs.display_avatar,
embed=embed,
wait=True,
)
@@ -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,8 +772,25 @@ class Calls(base.Cog):
async with aiohttp.ClientSession() as session:
webhook = other_channel.webhook(session)
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()
@transactions.atomic()
@@ -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,8 +839,15 @@ class Calls(base.Cog):
async with aiohttp.ClientSession() as session:
webhook = other_channel.webhook(session)
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)
+54 -2
View File
@@ -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
+97 -1
View File
@@ -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:
- `[<seconds>]`: The amount of seconds to wait before shutdown.
- `[<reason>]`: 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 <t:{int((datetime.now(timezone.utc) + timedelta(seconds=seconds)).timestamp())}:R> 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 <t:{int((datetime.now(timezone.utc) + timedelta(seconds=seconds)).timestamp())}:R>."
)
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.
+43 -1
View File
@@ -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 <t:{int(report.call.started_at.timestamp())}:F> to <t:{int(report.call.ended_at.timestamp())}:F>.",
),
],
author=bot.embed_author,
footer=discord.EmbedFooter(
text=f"Call ID: {report.call.id} | Report ID: {report.id}"
),
thumbnail=bot.user.display_avatar.url,
)
+83 -1
View File
@@ -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 <t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
message += "."
await interaction.channel.send(message)
+183 -2
View File
@@ -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),
)
+1
View File
@@ -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 = {}
+37 -215
View File
@@ -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,12 +29,12 @@ 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": {
(
BLOCKED_WORDS = [
create_pattern(word)
for word in [
"anal",
"bdsm",
"boner",
@@ -177,9 +147,10 @@ blocked_words = {
"taint",
"teabagging",
"tit",
"tittyfuck",
"tosser",
"tranny",
"titfuck",
"tityfuck",
"toser",
"trany",
"twat",
"wanking",
"pornographic",
@@ -187,29 +158,17 @@ blocked_words = {
"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](<https://findahelpline.com/>).",
},
("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": {
(
"kms",
"kys",
"kill myself",
"kill yourself",
"kill your self",
"end my life",
"end your life",
"unlive",
"fagot",
"fag",
"dyke",
"nigger",
"nigga",
"chink",
@@ -219,13 +178,6 @@ blocked_words = {
"cracker",
"honky",
"kike",
): {
"level": ViolationLevel.BLOCK,
"message": "Racist language is not tolerated.",
}
},
"drug_related": {
(
"weed",
"cocaine",
"heroin",
@@ -235,13 +187,6 @@ blocked_words = {
"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",
@@ -249,13 +194,6 @@ blocked_words = {
"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](<https://findahelpline.com/>).",
}
},
"sensitive_topics": {
(
"abortion",
"rape",
"molest",
@@ -267,13 +205,6 @@ blocked_words = {
"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",
@@ -284,13 +215,6 @@ blocked_words = {
"fanatic",
"apostate",
"idolater",
): {
"level": ViolationLevel.BLOCK,
"message": "Religious slurs are not allowed.",
}
},
"body_shaming": {
(
"fatty",
"lardass",
"twig",
@@ -301,139 +225,37 @@ blocked_words = {
"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/",
]
]
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
+1 -1
View File
@@ -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": {
+124 -1
View File
@@ -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)
+31 -2
View File
@@ -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 = (
+44 -2
View File
@@ -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)
+9
View File
@@ -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