feat(bot):
This commit is contained in:
+162
@@ -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
@@ -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" ]
|
||||
@@ -1,6 +1,13 @@
|
||||
name: "payphone"
|
||||
|
||||
services:
|
||||
bot:
|
||||
build: .
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IS_DEBUG=false
|
||||
|
||||
database:
|
||||
image: postgres:16.3
|
||||
environment:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+89
-26
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
+212
-390
@@ -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](<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": {
|
||||
(
|
||||
"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](<https://findahelpline.com/>).",
|
||||
}
|
||||
},
|
||||
"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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user