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"
|
name: "payphone"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- IS_DEBUG=false
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: postgres:16.3
|
image: postgres:16.3
|
||||||
environment:
|
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.
|
# TODO: Make it an AutoShardedBot on production.
|
||||||
class Payphone(commands.AutoShardedBot):
|
class Payphone(commands.AutoShardedBot):
|
||||||
redis: aredis.Redis
|
redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
@@ -41,7 +41,6 @@ class Payphone(commands.AutoShardedBot):
|
|||||||
|
|
||||||
await setup.init()
|
await setup.init()
|
||||||
|
|
||||||
self.redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis")
|
|
||||||
await self.redis.ping()
|
await self.redis.ping()
|
||||||
|
|
||||||
await self.redis.delete(
|
await self.redis.delete(
|
||||||
@@ -130,12 +129,12 @@ class Payphone(commands.AutoShardedBot):
|
|||||||
return bool(int(is_banned))
|
return bool(int(is_banned))
|
||||||
|
|
||||||
is_banned = await models.Ban.filter(
|
is_banned = await models.Ban.filter(
|
||||||
expressions.Q(user_id=user_id)
|
expressions.Q(user_id=user_id),
|
||||||
& expressions.Q(
|
expressions.Q(
|
||||||
expires_at=None,
|
expires_at=None,
|
||||||
expires_at__lt=datetime.now(timezone.utc),
|
expires_at__gt=datetime.now(timezone.utc),
|
||||||
join_type="OR",
|
join_type="OR",
|
||||||
)
|
),
|
||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
await self.redis.set(key, int(is_banned), ex=300)
|
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)
|
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.
|
"""Returns the prefix for the message.
|
||||||
|
|
||||||
The prefix is case-insensitive, meaning that it'll adapt to the message to be valid not
|
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
|
depending of the case. In case that the prefix is invalid, the default or set prefix in its
|
||||||
original case will be returned.
|
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:
|
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:
|
Returns:
|
||||||
str: The prefix for the message.
|
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."]
|
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
|
key = constants.PREFIX_TEMPLATE % server_id
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from tortoise import transactions
|
from tortoise import transactions
|
||||||
|
|
||||||
from payphone import bot
|
from payphone import bot
|
||||||
|
from payphone.db import models, utils
|
||||||
from payphone.cogs import base
|
from payphone.cogs import base
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ class Admin(base.Cog):
|
|||||||
|
|
||||||
Settings:
|
Settings:
|
||||||
- `prefix`: Set a custom prefix.
|
- `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.
|
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}`!")
|
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:
|
def setup(b: bot.Payphone) -> None:
|
||||||
"""Load the cog into the bot.
|
"""Load the cog into the bot.
|
||||||
|
|||||||
+84
-21
@@ -1,5 +1,7 @@
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
@@ -14,7 +16,8 @@ import aiohttp
|
|||||||
from payphone import bot
|
from payphone import bot
|
||||||
from payphone.db import models, utils
|
from payphone.db import models, utils
|
||||||
from payphone.cogs import base
|
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):
|
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)
|
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.command(aliases=["c"])
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@checks.is_unbanned()
|
@checks.is_unbanned()
|
||||||
@@ -537,6 +548,7 @@ class Calls(base.Cog):
|
|||||||
|
|
||||||
if call.ended_at is None:
|
if call.ended_at is None:
|
||||||
call.ended_at = datetime.now(timezone.utc)
|
call.ended_at = datetime.now(timezone.utc)
|
||||||
|
await call.save()
|
||||||
|
|
||||||
updated_channels = []
|
updated_channels = []
|
||||||
|
|
||||||
@@ -583,7 +595,7 @@ class Calls(base.Cog):
|
|||||||
channel_id = ctx.channel.id
|
channel_id = ctx.channel.id
|
||||||
|
|
||||||
call = await models.Call.get(channels__id=channel_id).prefetch_related(
|
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)
|
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)
|
user, profile = await utils.get_user(message.author)
|
||||||
|
|
||||||
used_masks = await models.CallUserChannelRelationship.filter(
|
call_rss = await models.CallUserChannelRelationship.filter(
|
||||||
call=call, channel=channel
|
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(
|
call_rs, _ = await models.CallUserChannelRelationship.get_or_create(
|
||||||
{
|
{
|
||||||
@@ -660,26 +673,48 @@ class Calls(base.Cog):
|
|||||||
channel=channel,
|
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:
|
async with aiohttp.ClientSession() as session:
|
||||||
webhook = other_channel.webhook(session)
|
webhook = other_channel.webhook(session)
|
||||||
|
|
||||||
mask = constants.Mask(call_rs.mask)
|
embed = None
|
||||||
|
|
||||||
username: str
|
if message.reference:
|
||||||
avatar_url: str
|
embed = await embeds.ReplyEmbed(message.reference, call_id=call.id)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
webhook_message = await webhook.send(
|
webhook_message = await webhook.send(
|
||||||
message.content,
|
cleaned_content,
|
||||||
username=username,
|
username=call_rs.display_name,
|
||||||
avatar_url=avatar_url,
|
avatar_url=call_rs.display_avatar,
|
||||||
|
embed=embed,
|
||||||
wait=True,
|
wait=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -690,13 +725,14 @@ class Calls(base.Cog):
|
|||||||
user=user,
|
user=user,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
mask=call_rs.mask if call_rs.is_masked else None,
|
mask=call_rs.mask if call_rs.is_masked else None,
|
||||||
propagation_id=webhook_message.id,
|
propagation_id=webhook_message.id if webhook_message else None,
|
||||||
propagation_channel=other_channel,
|
propagation_channel=other_channel if webhook_message else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
await models.MessageContent.create(
|
await models.MessageContent.create(
|
||||||
message=message_row,
|
message=message_row,
|
||||||
content=message.content,
|
content=cleaned_content,
|
||||||
|
was_propagated=not bool(blocked_word),
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
@@ -736,8 +772,25 @@ class Calls(base.Cog):
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
webhook = other_channel.webhook(session)
|
webhook = other_channel.webhook(session)
|
||||||
|
|
||||||
|
try:
|
||||||
await webhook.delete_message(message.propagation_id)
|
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.Cog.listener()
|
||||||
@commands.guild_only()
|
@commands.guild_only()
|
||||||
@transactions.atomic()
|
@transactions.atomic()
|
||||||
@@ -761,6 +814,9 @@ class Calls(base.Cog):
|
|||||||
channel_id = payload.channel_id
|
channel_id = payload.channel_id
|
||||||
content = payload.data["content"]
|
content = payload.data["content"]
|
||||||
|
|
||||||
|
if censorship.moderate(content) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
is_on_call = await self.bot.redis.sismember(
|
is_on_call = await self.bot.redis.sismember(
|
||||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||||
)
|
)
|
||||||
@@ -783,8 +839,15 @@ class Calls(base.Cog):
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
webhook = other_channel.webhook(session)
|
webhook = other_channel.webhook(session)
|
||||||
|
|
||||||
|
try:
|
||||||
await webhook.edit_message(message.propagation_id, content=content)
|
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)
|
await models.MessageContent.create(message=message, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,64 @@
|
|||||||
import discord
|
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.
|
"""Generates the embed sent on calls to represent replies.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
reference (discord.MessageReference): The message reference.
|
reference (discord.MessageReference): The message reference.
|
||||||
|
call_id (int): The ID of the call the message was sent in.
|
||||||
|
|
||||||
Returns:
|
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
|
import discord
|
||||||
from discord import errors
|
from discord import errors
|
||||||
@@ -169,6 +172,32 @@ class Mods(base.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@commands.command()
|
@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()
|
@commands.is_owner()
|
||||||
@transactions.atomic()
|
@transactions.atomic()
|
||||||
async def moderator(
|
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"}`."
|
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:
|
def setup(b: bot.Payphone) -> None:
|
||||||
"""Load the cog into the bot.
|
"""Load the cog into the bot.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import discord
|
import discord
|
||||||
|
from discord.utils import find
|
||||||
|
|
||||||
from payphone import bot
|
from payphone import bot
|
||||||
from payphone.db import models
|
from payphone.db import models
|
||||||
from payphone.core import constants
|
from payphone.core import constants, formatting
|
||||||
|
|
||||||
|
|
||||||
class BanNotificationEmbed(discord.Embed):
|
class BanNotificationEmbed(discord.Embed):
|
||||||
@@ -44,3 +45,44 @@ class BanNotificationEmbed(discord.Embed):
|
|||||||
),
|
),
|
||||||
thumbnail=bot.user.display_avatar.url,
|
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
|
import discord
|
||||||
|
from discord import errors
|
||||||
|
|
||||||
from tortoise import transactions
|
from tortoise import transactions
|
||||||
|
|
||||||
from payphone import bot
|
from payphone import bot
|
||||||
from payphone.db import models
|
from payphone.db import models
|
||||||
|
from payphone.core import formatting
|
||||||
from payphone.cogs.mods import embeds, views
|
from payphone.cogs.mods import embeds, views
|
||||||
|
|
||||||
|
|
||||||
@@ -36,10 +40,88 @@ class BanNotificationModal(discord.ui.Modal):
|
|||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
|
|
||||||
ban.appeal = self.children[0].value
|
ban.appeal = self.children[0].value
|
||||||
ban.status = models.BanStatus.APPEALED
|
ban.status = models.BanStatus.ON_REVIEW
|
||||||
await ban.save()
|
await ban.save()
|
||||||
|
|
||||||
await interaction.message.edit(
|
await interaction.message.edit(
|
||||||
embed=embeds.BanNotificationEmbed(self.bot, ban),
|
embed=embeds.BanNotificationEmbed(self.bot, ban),
|
||||||
view=views.BanNotificationView(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
|
import discord
|
||||||
|
from discord.ext import pages
|
||||||
|
from discord.utils import find
|
||||||
|
|
||||||
|
from tortoise import transactions
|
||||||
|
|
||||||
from payphone import bot
|
from payphone import bot
|
||||||
from payphone.db import models
|
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):
|
class BanNotificationView(discord.ui.View):
|
||||||
@@ -19,7 +24,7 @@ class BanNotificationView(discord.ui.View):
|
|||||||
ban (models.Ban): The ban to appeal.
|
ban (models.Ban): The ban to appeal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
super().__init__()
|
super().__init__(timeout=None)
|
||||||
|
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@@ -45,3 +50,179 @@ class BanNotificationView(discord.ui.View):
|
|||||||
"""Sends the ban appeal modal."""
|
"""Sends the ban appeal modal."""
|
||||||
|
|
||||||
await interaction.response.send_modal(modals.BanNotificationModal(self.bot))
|
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 = (
|
call_rss = (
|
||||||
await models.CallUserChannelRelationship.filter(call_id__in=call_ids)
|
await models.CallUserChannelRelationship.filter(call_id__in=call_ids)
|
||||||
.select_related("profile")
|
.select_related("profile")
|
||||||
|
.order_by("-id")
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
calls_rss_map = {}
|
calls_rss_map = {}
|
||||||
|
|||||||
+37
-215
@@ -1,36 +1,6 @@
|
|||||||
# TODO: Rewrite the whole file.
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import re
|
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:
|
def create_pattern(text: str) -> str:
|
||||||
"""Creates regex patterns for words or phrases with possible variations
|
"""Creates regex patterns for words or phrases with possible variations
|
||||||
|
|
||||||
@@ -59,12 +29,12 @@ def create_pattern(text: str) -> str:
|
|||||||
for r in replacements:
|
for r in replacements:
|
||||||
text = text.replace(r[0], f"({r[1]})")
|
text = text.replace(r[0], f"({r[1]})")
|
||||||
|
|
||||||
return f"^{text}$"
|
return f"({text})"
|
||||||
|
|
||||||
|
|
||||||
blocked_words = {
|
BLOCKED_WORDS = [
|
||||||
"nsfw": {
|
create_pattern(word)
|
||||||
(
|
for word in [
|
||||||
"anal",
|
"anal",
|
||||||
"bdsm",
|
"bdsm",
|
||||||
"boner",
|
"boner",
|
||||||
@@ -177,9 +147,10 @@ blocked_words = {
|
|||||||
"taint",
|
"taint",
|
||||||
"teabagging",
|
"teabagging",
|
||||||
"tit",
|
"tit",
|
||||||
"tittyfuck",
|
"titfuck",
|
||||||
"tosser",
|
"tityfuck",
|
||||||
"tranny",
|
"toser",
|
||||||
|
"trany",
|
||||||
"twat",
|
"twat",
|
||||||
"wanking",
|
"wanking",
|
||||||
"pornographic",
|
"pornographic",
|
||||||
@@ -187,29 +158,17 @@ blocked_words = {
|
|||||||
"explicit content",
|
"explicit content",
|
||||||
"adult movie",
|
"adult movie",
|
||||||
"naked",
|
"naked",
|
||||||
): {
|
"kms",
|
||||||
"level": ViolationLevel.BLOCK,
|
"kys",
|
||||||
"message": "Keep NSFW conversations in age restricted channels!",
|
"kill myself",
|
||||||
}
|
"kill yourself",
|
||||||
},
|
"kill your self",
|
||||||
"suicide": {
|
"end my life",
|
||||||
("kms", "kill myself", "end my life"): {
|
"end your life",
|
||||||
"level": ViolationLevel.WARN,
|
"unlive",
|
||||||
"message": "Hey! You're not alone. Need someone to talk to? [Click here to find a free, confidential helpline](<https://findahelpline.com/>).",
|
"fagot",
|
||||||
},
|
"fag",
|
||||||
("kys", "kill yourself", "kill your self"): {
|
"dyke",
|
||||||
"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",
|
"nigger",
|
||||||
"nigga",
|
"nigga",
|
||||||
"chink",
|
"chink",
|
||||||
@@ -219,13 +178,6 @@ blocked_words = {
|
|||||||
"cracker",
|
"cracker",
|
||||||
"honky",
|
"honky",
|
||||||
"kike",
|
"kike",
|
||||||
): {
|
|
||||||
"level": ViolationLevel.BLOCK,
|
|
||||||
"message": "Racist language is not tolerated.",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"drug_related": {
|
|
||||||
(
|
|
||||||
"weed",
|
"weed",
|
||||||
"cocaine",
|
"cocaine",
|
||||||
"heroin",
|
"heroin",
|
||||||
@@ -235,13 +187,6 @@ blocked_words = {
|
|||||||
"marijuana",
|
"marijuana",
|
||||||
"opioid",
|
"opioid",
|
||||||
"crack",
|
"crack",
|
||||||
): {
|
|
||||||
"level": ViolationLevel.BLOCK,
|
|
||||||
"message": "Drug-related content is not allowed. Please refrain from discussing illegal substances.",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"self_harm": {
|
|
||||||
(
|
|
||||||
"self-harm",
|
"self-harm",
|
||||||
"self harm",
|
"self harm",
|
||||||
"self-injury",
|
"self-injury",
|
||||||
@@ -249,13 +194,6 @@ blocked_words = {
|
|||||||
"self-mutilation",
|
"self-mutilation",
|
||||||
"self mutilation",
|
"self mutilation",
|
||||||
"hurting myself",
|
"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",
|
"abortion",
|
||||||
"rape",
|
"rape",
|
||||||
"molest",
|
"molest",
|
||||||
@@ -267,13 +205,6 @@ blocked_words = {
|
|||||||
"child abuse",
|
"child abuse",
|
||||||
"neglect",
|
"neglect",
|
||||||
"victim",
|
"victim",
|
||||||
): {
|
|
||||||
"level": ViolationLevel.WARN,
|
|
||||||
"message": "Sensitive topics are not suitable for this bot. Please discuss these issues with appropriate professionals.",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"religious_slurs": {
|
|
||||||
(
|
|
||||||
"heathen",
|
"heathen",
|
||||||
"infidel",
|
"infidel",
|
||||||
"heretic",
|
"heretic",
|
||||||
@@ -284,13 +215,6 @@ blocked_words = {
|
|||||||
"fanatic",
|
"fanatic",
|
||||||
"apostate",
|
"apostate",
|
||||||
"idolater",
|
"idolater",
|
||||||
): {
|
|
||||||
"level": ViolationLevel.BLOCK,
|
|
||||||
"message": "Religious slurs are not allowed.",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"body_shaming": {
|
|
||||||
(
|
|
||||||
"fatty",
|
"fatty",
|
||||||
"lardass",
|
"lardass",
|
||||||
"twig",
|
"twig",
|
||||||
@@ -301,139 +225,37 @@ blocked_words = {
|
|||||||
"chunk",
|
"chunk",
|
||||||
"blubber",
|
"blubber",
|
||||||
"skinny",
|
"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:
|
PATTERN = re.compile("|".join(BLOCKED_WORDS), re.IGNORECASE)
|
||||||
if url.startswith(prefix):
|
URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
|
||||||
return True
|
INVITES_PATTERN = re.compile(r"discord.gg/[^\s]+", re.IGNORECASE)
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
url_pattern = re.compile(r"https?://[^\s]+")
|
def moderate(message: str) -> str:
|
||||||
invites_pattern = re.compile(r"discord.gg/[^\s]+")
|
"""Returns the first censored word or pattern found.
|
||||||
|
|
||||||
|
|
||||||
def moderate(
|
|
||||||
message: str, allowed_categories: list[str] = []
|
|
||||||
) -> tuple[bool, list[Violation]]:
|
|
||||||
"""Checks for non allowed patterns and returns them.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message (str): The raw message.
|
message (str): The message to moderate.
|
||||||
allowed_categories (List[str], optional): Categories allowed to be skipped. Defaults to [].
|
|
||||||
|
|
||||||
Returns:
|
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 = []
|
match = PATTERN.match(message)
|
||||||
msg_words = get_words(message)
|
|
||||||
|
|
||||||
for category, words_info in blocked_words.items():
|
if match is not None:
|
||||||
if category in allowed_categories:
|
return match.string
|
||||||
continue
|
|
||||||
|
|
||||||
for words, info in words_info.items():
|
match = URL_PATTERN.match(message)
|
||||||
for word, pattern in words:
|
|
||||||
found = False
|
|
||||||
word_count = len(word.split())
|
|
||||||
|
|
||||||
for msg_word in map(
|
if match is not None:
|
||||||
lambda i: " ".join(msg_words[i : i + word_count]),
|
return match.string
|
||||||
range(len(msg_words)),
|
|
||||||
):
|
|
||||||
m = pattern.search(msg_word.lower()) # pylint: disable=no-member
|
|
||||||
|
|
||||||
if m is not None:
|
match = INVITES_PATTERN.match(message)
|
||||||
found_violations.append(
|
|
||||||
Violation(
|
|
||||||
word=msg_word,
|
|
||||||
pattern=pattern,
|
|
||||||
message=info["message"],
|
|
||||||
level=info["level"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if found:
|
if match is not None:
|
||||||
continue
|
return message[match.start]
|
||||||
|
|
||||||
urls = [url for url in re.findall(url_pattern, message) if not allowed_gif_url(url)]
|
return None
|
||||||
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
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ IS_DEBUG = os.environ.get("IS_DEBUG", "false") == "true"
|
|||||||
|
|
||||||
TORTOISE_ORM = {
|
TORTOISE_ORM = {
|
||||||
"connections": {
|
"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": {
|
"apps": {
|
||||||
"models": {
|
"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:
|
def trim(text: str, length: int = 100) -> str:
|
||||||
"""Trims a string if it's too long.
|
"""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:
|
def monospace_grid(values: list[str], space: int = 14) -> str:
|
||||||
"""Creates a monospace grid
|
"""Creates a monospace grid.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
values (list[str]): All the values that the list must contain
|
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]) + "```"
|
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 discord.ext import commands
|
||||||
|
|
||||||
from payphone.cogs import base
|
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):
|
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:
|
async def send_command_help(self, command: commands.Command) -> None:
|
||||||
command_name = (
|
command_name = (
|
||||||
|
|||||||
+44
-2
@@ -27,12 +27,14 @@ class Call(models.Model):
|
|||||||
channels: fields.BackwardFKRelation["Channel"]
|
channels: fields.BackwardFKRelation["Channel"]
|
||||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
||||||
report: fields.BackwardOneToOneRelation["Report"]
|
report: fields.BackwardOneToOneRelation["Report"]
|
||||||
|
messages: fields.BackwardFKRelation["Message"]
|
||||||
|
|
||||||
|
|
||||||
class Profile(models.Model):
|
class Profile(models.Model):
|
||||||
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
||||||
"models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE
|
"models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE
|
||||||
)
|
)
|
||||||
|
user_id: int
|
||||||
server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField(
|
server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField(
|
||||||
"models.Server",
|
"models.Server",
|
||||||
related_name="profiles",
|
related_name="profiles",
|
||||||
@@ -45,6 +47,26 @@ class Profile(models.Model):
|
|||||||
|
|
||||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
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:
|
class Meta:
|
||||||
unique_together = [("user_id", "server_id")]
|
unique_together = [("user_id", "server_id")]
|
||||||
|
|
||||||
@@ -172,7 +194,8 @@ class Message(models.Model):
|
|||||||
"models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE
|
"models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE
|
||||||
)
|
)
|
||||||
user = fields.ForeignKeyRelation("models.User", 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
|
"models.Profile", on_delete=fields.OnDelete.CASCADE
|
||||||
)
|
)
|
||||||
replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField(
|
replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField(
|
||||||
@@ -243,6 +266,25 @@ class Message(models.Model):
|
|||||||
|
|
||||||
return f"<:{mask_name}:{self.mask}>"
|
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):
|
class MessageContent(models.Model):
|
||||||
message = fields.ForeignKeyField(
|
message = fields.ForeignKeyField(
|
||||||
@@ -269,7 +311,7 @@ class Report(models.Model):
|
|||||||
reason: str = fields.CharField(max_length=500)
|
reason: str = fields.CharField(max_length=500)
|
||||||
priority: int = fields.IntField()
|
priority: int = fields.IntField()
|
||||||
status: ReportStatus = fields.IntEnumField(
|
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)
|
created_at: datetime = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user