feat(bot): Everything.
This commit is contained in:
+370
-3
@@ -1,6 +1,373 @@
|
||||
from discord.ext import bridge
|
||||
from typing import Literal
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord import abc
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import errors
|
||||
|
||||
from tortoise import expressions, transactions
|
||||
|
||||
import redis.asyncio as aredis
|
||||
|
||||
from payphone.db import setup, models
|
||||
from payphone.core import config, constants, exceptions, formatting, help_command
|
||||
|
||||
|
||||
# TODO: Make it an AutoShardedBot on production.
|
||||
class Payphone(bridge.Bot):
|
||||
pass
|
||||
class Payphone(commands.AutoShardedBot):
|
||||
redis: aredis.Redis
|
||||
|
||||
def __init__(self) -> None:
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
|
||||
super().__init__(
|
||||
intents=intents,
|
||||
command_prefix=self.get_prefix,
|
||||
help_command=help_command.HelpCommand(),
|
||||
)
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
"""Initializes the bot.
|
||||
|
||||
Initializes the bot's DB and connects to Redis, and then prints a ready message to the
|
||||
console.
|
||||
"""
|
||||
|
||||
# Import from inside the function to prevent circular import errors.
|
||||
from payphone.cogs.mods import views as mod_views
|
||||
|
||||
await setup.init()
|
||||
|
||||
self.redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis")
|
||||
await self.redis.ping()
|
||||
|
||||
await self.redis.delete(
|
||||
constants.CHANNELS_ONCALL_SET, constants.CHANNELS_SEARCH_QUEUE
|
||||
)
|
||||
|
||||
self.add_view(mod_views.BanNotificationView(self, models.Ban()))
|
||||
|
||||
print(f"Ready! Logged in as {self.user.display_name}.")
|
||||
|
||||
async def on_shard_ready(self, shard_id: int) -> None:
|
||||
"""Sets the activity for the shard.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the initialized shard.
|
||||
"""
|
||||
|
||||
await self.change_presence(
|
||||
activity=discord.CustomActivity(
|
||||
name=f"Listening to p.help | Shard {shard_id + 1}"
|
||||
),
|
||||
status=discord.Status.online,
|
||||
shard_id=shard_id,
|
||||
)
|
||||
|
||||
print(f"Shard is ready! Shard ID: {shard_id}")
|
||||
|
||||
async def on_shard_resumed(self, shard_id: int) -> None:
|
||||
"""Sets the activity for the shard.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the resumed shard.
|
||||
"""
|
||||
|
||||
await self.change_presence(
|
||||
activity=discord.CustomActivity(
|
||||
name=f"Listening to p.help | Shard {shard_id + 1}"
|
||||
),
|
||||
status=discord.Status.online,
|
||||
shard_id=shard_id,
|
||||
)
|
||||
|
||||
print(f"Shard was resumed! Shard ID: {shard_id}")
|
||||
|
||||
async def get_or_fetch_channel(
|
||||
self, channel_id: int
|
||||
) -> abc.GuildChannel | abc.PrivateChannel | None:
|
||||
"""Get's a channel from the bot's cache, or fetches it from Discord if it's not available.
|
||||
|
||||
Args:
|
||||
channel_id (int): The channel's ID.
|
||||
|
||||
Returns:
|
||||
(abc.GuildChannel | abc.PrivateChannel | None): The channel, or None if not found.
|
||||
"""
|
||||
|
||||
channel = self.get_channel(channel_id)
|
||||
|
||||
if channel is None:
|
||||
return await self.fetch_channel(channel_id)
|
||||
|
||||
return channel
|
||||
|
||||
async def is_banned(
|
||||
self, user: discord.User | discord.Member | models.User | int
|
||||
) -> bool:
|
||||
"""Checks whether a user is banned.
|
||||
|
||||
Checks whether a user is banned, and caches the result in Redis. The cache expires in 5
|
||||
minutes.
|
||||
|
||||
Args:
|
||||
user (discord.User | discord.Member | models.User | int): The user to check or their
|
||||
ID.
|
||||
|
||||
Returns:
|
||||
bool: Whether the user is banned or not.
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id
|
||||
key = constants.BAN_TEMPLATE % user_id
|
||||
|
||||
is_banned = await self.redis.get(key)
|
||||
|
||||
if is_banned is not None:
|
||||
return bool(int(is_banned))
|
||||
|
||||
is_banned = await models.Ban.filter(
|
||||
expressions.Q(user_id=user_id)
|
||||
& expressions.Q(
|
||||
expires_at=None,
|
||||
expires_at__lt=datetime.now(timezone.utc),
|
||||
join_type="OR",
|
||||
)
|
||||
).exists()
|
||||
|
||||
await self.redis.set(key, int(is_banned), ex=300)
|
||||
|
||||
return is_banned
|
||||
|
||||
@transactions.atomic()
|
||||
async def ban(
|
||||
self,
|
||||
user: discord.User | discord.Member | models.User | int,
|
||||
until: datetime | None,
|
||||
reason: str,
|
||||
moderator: discord.User | discord.Member | models.User | int | None = None,
|
||||
) -> models.Ban:
|
||||
"""Bans a user from Payphone.
|
||||
|
||||
Args:
|
||||
user (discord.User | discord.Member | models.User | int): The user to ban or their ID.
|
||||
until (datetime | None): The expiry date, or None if it does not expire.
|
||||
reason (str): The reason of the ban.
|
||||
moderator (discord.User | discord.Member | models.User | int | None, optional): The
|
||||
moderator who performed the action.
|
||||
|
||||
Returns:
|
||||
models.Ban: The newly created ban.
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id
|
||||
key = constants.BAN_TEMPLATE % user_id
|
||||
|
||||
mod_id = (
|
||||
moderator.id
|
||||
if isinstance(moderator, (discord.User, discord.Member, models.User))
|
||||
else moderator
|
||||
)
|
||||
|
||||
row = await models.Ban.create(
|
||||
user_id=user_id,
|
||||
reason=reason,
|
||||
expires_at=until,
|
||||
banned_by_id=mod_id,
|
||||
)
|
||||
|
||||
await self.redis.set(key, "1", ex=300)
|
||||
|
||||
return row
|
||||
|
||||
@transactions.atomic()
|
||||
async def unban(
|
||||
self, user: discord.User | discord.Member | models.User | int
|
||||
) -> None:
|
||||
"""Unbans a user from Payphone.
|
||||
|
||||
Args:
|
||||
user (discord.User | discord.Member | models.User | int): The user to unban or their
|
||||
ID.
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id
|
||||
key = constants.BAN_TEMPLATE % user_id
|
||||
|
||||
await models.Ban.filter(
|
||||
expressions.Q(
|
||||
expires_at=None,
|
||||
expires_at__gt=datetime.now(timezone.utc),
|
||||
join_type="OR",
|
||||
),
|
||||
user_id=user_id,
|
||||
).update(expires_at=datetime.now(timezone.utc))
|
||||
|
||||
await self.redis.set(key, "0", ex=300)
|
||||
|
||||
async def get_prefix(self, message: discord.Message) -> 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.
|
||||
|
||||
Args:
|
||||
message (discord.Message): The message to check the prefix for.
|
||||
|
||||
Returns:
|
||||
str: The prefix for the message.
|
||||
"""
|
||||
|
||||
if message.guild is None:
|
||||
return ["p.", "P."]
|
||||
|
||||
server_id = message.guild.id
|
||||
|
||||
key = constants.PREFIX_TEMPLATE % server_id
|
||||
|
||||
prefix: bytes | None = await self.redis.get(key)
|
||||
|
||||
if prefix is not None:
|
||||
prefix = prefix.decode()
|
||||
|
||||
else:
|
||||
server, _ = await models.Server.get_or_create(id=server_id)
|
||||
|
||||
await self.redis.set(key, server.prefix, ex=60 * 60)
|
||||
|
||||
prefix = server.prefix
|
||||
|
||||
if message.content.lower().startswith(prefix.lower()):
|
||||
return message.content[: len(prefix)]
|
||||
|
||||
return prefix
|
||||
|
||||
async def set_prefix(
|
||||
self, server: discord.Guild | models.Server | int, prefix: str = "p."
|
||||
) -> None:
|
||||
"""Update a server's prefix.
|
||||
|
||||
Args:
|
||||
server (discord.Guild | models.Server | int): The server to update or it's ID.
|
||||
prefix (str, optional): The new prefix. It cannot be longer than 3 characters. Defaults
|
||||
to "p.".
|
||||
|
||||
Raises:
|
||||
ValueError: The prefix provided is invalid.
|
||||
"""
|
||||
|
||||
if len(prefix) > 3 or len(prefix) == 0:
|
||||
raise ValueError(
|
||||
"The prefix must not be longer than 3 characters and must not be an empty string."
|
||||
)
|
||||
|
||||
server_id = server if isinstance(server, int) else server.id
|
||||
|
||||
await models.Server.filter(id=server_id).update(prefix=prefix)
|
||||
|
||||
await self.redis.set(constants.PREFIX_TEMPLATE % server_id, prefix, ex=60 * 60)
|
||||
|
||||
async def on_command_error(
|
||||
self, context: commands.Context, exception: commands.CommandError
|
||||
) -> None:
|
||||
"""Handles each of the errors raised by commands.
|
||||
|
||||
Args:
|
||||
context (commands.Context): The context of the command.
|
||||
exception (commands.CommandError): The exception raised.
|
||||
"""
|
||||
|
||||
if isinstance(exception, exceptions.ChannelDisabledError):
|
||||
await context.send(
|
||||
"❌ You cannot run commands in this channel.", delete_after=20.0
|
||||
)
|
||||
|
||||
elif isinstance(exception, exceptions.BannedError):
|
||||
await context.send("❌ You have been banned from Payphone.")
|
||||
|
||||
elif isinstance(exception, exceptions.NotOnCallError):
|
||||
await context.send("❌ You need to be on a call to run this command.")
|
||||
|
||||
elif isinstance(exception, exceptions.NotAModeratorError):
|
||||
await context.send("❌ You're not a bot moderator.")
|
||||
|
||||
elif isinstance(exception, errors.MissingRequiredArgument):
|
||||
await context.send(
|
||||
f"❌ `{exception.param.name}` is missing. Use `{context.clean_prefix}{context.command.name}` to learn how to use it."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.BotMissingPermissions):
|
||||
await context.send(
|
||||
f"❌ Payphone's missing {formatting.text_join([f"`{perm.replace("_", " ").title()}`" for perm in exception.missing_permissions])} permissions to run this command."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.NoPrivateMessage):
|
||||
await context.send("❌ This command can only be run in a server.")
|
||||
|
||||
elif isinstance(exception, errors.MissingPermissions):
|
||||
await context.send("❌ You don't have permission to run this command.")
|
||||
|
||||
elif isinstance(exception, errors.CommandOnCooldown):
|
||||
await context.send(
|
||||
f"❌ You're going too fast! Wait `{int(exception.retry_after)}` seconds before trying again."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.CommandNotFound):
|
||||
return
|
||||
|
||||
else:
|
||||
return await super().on_command_error(context, exception)
|
||||
|
||||
@property
|
||||
def embed_author(self) -> discord.EmbedAuthor:
|
||||
"""The embed author for embeds sent by Payphone.
|
||||
|
||||
Returns:
|
||||
discord.EmbedAuthor: The embed author object.
|
||||
"""
|
||||
|
||||
return discord.EmbedAuthor(
|
||||
name=self.user.display_name, icon_url=self.user.display_avatar.url
|
||||
)
|
||||
|
||||
|
||||
bot = Payphone()
|
||||
|
||||
bot.load_extension("payphone.cogs.admin.cog")
|
||||
bot.load_extension("payphone.cogs.calls.cog")
|
||||
bot.load_extension("payphone.cogs.mods.cog")
|
||||
bot.load_extension("payphone.cogs.users.cog")
|
||||
bot.load_extension("payphone.cogs.utility.cog")
|
||||
|
||||
|
||||
@bot.check
|
||||
async def is_channel_enabled(ctx: commands.Context) -> Literal[True]:
|
||||
"""Checks whether the current channel is enabled to run a command or not.
|
||||
|
||||
Args:
|
||||
ctx (commands.Context): The current context.
|
||||
|
||||
Raises:
|
||||
exceptions.ChannelDisabledError: The current channel is disabled.
|
||||
"""
|
||||
|
||||
if ctx.guild is None:
|
||||
return True
|
||||
|
||||
server_id = ctx.guild.id
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
await models.Server.get_or_create(id=server_id)
|
||||
channel, _ = await models.Channel.get_or_create(id=channel_id, server_id=server_id)
|
||||
|
||||
if channel.enabled:
|
||||
return True
|
||||
|
||||
if await models.Channel.filter(server_id=server_id, enabled=True).exists():
|
||||
raise exceptions.ChannelDisabledError()
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from discord.ext import commands
|
||||
|
||||
from tortoise import transactions
|
||||
|
||||
from payphone import bot
|
||||
from payphone.cogs import base
|
||||
|
||||
|
||||
class Admin(base.Cog):
|
||||
def __init__(self, bot_instance: bot.Payphone):
|
||||
self.bot = bot_instance
|
||||
|
||||
@commands.group(invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
@commands.has_guild_permissions(manage_guild=True)
|
||||
async def config(
|
||||
self, ctx: commands.Context, setting: str = "", value: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Set up Payphone for the current server.
|
||||
|
||||
Settings:
|
||||
- `prefix`: Set a custom prefix.
|
||||
|
||||
You can use `{prefix}help config <setting>` to get help about a specific configuration.
|
||||
"""
|
||||
|
||||
await ctx.send_help("config")
|
||||
|
||||
@config.command(extras={"help_name": "Prefix"})
|
||||
@commands.guild_only()
|
||||
@commands.has_guild_permissions(manage_guild=True)
|
||||
@transactions.atomic()
|
||||
async def prefix(self, ctx: commands.Context, prefix: str = "p.") -> None:
|
||||
"""
|
||||
Set a custom bot prefix.
|
||||
|
||||
Options:
|
||||
- `<prefix>`: The new custom prefix.
|
||||
|
||||
Permissions:
|
||||
- Manage Server
|
||||
"""
|
||||
|
||||
try:
|
||||
await self.bot.set_prefix(ctx.guild, prefix)
|
||||
except ValueError:
|
||||
await ctx.send("❌ The prefix must not be longer than 3 characters.")
|
||||
return
|
||||
|
||||
await ctx.send(f"✅ The prefix was set to `{prefix}`!")
|
||||
|
||||
|
||||
def setup(b: bot.Payphone) -> None:
|
||||
"""Load the cog into the bot.
|
||||
|
||||
Args:
|
||||
b (bot.Payphone): The bot instance to load the cog into.
|
||||
"""
|
||||
|
||||
b.add_cog(Admin(b))
|
||||
@@ -0,0 +1,7 @@
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class Cog(commands.Cog):
|
||||
"""Base cog with Payphone-specific properties."""
|
||||
|
||||
show_in_help: bool = True
|
||||
@@ -0,0 +1,798 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import secrets
|
||||
|
||||
import discord
|
||||
from discord import errors
|
||||
from discord.ext import commands, tasks
|
||||
from discord.utils import find
|
||||
|
||||
from tortoise import transactions
|
||||
|
||||
import aiohttp
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models, utils
|
||||
from payphone.cogs import base
|
||||
from payphone.core import constants, checks
|
||||
|
||||
|
||||
class Calls(base.Cog):
|
||||
def __init__(self, bot_instance: bot.Payphone):
|
||||
self.bot = bot_instance
|
||||
|
||||
self.hangup_calls.start()
|
||||
|
||||
def cog_unload(self) -> None:
|
||||
self.hangup_calls.stop()
|
||||
|
||||
return super().cog_unload()
|
||||
|
||||
@tasks.loop(minutes=1)
|
||||
@transactions.atomic()
|
||||
async def hangup_calls(self) -> None:
|
||||
"""Hangs up inactive calls.
|
||||
|
||||
Hangs up inactive calls. Calls are hung up when they either:
|
||||
- Have had no messages and more than one minute has passed since the start of the call.
|
||||
- Have had no messages for the past 15 minutes.
|
||||
|
||||
In case they are hung up, both channels are attempted to be notified. In case of failure,
|
||||
that channel is not notified and hung up silently.
|
||||
"""
|
||||
|
||||
calls = await models.Call.filter(ended_at=None).prefetch_related("channels")
|
||||
|
||||
hung_up_channel_ids = []
|
||||
|
||||
for call in calls:
|
||||
message = (
|
||||
await models.Message.filter(call=call).order_by("-id").limit(1).first()
|
||||
)
|
||||
|
||||
if (
|
||||
message is None
|
||||
and call.started_at < datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
) or message.created_at < datetime.now(timezone.utc) - timedelta(
|
||||
minutes=15
|
||||
):
|
||||
for channel in call.channels:
|
||||
hung_up_channel_ids += channel.id
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(channel.id).send(
|
||||
"☎️ The call was hung up because nobody replied."
|
||||
)
|
||||
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, *hung_up_channel_ids)
|
||||
|
||||
@commands.command(aliases=["c"])
|
||||
@commands.guild_only()
|
||||
@checks.is_unbanned()
|
||||
@transactions.atomic()
|
||||
async def call(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Call and connect with other people.
|
||||
"""
|
||||
|
||||
if ctx.channel.is_nsfw():
|
||||
await ctx.send("❌ Calls cannot be started in NSFW channels.")
|
||||
return
|
||||
|
||||
user_id = ctx.author.id
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if is_on_call:
|
||||
await ctx.reply(
|
||||
f"☎️ You're already on a call! Use `{ctx.clean_prefix}hangup` to hangup."
|
||||
)
|
||||
return
|
||||
|
||||
is_searching = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
|
||||
)
|
||||
|
||||
if is_searching:
|
||||
await ctx.reply(
|
||||
f"☎️ You're already searching for a call! Use `{ctx.clean_prefix}hangup` to stop searching."
|
||||
)
|
||||
return
|
||||
|
||||
last_channel_calls = [
|
||||
i[1]
|
||||
for i in (
|
||||
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
|
||||
.limit(5)
|
||||
.distinct()
|
||||
.order_by("-id")
|
||||
.values_list("id", "call_id")
|
||||
)
|
||||
]
|
||||
last_user_calls = [
|
||||
i[1]
|
||||
for i in (
|
||||
await models.CallUserChannelRelationship.filter(user_id=user_id)
|
||||
.limit(5)
|
||||
.limit(5)
|
||||
.distinct()
|
||||
.order_by("-id")
|
||||
.values_list("id", "call_id")
|
||||
)
|
||||
]
|
||||
|
||||
last_calls = set(last_user_calls + last_channel_calls)
|
||||
|
||||
last_calls_rss = await models.CallUserChannelRelationship.filter(
|
||||
call_id__in=last_calls
|
||||
)
|
||||
last_calls_rss_channels_map = {}
|
||||
|
||||
for call_rs in last_calls_rss:
|
||||
if call_rs.user_id == user_id or call_rs.channel_id == channel_id:
|
||||
last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id
|
||||
|
||||
last_calls_other_channels_ids = set()
|
||||
|
||||
for call_rs in last_calls_rss:
|
||||
if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id:
|
||||
last_calls_other_channels_ids.add(call_rs.channel_id)
|
||||
|
||||
other_channel_id: int = await self.bot.redis.spop(
|
||||
constants.CHANNELS_SEARCH_QUEUE
|
||||
)
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
# For some weird reason, sometimes it returns None and sometimes b"None".
|
||||
if other_channel_id is None or other_channel_id == b"None":
|
||||
other_channel_id = None
|
||||
break
|
||||
|
||||
# Redis returns the ID as a bytes string.
|
||||
other_channel_id = int(other_channel_id)
|
||||
|
||||
if i == 5:
|
||||
break
|
||||
|
||||
i += 1
|
||||
|
||||
if other_channel_id in last_calls_other_channels_ids:
|
||||
other_channel_id = await self.bot.redis.spop(
|
||||
constants.CHANNELS_SEARCH_QUEUE
|
||||
)
|
||||
|
||||
if other_channel_id is None:
|
||||
break
|
||||
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id)
|
||||
)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
channel = await utils.get_channel(ctx.channel)
|
||||
|
||||
if other_channel_id is not None:
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id)
|
||||
)
|
||||
|
||||
msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}"
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(other_channel_id).send(
|
||||
msg_phone_answered
|
||||
)
|
||||
|
||||
call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw())
|
||||
|
||||
user, profile = await utils.get_user(ctx.author)
|
||||
|
||||
await models.CallUserChannelRelationship.create(
|
||||
call=call,
|
||||
channel_id=channel_id,
|
||||
user=user,
|
||||
profile=profile,
|
||||
is_masked=user.is_masked,
|
||||
mask=secrets.choice(constants.MASKS_LIST).value,
|
||||
)
|
||||
|
||||
await models.Channel.filter(
|
||||
id__in=[other_channel_id, channel.id]
|
||||
).update(call=call)
|
||||
|
||||
except errors.DiscordException:
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET,
|
||||
str(channel_id),
|
||||
str(other_channel_id),
|
||||
)
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
|
||||
)
|
||||
msg_phone_answered = ""
|
||||
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
|
||||
)
|
||||
+ msg_phone_answered
|
||||
)
|
||||
|
||||
else:
|
||||
await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
|
||||
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
|
||||
)
|
||||
)
|
||||
|
||||
@commands.command(aliases=["s"])
|
||||
@commands.guild_only()
|
||||
@checks.is_unbanned()
|
||||
@transactions.atomic()
|
||||
async def skip(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Hang up the current call and search for another.
|
||||
"""
|
||||
|
||||
if ctx.channel.is_nsfw():
|
||||
await ctx.send("❌ Calls cannot be started in NSFW channels.")
|
||||
return
|
||||
|
||||
user_id = ctx.author.id
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if not is_on_call:
|
||||
await ctx.send("❌ You're not in a call!")
|
||||
return
|
||||
|
||||
call = await models.Call.get(channels__id=channel_id).prefetch_related(
|
||||
"channels"
|
||||
)
|
||||
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
await call.save()
|
||||
|
||||
channel = find(lambda c: c.id == channel_id, call.channels)
|
||||
other_channel = find(lambda c: c.id != channel_id, call.channels)
|
||||
|
||||
other_channel.call = None
|
||||
await other_channel.save()
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(other_channel.id).send(
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
|
||||
)
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel.id)
|
||||
)
|
||||
|
||||
last_channel_calls = [
|
||||
i[1]
|
||||
for i in (
|
||||
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
|
||||
.limit(5)
|
||||
.distinct()
|
||||
.order_by("-id")
|
||||
.values_list("id", "call_id")
|
||||
)
|
||||
]
|
||||
last_user_calls = [
|
||||
i[1]
|
||||
for i in (
|
||||
await models.CallUserChannelRelationship.filter(user_id=user_id)
|
||||
.limit(5)
|
||||
.limit(5)
|
||||
.distinct()
|
||||
.order_by("-id")
|
||||
.values_list("id", "call_id")
|
||||
)
|
||||
]
|
||||
|
||||
last_calls = set(last_user_calls + last_channel_calls)
|
||||
|
||||
last_calls_rss = await models.CallUserChannelRelationship.filter(
|
||||
call_id__in=last_calls
|
||||
)
|
||||
last_calls_rss_channels_map = {}
|
||||
|
||||
for call_rs in last_calls_rss:
|
||||
if call_rs.user_id == user_id or call_rs.channel_id == channel_id:
|
||||
last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id
|
||||
|
||||
last_calls_other_channels_ids = set()
|
||||
|
||||
for call_rs in last_calls_rss:
|
||||
if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id:
|
||||
last_calls_other_channels_ids.add(call_rs.channel_id)
|
||||
|
||||
other_channel_id: int = await self.bot.redis.spop(
|
||||
constants.CHANNELS_SEARCH_QUEUE
|
||||
)
|
||||
i = 0
|
||||
|
||||
while True:
|
||||
# For some weird reason, sometimes it returns None and sometimes b"b'None'".
|
||||
if other_channel_id is None or other_channel_id == b"b'None'":
|
||||
other_channel_id = None
|
||||
break
|
||||
|
||||
# Redis returns the ID as a bytes string.
|
||||
other_channel_id = int(other_channel_id)
|
||||
|
||||
if i == 5:
|
||||
break
|
||||
|
||||
i += 1
|
||||
|
||||
if other_channel_id in last_calls_other_channels_ids:
|
||||
other_channel_id = await self.bot.redis.spop(
|
||||
constants.CHANNELS_SEARCH_QUEUE
|
||||
)
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id)
|
||||
)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
if other_channel_id is not None:
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id)
|
||||
)
|
||||
|
||||
msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}"
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(other_channel_id).send(
|
||||
msg_phone_answered
|
||||
)
|
||||
|
||||
call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw())
|
||||
|
||||
user, profile = await utils.get_user(ctx.author)
|
||||
|
||||
await models.CallUserChannelRelationship.create(
|
||||
call=call,
|
||||
channel_id=channel_id,
|
||||
user=user,
|
||||
profile=profile,
|
||||
is_masked=user.is_masked,
|
||||
mask=secrets.choice(constants.MASKS_LIST).value,
|
||||
)
|
||||
|
||||
await models.Channel.filter(
|
||||
id__in=[other_channel_id, channel.id]
|
||||
).update(call=call)
|
||||
|
||||
except errors.DiscordException:
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET,
|
||||
str(channel_id),
|
||||
str(other_channel_id),
|
||||
)
|
||||
await self.bot.redis.sadd(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
|
||||
)
|
||||
msg_phone_answered = ""
|
||||
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
f"☎️ You skipped the call.\n"
|
||||
)
|
||||
+ (
|
||||
msg_phone_answered
|
||||
if msg_phone_answered
|
||||
else f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
|
||||
|
||||
channel.call = None
|
||||
await channel.save()
|
||||
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
f"☎️ You skipped the call.\n"
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
|
||||
)
|
||||
)
|
||||
|
||||
@commands.command(aliases=["hang", "h"], extras={"help_name": "Hang Up"})
|
||||
@commands.guild_only()
|
||||
@checks.is_unbanned()
|
||||
@transactions.atomic()
|
||||
async def hangup(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Hang up the phone.
|
||||
"""
|
||||
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if is_on_call:
|
||||
channel = await utils.get_channel(ctx.channel)
|
||||
|
||||
call = await models.Call.get(id=channel.call_id).prefetch_related(
|
||||
"channels"
|
||||
)
|
||||
|
||||
other_channel: models.Channel = find(
|
||||
lambda c: c.id != channel_id, call.channels
|
||||
)
|
||||
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel.id)
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_channel(other_channel.id)).send(
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
|
||||
)
|
||||
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}")
|
||||
|
||||
channel.call = None
|
||||
other_channel.call = None
|
||||
|
||||
await models.Channel.bulk_update([channel, other_channel], ["call_id"])
|
||||
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
await call.save()
|
||||
|
||||
is_searching = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
|
||||
)
|
||||
|
||||
if is_searching:
|
||||
await self.bot.redis.srem(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
|
||||
|
||||
await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_NO_ANSWER)}")
|
||||
|
||||
if not is_searching and not is_on_call:
|
||||
await ctx.send(f"❌ {secrets.choice(constants.MSGS_PHONE_NOT_IN_CALL)}")
|
||||
|
||||
@commands.command()
|
||||
@commands.guild_only()
|
||||
@checks.is_unbanned()
|
||||
async def report(
|
||||
self, ctx: commands.Context, call_id: int | None = None, *, reason: str
|
||||
) -> None:
|
||||
"""
|
||||
Report a call by it's ID, or the current/last call if no ID is specified.
|
||||
|
||||
Options:
|
||||
- `[<call_id>]`: The ID of the call to report.
|
||||
- `<reason>`: The reason why you're reporting the call.
|
||||
"""
|
||||
|
||||
user, _ = await utils.get_user(ctx.author)
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
if call_id is None:
|
||||
call_rs = (
|
||||
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
|
||||
.order_by("-id")
|
||||
.limit(1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if call_rs is None:
|
||||
await ctx.send("❌ No calls occurred on this channel.")
|
||||
return
|
||||
|
||||
call_id = call_rs.call_id
|
||||
|
||||
call = await models.Call.get_or_none(id=call_id).prefetch_related(
|
||||
"channels", "report", "call_rss"
|
||||
)
|
||||
|
||||
if call is None:
|
||||
await ctx.send(f"❌ There is no call with ID `{call_id}`.")
|
||||
|
||||
if call.report is not None:
|
||||
await ctx.send(
|
||||
"✅ This call has already been reported. Our moderator team will review it as soon as possible."
|
||||
)
|
||||
return
|
||||
|
||||
if not any(map(lambda rs: rs.user_id == user.id, call.call_rss)):
|
||||
await ctx.send("❌ You have not participated in this call.")
|
||||
return
|
||||
|
||||
await models.Report.create(
|
||||
call_id=call_id, reported_by_id=user.id, reason=reason, priority=1
|
||||
)
|
||||
|
||||
message = "✅ The call has been reported."
|
||||
|
||||
if call.ended_at is None:
|
||||
call.ended_at = datetime.now(timezone.utc)
|
||||
|
||||
updated_channels = []
|
||||
|
||||
for channel in call.channels:
|
||||
channel.call = None
|
||||
updated_channels.append(channel)
|
||||
|
||||
await models.Channel.bulk_update(updated_channels, ["call_id"])
|
||||
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET,
|
||||
str(updated_channels[0]),
|
||||
str(updated_channels[1]),
|
||||
)
|
||||
|
||||
message += f"\n☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}"
|
||||
|
||||
other_channel = find(lambda c: c.id != channel_id, updated_channels)
|
||||
|
||||
await self.bot.redis.srem(
|
||||
constants.CHANNELS_ONCALL_SET, str(other_channel.id), str(channel_id)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(other_channel.id).send(
|
||||
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
|
||||
)
|
||||
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
await ctx.send(message)
|
||||
|
||||
@commands.command(aliases=["fr"], extras={"help_name": "Friend Request"})
|
||||
@commands.guild_only()
|
||||
@checks.is_unbanned()
|
||||
@checks.is_on_call()
|
||||
async def friendreq(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Ask the other party to send you a friend request.
|
||||
"""
|
||||
|
||||
user_id = ctx.author.id
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
call = await models.Call.get(channels__id=channel_id).prefetch_related(
|
||||
"channels"
|
||||
)
|
||||
|
||||
other_channel = find(lambda c: c.id != channel_id, call.channels)
|
||||
call_rs: models.CallUserChannelRelationship = find(
|
||||
lambda rs: rs.user_id == user_id, call.call_rss
|
||||
)
|
||||
|
||||
try:
|
||||
await self.bot.get_partial_messageable(other_channel.id).send(
|
||||
f"**{call_rs.mask_emoji + " " if call_rs.is_masked else None} {call_rs.display_name}** wants you to add them as a friend on Discord."
|
||||
)
|
||||
|
||||
except errors.DiscordException:
|
||||
await ctx.send("⚠️ Your friend request couldn't be sent.")
|
||||
return
|
||||
|
||||
await ctx.send("✅ Your friend request was sent!")
|
||||
|
||||
@commands.Cog.listener()
|
||||
@commands.guild_only()
|
||||
@transactions.atomic()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
"""Processes every message received.
|
||||
|
||||
Args:
|
||||
message (discord.Message): The message received.
|
||||
"""
|
||||
|
||||
if message.author.bot or message.is_system() or len(message.stickers) > 0:
|
||||
return
|
||||
|
||||
if (await self.bot.get_context(message)).valid:
|
||||
return
|
||||
|
||||
user_id = message.author.id
|
||||
channel_id = message.channel.id
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if not is_on_call:
|
||||
return
|
||||
|
||||
is_banned = await self.bot.is_banned(user_id)
|
||||
|
||||
if is_banned:
|
||||
return
|
||||
|
||||
channel = await utils.get_channel(message.channel)
|
||||
|
||||
call = await models.Call.get(id=channel.call_id).prefetch_related("channels")
|
||||
|
||||
other_channel: models.Channel = find(
|
||||
lambda c: c.id != channel_id, call.channels
|
||||
)
|
||||
|
||||
user, profile = await utils.get_user(message.author)
|
||||
|
||||
used_masks = await models.CallUserChannelRelationship.filter(
|
||||
call=call, channel=channel
|
||||
).values_list("mask", flat=True)
|
||||
|
||||
call_rs, _ = await models.CallUserChannelRelationship.get_or_create(
|
||||
{
|
||||
"mask": secrets.choice(
|
||||
[i for i in constants.MASKS_LIST if i not in used_masks]
|
||||
),
|
||||
"is_masked": user.is_masked,
|
||||
"profile": profile,
|
||||
},
|
||||
call=call,
|
||||
user=user,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook = other_channel.webhook(session)
|
||||
|
||||
mask = constants.Mask(call_rs.mask)
|
||||
|
||||
username: str
|
||||
avatar_url: str
|
||||
|
||||
if call_rs.is_masked:
|
||||
username = mask.name.lower().replace("_", " ").title()
|
||||
avatar_url = f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png"
|
||||
|
||||
else:
|
||||
username = profile.name
|
||||
avatar_url = profile.avatar_url
|
||||
|
||||
webhook_message = await webhook.send(
|
||||
message.content,
|
||||
username=username,
|
||||
avatar_url=avatar_url,
|
||||
wait=True,
|
||||
)
|
||||
|
||||
message_row = await models.Message.create(
|
||||
id=message.id,
|
||||
call=call,
|
||||
channel=channel,
|
||||
user=user,
|
||||
profile=profile,
|
||||
mask=call_rs.mask if call_rs.is_masked else None,
|
||||
propagation_id=webhook_message.id,
|
||||
propagation_channel=other_channel,
|
||||
)
|
||||
|
||||
await models.MessageContent.create(
|
||||
message=message_row,
|
||||
content=message.content,
|
||||
)
|
||||
|
||||
@commands.Cog.listener()
|
||||
@commands.guild_only()
|
||||
@transactions.atomic()
|
||||
async def on_raw_message_delete(
|
||||
self, payload: discord.RawMessageDeleteEvent
|
||||
) -> None:
|
||||
"""Processes a message deletion received.
|
||||
|
||||
Args:
|
||||
payload (discord.RawMessageDeleteEvent): The received payload.
|
||||
"""
|
||||
|
||||
message_id = payload.message_id
|
||||
channel_id = payload.channel_id
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if not is_on_call:
|
||||
return
|
||||
|
||||
message = await models.Message.get_or_none(id=message_id).select_related(
|
||||
"propagation_channel"
|
||||
)
|
||||
|
||||
if message is None:
|
||||
return
|
||||
|
||||
other_channel = message.propagation_channel
|
||||
|
||||
if other_channel.call_id != message.call_id:
|
||||
return
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook = other_channel.webhook(session)
|
||||
|
||||
await webhook.delete_message(message.propagation_id)
|
||||
|
||||
@commands.Cog.listener()
|
||||
@commands.guild_only()
|
||||
@transactions.atomic()
|
||||
async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
|
||||
"""Processes a message edit received.
|
||||
|
||||
Args:
|
||||
payload (discord.RawMessageUpdateEvent): The event payload.
|
||||
"""
|
||||
|
||||
if (
|
||||
payload.cached_message is not None
|
||||
and (
|
||||
payload.cached_message.author.bot or payload.cached_message.is_system()
|
||||
)
|
||||
or "content" not in payload.data
|
||||
):
|
||||
return
|
||||
|
||||
message_id = payload.message_id
|
||||
channel_id = payload.channel_id
|
||||
content = payload.data["content"]
|
||||
|
||||
is_on_call = await self.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if not is_on_call:
|
||||
return
|
||||
|
||||
message = await models.Message.get_or_none(id=message_id).select_related(
|
||||
"propagation_channel"
|
||||
)
|
||||
|
||||
if message is None:
|
||||
return
|
||||
|
||||
other_channel = message.propagation_channel
|
||||
|
||||
if other_channel.call_id != message.call_id:
|
||||
return
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook = other_channel.webhook(session)
|
||||
|
||||
await webhook.edit_message(message.propagation_id, content=content)
|
||||
|
||||
await models.MessageContent.create(message=message, content=content)
|
||||
|
||||
|
||||
def setup(b: bot.Payphone) -> None:
|
||||
"""Load the cog into the bot.
|
||||
|
||||
Args:
|
||||
b (bot.Payphone): The bot instance to load the cog into.
|
||||
"""
|
||||
|
||||
b.add_cog(Calls(b))
|
||||
@@ -0,0 +1,12 @@
|
||||
import discord
|
||||
|
||||
|
||||
async def ReplyEmbed(reference: discord.MessageReference) -> discord.Embed:
|
||||
"""Generates the embed sent on calls to represent replies.
|
||||
|
||||
Args:
|
||||
reference (discord.MessageReference): The message reference.
|
||||
|
||||
Returns:
|
||||
discord.Embed: The generated embed.
|
||||
"""
|
||||
@@ -0,0 +1,210 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord import errors
|
||||
from discord.ext import commands
|
||||
|
||||
from tortoise import transactions
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models, utils
|
||||
from payphone.cogs import base
|
||||
from payphone.cogs.mods import embeds, views
|
||||
from payphone.core import checks, converters, constants
|
||||
|
||||
|
||||
class Mods(base.Cog):
|
||||
show_in_help = False
|
||||
|
||||
def __init__(self, bot_instance: bot.Payphone):
|
||||
self.bot = bot_instance
|
||||
|
||||
@commands.command(extras={"reply_argument": "user"})
|
||||
@checks.is_moderator()
|
||||
@transactions.atomic()
|
||||
async def ban(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.User | discord.Member | converters.DiscordUser,
|
||||
time: converters.Timedelta | None = None,
|
||||
*,
|
||||
reason: str | None = "",
|
||||
) -> None:
|
||||
"""
|
||||
Ban a user by their ID, pinging them, or replying to their message.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user to ban or their ID.
|
||||
- `[<time>]`: The amount of time to ban the user for. For example, `1d 5h`.
|
||||
- `[<reason>]`: The reason of their ban.
|
||||
"""
|
||||
|
||||
user_id = None
|
||||
|
||||
if isinstance(user, (discord.User, discord.Member)):
|
||||
user_id = user.id
|
||||
|
||||
else:
|
||||
user_id = user
|
||||
|
||||
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
|
||||
|
||||
row = await self.bot.ban(
|
||||
user_id,
|
||||
(datetime.now(timezone.utc) + time) if time is not None else None,
|
||||
reason,
|
||||
ctx.author,
|
||||
)
|
||||
|
||||
message = f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was banned"
|
||||
|
||||
if row.expires_at:
|
||||
message += f" until <t:{int(row.expires_at.timestamp())}:F> (<t:{int(row.expires_at.timestamp())}:R>)"
|
||||
|
||||
message += "."
|
||||
|
||||
await ctx.send(message)
|
||||
|
||||
try:
|
||||
notification_message = await (
|
||||
await self.bot.get_or_fetch_user(user_id)
|
||||
).send(
|
||||
embed=embeds.BanNotificationEmbed(self.bot, row),
|
||||
view=views.BanNotificationView(self.bot, row),
|
||||
)
|
||||
|
||||
row.notification_message_id = notification_message.id
|
||||
await row.save()
|
||||
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
@commands.command(extras={"reply_argument": "user"})
|
||||
@checks.is_moderator()
|
||||
@transactions.atomic()
|
||||
async def unban(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.User | discord.Member | converters.DiscordUser,
|
||||
) -> None:
|
||||
"""
|
||||
Unbans a user by their ID, pinging them, or replying to their message. The user will be
|
||||
attempted to get notified of the unban.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user to unban or their ID.
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id
|
||||
|
||||
await self.bot.unban(user_id)
|
||||
|
||||
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
|
||||
|
||||
await ctx.send(
|
||||
f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was unbanned."
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(user_id)).send(
|
||||
"🔔 You have been unbanned from Payphone."
|
||||
)
|
||||
except errors.DiscordException:
|
||||
pass
|
||||
|
||||
@commands.command(extras={"reply_argument": "user"})
|
||||
@checks.is_moderator()
|
||||
@transactions.atomic()
|
||||
async def warn(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.User | discord.Member | converters.DiscordUser,
|
||||
*,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""
|
||||
Warn a user by their ID or mention.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user's mention or their ID.
|
||||
- `<reason>`: The reason of the warn.
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id
|
||||
|
||||
user = None
|
||||
|
||||
try:
|
||||
user = await self.bot.get_or_fetch_user(user_id)
|
||||
except errors.DiscordError:
|
||||
await ctx.send(
|
||||
"❌ Could not find this user. It may be because the user has left all servers with Payphone in them."
|
||||
)
|
||||
return
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.EMBED_COLOR,
|
||||
title="You've been warned",
|
||||
description=reason,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
author=self.bot.embed_author,
|
||||
footer=discord.EmbedFooter(
|
||||
text="Ignoring this warning will result in a ban."
|
||||
),
|
||||
thumbnail=self.bot.user.display_avatar.url,
|
||||
)
|
||||
|
||||
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
|
||||
|
||||
try:
|
||||
await user.send(embed=embed)
|
||||
except errors.DiscordException:
|
||||
await ctx.send(
|
||||
f"❌ Could not send the warning to {f"**{profile.name}**" if profile is not None else "the user"}, probably because they have their DMs closed."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.send(
|
||||
f"✅ {f"**{profile.name}**" if profile is not None else "the user"} has been warned."
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@commands.is_owner()
|
||||
@transactions.atomic()
|
||||
async def moderator(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.User | discord.Member,
|
||||
toggle: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Toggles a user's moderator status. It can only be ran by Payphone's owner.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user to toggle the moderation status for.
|
||||
- `[<toggle>]`: The moderator status to set to the user.
|
||||
"""
|
||||
|
||||
user_row, profile_row = await utils.get_user(user)
|
||||
|
||||
if toggle is not None:
|
||||
user_row.is_moderator = toggle
|
||||
|
||||
else:
|
||||
user_row.is_moderator = not user_row.is_moderator
|
||||
|
||||
await user_row.save()
|
||||
|
||||
await ctx.send(
|
||||
f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`."
|
||||
)
|
||||
|
||||
|
||||
def setup(b: bot.Payphone) -> None:
|
||||
"""Load the cog into the bot.
|
||||
|
||||
Args:
|
||||
b (bot.Payphone): The bot instance to load the cog into.
|
||||
"""
|
||||
|
||||
b.add_cog(Mods(b))
|
||||
@@ -0,0 +1,46 @@
|
||||
import discord
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models
|
||||
from payphone.core import constants
|
||||
|
||||
|
||||
class BanNotificationEmbed(discord.Embed):
|
||||
"""Notification embed sent to users when they get banned."""
|
||||
|
||||
def __init__(self, bot: bot.Payphone, ban: models.Ban) -> None:
|
||||
"""Initializes the embed.
|
||||
|
||||
Args:
|
||||
bot (bot.Payphone): The instance of the bot.
|
||||
ban (models.Ban): The ban.
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
color=constants.EMBED_COLOR,
|
||||
title="You've Been Banned from Payphone",
|
||||
description="You have been banned from using Payphone.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Reason",
|
||||
value=ban.reason if ban.reason else "*No reason.*",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="Expires At",
|
||||
value=f"<t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
|
||||
if ban.expires_at
|
||||
else "*Does not expire.*",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="Appeal",
|
||||
value="*You haven't appealed this ban.*"
|
||||
if ban.appeal is None
|
||||
else ban.appeal,
|
||||
),
|
||||
],
|
||||
author=bot.embed_author,
|
||||
footer=discord.EmbedFooter(
|
||||
text="If the ban was automatically set, it may be removed after a moderator checks it."
|
||||
),
|
||||
thumbnail=bot.user.display_avatar.url,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import discord
|
||||
|
||||
from tortoise import transactions
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models
|
||||
from payphone.cogs.mods import embeds, views
|
||||
|
||||
|
||||
class BanNotificationModal(discord.ui.Modal):
|
||||
"""Modal sent to users to appeal their ban."""
|
||||
|
||||
def __init__(self, bot: bot.Payphone) -> None:
|
||||
"""Initializes the modal."""
|
||||
|
||||
self.bot = bot
|
||||
|
||||
super().__init__(
|
||||
discord.ui.InputText(
|
||||
custom_id="ban-notification-appeal:appeal",
|
||||
label="Appeal",
|
||||
placeholder="",
|
||||
max_length=500,
|
||||
required=True,
|
||||
style=discord.InputTextStyle.long,
|
||||
),
|
||||
title="Ban Appeal",
|
||||
custom_id="ban-notification-appeal",
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
@transactions.atomic()
|
||||
async def callback(self, interaction: discord.Interaction):
|
||||
ban = await models.Ban.get(notification_message_id=interaction.message.id)
|
||||
|
||||
await interaction.response.defer()
|
||||
|
||||
ban.appeal = self.children[0].value
|
||||
ban.status = models.BanStatus.APPEALED
|
||||
await ban.save()
|
||||
|
||||
await interaction.message.edit(
|
||||
embed=embeds.BanNotificationEmbed(self.bot, ban),
|
||||
view=views.BanNotificationView(self.bot, ban),
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import discord
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models
|
||||
from payphone.cogs.mods import modals
|
||||
|
||||
|
||||
class BanNotificationView(discord.ui.View):
|
||||
"""View sent with the ban notification embed.
|
||||
|
||||
This view contains a button that sends a modal to appeal the ban.
|
||||
"""
|
||||
|
||||
def __init__(self, bot: bot.Payphone, ban: models.Ban) -> None:
|
||||
"""Initializes the view.
|
||||
|
||||
Args:
|
||||
bot (bot.Payphone): The bot.
|
||||
ban (models.Ban): The ban to appeal.
|
||||
"""
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.bot = bot
|
||||
|
||||
if ban.status != models.BanStatus.VALID:
|
||||
self.disable_all_items()
|
||||
self.children[0].label = "Appealed"
|
||||
|
||||
self.add_item(
|
||||
discord.ui.Button(
|
||||
label=f"Status: {ban.status.name.replace("_", " ").lower().title()}",
|
||||
disabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
@discord.ui.button(
|
||||
label="Appeal Ban",
|
||||
custom_id="ban-notification:appeal-ban",
|
||||
style=discord.ButtonStyle.blurple,
|
||||
)
|
||||
async def callback(
|
||||
self, _button: discord.ui.Button, interaction: discord.Interaction
|
||||
) -> None:
|
||||
"""Sends the ban appeal modal."""
|
||||
|
||||
await interaction.response.send_modal(modals.BanNotificationModal(self.bot))
|
||||
@@ -0,0 +1,255 @@
|
||||
import discord
|
||||
from discord.ext import commands, pages
|
||||
from discord.utils import find
|
||||
|
||||
from tortoise import transactions, functions
|
||||
|
||||
from payphone import bot
|
||||
from payphone.db import models, utils
|
||||
from payphone.cogs import base
|
||||
from payphone.core import constants
|
||||
|
||||
|
||||
class Users(base.Cog):
|
||||
def __init__(self, bot_instance: bot.Payphone):
|
||||
self.bot = bot_instance
|
||||
|
||||
@commands.command()
|
||||
@transactions.atomic()
|
||||
async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None:
|
||||
user, _ = await utils.get_user(ctx.author)
|
||||
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
if toggle is None:
|
||||
if user.is_masked:
|
||||
channel = await models.Channel.get_or_none(
|
||||
id=channel_id
|
||||
).select_related("call")
|
||||
call_rs = (
|
||||
await models.CallUserChannelRelationship.get_or_none(
|
||||
channel=channel, user=user, call=channel.call
|
||||
)
|
||||
if channel is not None
|
||||
else None
|
||||
)
|
||||
|
||||
message = "🕵️ You're masked!"
|
||||
|
||||
if call_rs is not None:
|
||||
mask_weared = constants.Mask(call_rs.mask)
|
||||
mask_name = mask_weared.name.lower().replace("_", "")
|
||||
|
||||
message += f" You're currently being displayed as **<:{mask_name}:{call_rs.mask}>**."
|
||||
|
||||
else:
|
||||
message += " Your identity will be hidden during calls."
|
||||
|
||||
message += f" Use `{ctx.clean_prefix}mask off` to turn it off."
|
||||
|
||||
await ctx.send(message)
|
||||
|
||||
else:
|
||||
await ctx.send(
|
||||
f"🕵️ You're not masked! Use `{ctx.clean_prefix}mask on` to mask yourself."
|
||||
)
|
||||
|
||||
else:
|
||||
user.is_masked = toggle
|
||||
await user.save()
|
||||
|
||||
past_user_call_rss = (
|
||||
await models.CallUserChannelRelationship.filter(user=user)
|
||||
.order_by("-id")
|
||||
.limit(5)
|
||||
.select_related("call")
|
||||
)
|
||||
|
||||
def update(
|
||||
rs: models.CallUserChannelRelationship,
|
||||
) -> models.CallUserChannelRelationship:
|
||||
rs.is_masked = user.is_masked
|
||||
return rs
|
||||
|
||||
ongoing_call_user_rss = list(
|
||||
map(
|
||||
update,
|
||||
filter(lambda rs: rs.call.ended_at is None, past_user_call_rss),
|
||||
)
|
||||
)
|
||||
|
||||
if len(ongoing_call_user_rss) > 0:
|
||||
await models.CallUserChannelRelationship.bulk_update(
|
||||
ongoing_call_user_rss, ["is_masked"]
|
||||
)
|
||||
|
||||
message = (
|
||||
"🕵️ From now on your profile will be "
|
||||
+ ("hidden" if user.is_masked else "displayed")
|
||||
+ "."
|
||||
)
|
||||
|
||||
current_channel_ongoing_call_user_rs = find(
|
||||
lambda rs: rs.channel_id == channel_id, ongoing_call_user_rss
|
||||
)
|
||||
|
||||
if current_channel_ongoing_call_user_rs and user.is_masked:
|
||||
message += f" You're currently being displayed as **{current_channel_ongoing_call_user_rs.mask_emoji} {current_channel_ongoing_call_user_rs.display_name}**."
|
||||
|
||||
message += (
|
||||
f" Use `{ctx.clean_prefix}mask` during a call to see your mask."
|
||||
)
|
||||
|
||||
else:
|
||||
message += f" Use `{ctx.clean_prefix}mask on` to turn it back on."
|
||||
|
||||
await ctx.send(message)
|
||||
|
||||
@commands.command()
|
||||
@transactions.atomic()
|
||||
async def history(self, ctx: commands.Context) -> None:
|
||||
user_id = ctx.author.id
|
||||
|
||||
call_rss = (
|
||||
await models.CallUserChannelRelationship.filter(user_id=user_id)
|
||||
.order_by("-id")
|
||||
.select_related("profile")
|
||||
.limit(30)
|
||||
)
|
||||
|
||||
if len(call_rss) == 0:
|
||||
await ctx.send(
|
||||
f"☎️ You haven't participated in a call yet! Use `{ctx.clean_prefix}call` to start one."
|
||||
)
|
||||
return
|
||||
|
||||
call_ids = [rs.call_id for rs in call_rss]
|
||||
|
||||
profile = call_rss[0].profile
|
||||
|
||||
call_rss = (
|
||||
await models.CallUserChannelRelationship.filter(call_id__in=call_ids)
|
||||
.select_related("profile")
|
||||
.all()
|
||||
)
|
||||
calls_rss_map = {}
|
||||
|
||||
for call_rs in call_rss:
|
||||
if call_rs.call_id not in calls_rss_map:
|
||||
calls_rss_map[call_rs.call_id] = {
|
||||
"user_rs": None,
|
||||
"others_rs": [],
|
||||
"first_message": None,
|
||||
}
|
||||
|
||||
if call_rs.user_id == user_id:
|
||||
calls_rss_map[call_rs.call_id]["user_rs"] = call_rs
|
||||
|
||||
else:
|
||||
calls_rss_map[call_rs.call_id]["others_rs"].append(call_rs)
|
||||
|
||||
min_ids = await (
|
||||
models.Message.filter(call_id__in=call_ids)
|
||||
.annotate(min_id=functions.Min("id"))
|
||||
.group_by("call_id")
|
||||
.values_list("min_id", flat=True)
|
||||
)
|
||||
|
||||
# Step 2: Fetch the actual messages using these IDs
|
||||
first_messages = (
|
||||
await models.Message.filter(id__in=min_ids)
|
||||
.select_related("propagation_channel", "channel")
|
||||
.all()
|
||||
)
|
||||
|
||||
for msg in first_messages:
|
||||
calls_rss_map[msg.call_id]["first_message"] = msg
|
||||
|
||||
embeds = []
|
||||
|
||||
logs = ""
|
||||
logs_count = 0
|
||||
|
||||
for call_id, data in calls_rss_map.items():
|
||||
logs += f"- `{call_id}`: "
|
||||
|
||||
participant_names = ", ".join(
|
||||
call_rs.display_name
|
||||
if call_rs.channel_id != data["user_rs"].channel_id
|
||||
else call_rs.profile.name
|
||||
for call_rs in data["others_rs"] + [data["user_rs"]]
|
||||
)
|
||||
|
||||
link = None
|
||||
|
||||
first_message: models.Message | None = data["first_message"]
|
||||
|
||||
if first_message is not None:
|
||||
if first_message.channel_id == data["user_rs"].channel_id:
|
||||
link = f"https://discord.com/channels/{first_message.channel.server_id}/{first_message.channel_id}/{first_message.id}"
|
||||
|
||||
else:
|
||||
link = f"https://discord.com/channels/{first_message.propagation_channel.server_id}/{first_message.propagation_channel.id}/{first_message.propagation_id}"
|
||||
|
||||
if link is not None:
|
||||
logs += f"[{participant_names}]({link})"
|
||||
|
||||
else:
|
||||
logs += participant_names
|
||||
|
||||
logs += "\n"
|
||||
logs_count += 1
|
||||
|
||||
if logs.count("\n") == 10:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
title="Call History",
|
||||
description="Last calls:\n" + logs,
|
||||
thumbnail=profile.avatar_url,
|
||||
author=discord.EmbedAuthor(
|
||||
name=profile.name, icon_url=profile.avatar_url
|
||||
),
|
||||
footer=discord.EmbedFooter(text=f"Page {len(embeds) + 1} of 3"),
|
||||
)
|
||||
)
|
||||
|
||||
logs = ""
|
||||
|
||||
if logs != "":
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
title="Call History",
|
||||
description="Last calls:\n" + logs,
|
||||
thumbnail=profile.avatar_url,
|
||||
author=discord.EmbedAuthor(
|
||||
name=profile.name, icon_url=profile.avatar_url
|
||||
),
|
||||
footer=discord.EmbedFooter(text=f"Page {len(embeds) + 1} of 3"),
|
||||
)
|
||||
)
|
||||
|
||||
paginator = pages.Paginator(
|
||||
embeds,
|
||||
show_indicator=False,
|
||||
use_default_buttons=False,
|
||||
custom_buttons=[
|
||||
pages.PaginatorButton(
|
||||
button_type="prev", label="⬅️", style=discord.ButtonStyle.blurple
|
||||
),
|
||||
pages.PaginatorButton(
|
||||
button_type="next", label="➡️", style=discord.ButtonStyle.blurple
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await paginator.send(ctx)
|
||||
|
||||
|
||||
def setup(b: bot.Payphone) -> None:
|
||||
"""Load the cog into the bot.
|
||||
|
||||
Args:
|
||||
b (bot.Payphone): The bot instance to load the cog into.
|
||||
"""
|
||||
|
||||
b.add_cog(Users(b))
|
||||
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone import bot
|
||||
from payphone.cogs import base
|
||||
from payphone.core import config
|
||||
|
||||
|
||||
class Utility(base.Cog):
|
||||
def __init__(self, bot_instance: bot.Payphone):
|
||||
self.bot = bot_instance
|
||||
|
||||
@commands.command()
|
||||
@commands.cooldown(1, 60, commands.BucketType.user)
|
||||
async def suggest(self, ctx: commands.Context, suggestion: str) -> None:
|
||||
embed = discord.Embed(
|
||||
title="Suggestion",
|
||||
description=suggestion,
|
||||
author=discord.EmbedAuthor(
|
||||
name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url
|
||||
),
|
||||
footer=discord.EmbedFooter(text=ctx.author.username),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
await self.bot.get_partial_messageable(config.SUGGESTIONS_CHANNEL_ID).send(
|
||||
embed=embed
|
||||
)
|
||||
|
||||
|
||||
def setup(b: bot.Payphone) -> None:
|
||||
"""Load the cog into the bot.
|
||||
|
||||
Args:
|
||||
b (bot.Payphone): The bot instance to load the cog into.
|
||||
"""
|
||||
|
||||
b.add_cog(Utility(b))
|
||||
@@ -0,0 +1,439 @@
|
||||
# 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
|
||||
|
||||
Args:
|
||||
text (str): The word or phrase
|
||||
|
||||
Returns:
|
||||
str: The created pattern.
|
||||
"""
|
||||
|
||||
replacements = [
|
||||
("a", "a|@|4"),
|
||||
("c", "c|ç"),
|
||||
("e", "e|3"),
|
||||
("h", "h|#"),
|
||||
("i", "i|1|!"),
|
||||
("o", "o|0"),
|
||||
("t", "t|7"),
|
||||
("u", "u|v|ü"),
|
||||
("v", "u|v|ü"),
|
||||
("x", "x|><"),
|
||||
]
|
||||
|
||||
text = "+".join(list(text)) + "+"
|
||||
|
||||
for r in replacements:
|
||||
text = text.replace(r[0], f"({r[1]})")
|
||||
|
||||
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/",
|
||||
]
|
||||
|
||||
for prefix in allowed_prefixes:
|
||||
if url.startswith(prefix):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
message (str): The raw message.
|
||||
allowed_categories (List[str], optional): Categories allowed to be skipped. Defaults to [].
|
||||
|
||||
Returns:
|
||||
Tuple[bool, List[Violation]]: Whether the message is clean or not (first item) and all the violations found in the message (second item).
|
||||
"""
|
||||
|
||||
found_violations = []
|
||||
msg_words = get_words(message)
|
||||
|
||||
for category, words_info in blocked_words.items():
|
||||
if category in allowed_categories:
|
||||
continue
|
||||
|
||||
for words, info in words_info.items():
|
||||
for word, pattern in words:
|
||||
found = False
|
||||
word_count = len(word.split())
|
||||
|
||||
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 m is not None:
|
||||
found_violations.append(
|
||||
Violation(
|
||||
word=msg_word,
|
||||
pattern=pattern,
|
||||
message=info["message"],
|
||||
level=info["level"],
|
||||
)
|
||||
)
|
||||
found = True
|
||||
break
|
||||
|
||||
if found:
|
||||
continue
|
||||
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.db import utils
|
||||
from payphone.core import exceptions, constants
|
||||
|
||||
|
||||
def is_moderator():
|
||||
"""Checks whether the user is a moderator or not."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
if ctx.author.bot:
|
||||
return False
|
||||
|
||||
user, _ = await utils.get_user(ctx.author)
|
||||
|
||||
if user.is_moderator:
|
||||
return True
|
||||
|
||||
raise exceptions.NotAModeratorError()
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
def is_unbanned():
|
||||
"""Checks that the user is not banned."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
if await ctx.bot.is_banned(ctx.author):
|
||||
raise exceptions.BannedError()
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
def is_on_call():
|
||||
"""Checks that a call is ongoing in the channel."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
channel_id = ctx.channel.id
|
||||
|
||||
is_on_call = await ctx.bot.redis.sismember(
|
||||
constants.CHANNELS_ONCALL_SET, str(channel_id)
|
||||
)
|
||||
|
||||
if not is_on_call:
|
||||
raise exceptions.NotOnCallError()
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
|
||||
TOKEN = os.getenv("TOKEN")
|
||||
|
||||
IS_DEBUG = os.environ.get("IS_DEBUG", "false") == "true"
|
||||
|
||||
TORTOISE_ORM = {
|
||||
"connections": {
|
||||
"default": f"postgres://payphone:payphone@{"localhost" if IS_DEBUG else "postgres"}/payphone",
|
||||
},
|
||||
"apps": {
|
||||
"models": {
|
||||
"models": ["payphone.db.models", "aerich.models"],
|
||||
"default_connection": "default",
|
||||
}
|
||||
},
|
||||
"minsize": 5,
|
||||
"maxsize": 100
|
||||
}
|
||||
|
||||
SUGGESTIONS_CHANNEL_ID = int(os.getenv("SUGGESTIONS_CHANNEL_ID"))
|
||||
|
||||
SERVER_INVITE = os.getenv("SERVER_INVITE")
|
||||
@@ -0,0 +1,179 @@
|
||||
import enum
|
||||
|
||||
from payphone.core import config
|
||||
|
||||
|
||||
class Mask(enum.IntEnum):
|
||||
FERRET = 1278176917123760139 if config.IS_DEBUG else 1278173356423909448
|
||||
OCTOPUS = 1278176904410697880 if config.IS_DEBUG else 1278173331421397072
|
||||
CAT = 1278176891290783824 if config.IS_DEBUG else 1278173317034934282
|
||||
MONKEY = 1278176881195094049 if config.IS_DEBUG else 1278173297456185344
|
||||
PANDA = 1278176868767629455 if config.IS_DEBUG else 1278173272390762538
|
||||
SHEEP = 1278176856910073946 if config.IS_DEBUG else 1278173251914432563
|
||||
HORSE = 1278176842116894760 if config.IS_DEBUG else 1278173237582237708
|
||||
BEE = 1278176823846506577 if config.IS_DEBUG else 1278173216652787826
|
||||
WILD_BOAR = 1278176806498996235 if config.IS_DEBUG else 1278173196658413649
|
||||
DUCK = 1278176794591232102 if config.IS_DEBUG else 1278173181483679918
|
||||
CHICKEN = 1278176781790216233 if config.IS_DEBUG else 1278173167218589726
|
||||
ELEPHANT = 1278176771413512213 if config.IS_DEBUG else 1278173150063886346
|
||||
COW = 1278176761565413483 if config.IS_DEBUG else 1278173125024153733
|
||||
DEER = 1278176744691597312 if config.IS_DEBUG else 1278173110105014406
|
||||
TURKEY = 1278176730619576321 if config.IS_DEBUG else 1278173091796877363
|
||||
DOG = 1278176719932751903 if config.IS_DEBUG else 1278173070846201856
|
||||
BEAVER = 1278176709530882091 if config.IS_DEBUG else 1278173051485425705
|
||||
CAMEL = 1278176699921465394 if config.IS_DEBUG else 1278173035018584086
|
||||
KOALA = 1278176687493746709 if config.IS_DEBUG else 1278173021345021952
|
||||
HIPPO = 1278176675846295603 if config.IS_DEBUG else 1278173007419932804
|
||||
OSTRICH = 1278176661317095494 if config.IS_DEBUG else 1278172994669248523
|
||||
RHINO = 1278176650340864000 if config.IS_DEBUG else 1278172980848885832
|
||||
SQUIRREL = 1278176637627924570 if config.IS_DEBUG else 1278172965590274088
|
||||
CHAMALEON = 1278176625392877648 if config.IS_DEBUG else 1278172953305022496
|
||||
FROG = 1278176614512857088 if config.IS_DEBUG else 1278172937043710094
|
||||
SKUNK = 1278176604509442079 if config.IS_DEBUG else 1278172923495976981
|
||||
WALRUS = 1278176554723311678 if config.IS_DEBUG else 1278172912368619603
|
||||
HEDGEHOG = 1278176541750067252 if config.IS_DEBUG else 1278172899911405678
|
||||
PIG = 1278176531415564332 if config.IS_DEBUG else 1278172885239861338
|
||||
HEN = 1278176518643646485 if config.IS_DEBUG else 1278172871302184994
|
||||
TURTLE = 1278176507428343891 if config.IS_DEBUG else 1278172858199314455
|
||||
MOUSE = 1278175957047578696 if config.IS_DEBUG else 1278172846442414081
|
||||
RABBIT = 1278175944007487489 if config.IS_DEBUG else 1278172831921999945
|
||||
GOAT = 1278175933915856938 if config.IS_DEBUG else 1278172815421472808
|
||||
LION = 1278175922964664382 if config.IS_DEBUG else 1278172803534684235
|
||||
WOLF = 1278175902047535165 if config.IS_DEBUG else 1278172791052570687
|
||||
SNAKE = 1278175869331968061 if config.IS_DEBUG else 1278172776259260547
|
||||
LLAMA = 1278175850008674325 if config.IS_DEBUG else 1278172764913799168
|
||||
CRAB = 1278175835634925620 if config.IS_DEBUG else 1278172752322232330
|
||||
OWL = 1278175822947029152 if config.IS_DEBUG else 1278172738309193749
|
||||
BAT = 1278175813572886550 if config.IS_DEBUG else 1278172726426865724
|
||||
ZEBRA = 1278175799962505216 if config.IS_DEBUG else 1278172715114565753
|
||||
FOX = 1278175788818104474 if config.IS_DEBUG else 1278172702955274292
|
||||
GORILLA = 1278175775891259456 if config.IS_DEBUG else 1278172687797194824
|
||||
SLOTH = 1278175765661356175 if config.IS_DEBUG else 1278172676837343242
|
||||
PUFFER_FISH = 1278175753497743360 if config.IS_DEBUG else 1278172665093558294
|
||||
BEAR = 1278175741569142867 if config.IS_DEBUG else 1278172653076746301
|
||||
GIRAFFE = 1278175732601983006 if config.IS_DEBUG else 1278172640296702073
|
||||
PENGUIN = 1278175721956573236 if config.IS_DEBUG else 1278172627629899808
|
||||
PARROT = 1278175706798489642 if config.IS_DEBUG else 1278172600702337074
|
||||
|
||||
|
||||
class CatMask(enum.IntEnum):
|
||||
DINOSAUR = 1278255435652010014 if config.IS_DEBUG else 1278254725833297965
|
||||
SHLEEPY_CAT = 1278255475066146816 if config.IS_DEBUG else 1278254779767984181
|
||||
COZY_CAT = 1278255420124696663 if config.IS_DEBUG else 1278254704324771893
|
||||
COQUETTE_CAT = 1278255408254816300 if config.IS_DEBUG else 1278254684200636428
|
||||
PANIC_CAT = 1278255451993145345 if config.IS_DEBUG else 1278254760700674098
|
||||
|
||||
|
||||
MASKS_LIST = list(Mask)
|
||||
CAT_MASKS_LIST = list(CatMask)
|
||||
|
||||
TIPS = [
|
||||
"You can report calls using `{prefix}report`.",
|
||||
"Help us sharing Payphone to make it more active!",
|
||||
"You can check your stats with `{prefix}me`.",
|
||||
"Try calling in a NSFW channel.",
|
||||
"Emojis will only show if they're from a server Payphone's in.",
|
||||
"You cannot send links.",
|
||||
"[Vote for Payphone on top.gg](<https://top.gg/bot/1212940413795827763/vote>) to help us reach more users!",
|
||||
"[Write a review](<https://top.gg/bot/1212940413795827763#reviews>) to tell everyone your opinion about Payphone!",
|
||||
"Calls will be automatically hung up after 15 minutes if nobody talks.",
|
||||
"You can change Payphone's prefix with `{prefix}config prefix`.",
|
||||
"Do you have any suggestions? Use `{prefix}suggest`!",
|
||||
"You can use `{prefix}skip` to directly search for a new call.",
|
||||
"The links from `{prefix}history` will take you to that call.",
|
||||
"Want to stay hidden? Use `{prefix}mask on`!",
|
||||
"Did you know you can check your mask with `{prefix}mask`?",
|
||||
"Do you like Payphone? Consider [making a donation](<https://ko-fi.com/payphonedev>) to help us grow Payphone (and keep it running)!",
|
||||
"Using `{prefix}friendreq` is the best way to ask the other party to add you.",
|
||||
"Enable and disable channels using `{prefix}config enable` and `{prefix}config disable`!",
|
||||
"There's a secret easter egg command. Try to find it! (Hint: cat)",
|
||||
"Make sure to read Payphone's [Code of Conduct](<https://payphone.nyeki.dev/code-of-conduct>). Let's keep Payphone safe and fun for everyone!",
|
||||
f"Join [Payphone's official server](<{config.SERVER_INVITE}>) to participate in polls for new features!",
|
||||
]
|
||||
|
||||
MSGS_PHONE_CALLING = [
|
||||
"Dialing now! Please wait...",
|
||||
"Trying to connect you...",
|
||||
"Making the call, hang tight!",
|
||||
"Searching for someone to answer...",
|
||||
"Your call is being placed...",
|
||||
"Connecting your call now...",
|
||||
"Calling! One moment please...",
|
||||
"Attempting to reach your contact...",
|
||||
"Establishing the connection...",
|
||||
"Your call is on the way!",
|
||||
]
|
||||
|
||||
MSGS_PHONE_ANSWERED = [
|
||||
"Call connected! You're live!",
|
||||
"The call was answered.",
|
||||
"Someone picked up the call!",
|
||||
"Connected! Start talking.",
|
||||
"You're now connected!",
|
||||
"The call was successfully answered.",
|
||||
"Call picked up! You're connected.",
|
||||
"You're now live on the call!",
|
||||
"Call answered. Go ahead!",
|
||||
"Connection successful! You're live.",
|
||||
]
|
||||
|
||||
MSGS_PHONE_HANGUP_ME = [
|
||||
"Call ended. You hung up.",
|
||||
"You've disconnected the call.",
|
||||
"You ended the call.",
|
||||
"Call terminated. You hung up.",
|
||||
"You have hung up the call.",
|
||||
"You ended the conversation.",
|
||||
"You've finished the call.",
|
||||
"Call ended by you.",
|
||||
"You disconnected the call.",
|
||||
"You have ended the call.",
|
||||
]
|
||||
|
||||
MSGS_PHONE_HANGUP_YOU = [
|
||||
"The other party has hung up.",
|
||||
"Call ended by the other party.",
|
||||
"The other person disconnected.",
|
||||
"The call was terminated by the other party.",
|
||||
"The other person has hung up.",
|
||||
"Conversation ended by the other party.",
|
||||
"The call has been finished by the other party.",
|
||||
"The other party ended the call.",
|
||||
"Disconnected by the other party.",
|
||||
"The other person ended the call.",
|
||||
]
|
||||
|
||||
MSGS_PHONE_NO_ANSWER = [
|
||||
"No answer. Maybe try again later?",
|
||||
"It looks like nobody picked up.",
|
||||
"The call went unanswered.",
|
||||
"No response. Try calling again later.",
|
||||
"No one is available to answer.",
|
||||
"The call wasn't answered.",
|
||||
"Nobody answered the call.",
|
||||
"The call went unanswered. Try again soon!",
|
||||
"No answer at the moment.",
|
||||
"The call wasn't picked up.",
|
||||
]
|
||||
|
||||
MSGS_PHONE_NOT_IN_CALL = [
|
||||
"It looks like you're not in a call right now.",
|
||||
"No active call at the moment.",
|
||||
"You're not connected to any call.",
|
||||
"There's no call to hang up right now.",
|
||||
"You're not currently on a call.",
|
||||
"No call in progress.",
|
||||
"You're not in an active call.",
|
||||
"There isn't a call to end right now.",
|
||||
"You're not on a call.",
|
||||
"No ongoing call to disconnect.",
|
||||
]
|
||||
|
||||
CHANNELS_SEARCH_QUEUE = "channels:searching"
|
||||
CHANNELS_ONCALL_SET = "channels:on-call"
|
||||
BAN_TEMPLATE = "b:%s"
|
||||
PREFIX_TEMPLATE = "p:%s"
|
||||
|
||||
EMBED_COLOR = 0xE2D9D0
|
||||
|
||||
INVISIBLE_CHARACTER = "\u200B"
|
||||
@@ -0,0 +1,76 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import converter
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.core import exceptions
|
||||
|
||||
|
||||
class Timedelta(converter.Converter):
|
||||
"""Converts a string like "1d 2h" to a `datetime.timedelta` object."""
|
||||
|
||||
async def convert(self, _ctx: commands.Context, argument: str) -> timedelta:
|
||||
multipliers = {
|
||||
"s": 1,
|
||||
"m": 60,
|
||||
"h": 60 * 60,
|
||||
"d": 60 * 60 * 24,
|
||||
"w": 60 * 60 * 24 * 7,
|
||||
"M": 60 * 60 * 24 * 30,
|
||||
"y": 60 * 60 * 24 * 365,
|
||||
}
|
||||
|
||||
if argument[-1] in multipliers:
|
||||
try:
|
||||
unit = multipliers[argument[-1]]
|
||||
quantity = float(argument[:-1])
|
||||
|
||||
return timedelta(seconds=unit * quantity)
|
||||
|
||||
except Exception as e:
|
||||
raise exceptions.InvalidTimedeltaError(
|
||||
f'"{argument}" is not a valid time delta.'
|
||||
) from e
|
||||
|
||||
raise exceptions.InvalidTimedeltaError(
|
||||
f'"{argument}" is not a valid time delta.'
|
||||
)
|
||||
|
||||
|
||||
class DiscordUser(converter.Converter):
|
||||
"""Converts a URL, ID, or reply into a Discord User ID."""
|
||||
|
||||
async def converter(self, ctx: commands.Context, argument: str) -> int:
|
||||
if argument.isdecimal() and len(argument) in range(17, 19):
|
||||
return int(argument)
|
||||
|
||||
if argument.startswith("https://discord.com/users/") or argument.startswith(
|
||||
"discord://-/users/"
|
||||
):
|
||||
return int(argument.split("?", 1)[0].rsplit("/", 1)[1])
|
||||
|
||||
if ctx.message.reference is not None:
|
||||
ref = ctx.message.reference
|
||||
|
||||
if ref.cached_message is not None:
|
||||
msg = ref.cached_message
|
||||
|
||||
if msg.author.bot:
|
||||
row = await models.Message.get_or_none(id=msg.id)
|
||||
|
||||
if row is not None:
|
||||
return row.user_id
|
||||
|
||||
raise ValueError()
|
||||
|
||||
return msg.author.id
|
||||
|
||||
row = await models.Message.get_or_none(id=ref.message_id)
|
||||
|
||||
if row is not None:
|
||||
return row.user_id
|
||||
|
||||
raise ValueError()
|
||||
|
||||
raise ValueError()
|
||||
@@ -0,0 +1,21 @@
|
||||
from discord.ext.commands import errors
|
||||
|
||||
|
||||
class ChannelDisabledError(errors.CheckFailure):
|
||||
"""Raised when the channel being run the command in is disabled."""
|
||||
|
||||
|
||||
class NotAModeratorError(errors.CheckFailure):
|
||||
"""Raised when a non-moderator tries to run a moderator-only command."""
|
||||
|
||||
|
||||
class InvalidTimedeltaError(Exception):
|
||||
"""Raised when the passed timedelta string cannot be parsed by the converter."""
|
||||
|
||||
|
||||
class BannedError(errors.CheckFailure):
|
||||
"""Raised when the user running the command is banned."""
|
||||
|
||||
|
||||
class NotOnCallError(errors.CheckFailure):
|
||||
"""Raised when the command is ran in a channel with no ongoing calls."""
|
||||
@@ -0,0 +1,46 @@
|
||||
def trim(text: str, length: int = 100) -> str:
|
||||
"""Trims a string if it's too long.
|
||||
|
||||
Args:
|
||||
text (str): The original text.
|
||||
length (int, optional): The max length of the string before trimming. Defaults to 100.
|
||||
|
||||
Returns:
|
||||
str: The trimmed (or not) text.
|
||||
"""
|
||||
|
||||
if len(text) > length:
|
||||
return text[:length] + "..."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def text_join(texts: list[str], join_word: str = "and") -> str:
|
||||
"""Joins a list with commas and the last item with an "and".
|
||||
|
||||
Args:
|
||||
texts (list[str]): The list of texts to concatenate.
|
||||
join_word (str): The word to use at the end of the joint.
|
||||
|
||||
Returns:
|
||||
str: The formatted concatenation.
|
||||
"""
|
||||
|
||||
if len(texts) > 1:
|
||||
return f"{", ".join(texts[:-1])} {join_word} {texts[-1]}"
|
||||
|
||||
return texts[0]
|
||||
|
||||
|
||||
def monospace_grid(values: list[str], space: int = 14) -> str:
|
||||
"""Creates a monospace grid
|
||||
|
||||
Args:
|
||||
values (list[str]): All the values that the list must contain
|
||||
space (int, optional): The width of each column. Defaults to 14.
|
||||
|
||||
Returns:
|
||||
str: The formatted grid.
|
||||
"""
|
||||
|
||||
return "```" + "".join([v + " " * (space - len(v)) for v in values]) + "```"
|
||||
@@ -0,0 +1,119 @@
|
||||
from typing import List, Dict
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.cogs import base
|
||||
from payphone.core import formatting, constants, typing_utils
|
||||
|
||||
|
||||
class HelpCommand(commands.HelpCommand):
|
||||
async def send_bot_help(
|
||||
self, mapping: Dict[base.Cog | None, List[commands.Command]]
|
||||
) -> None:
|
||||
bot_commands: List[commands.Command] = []
|
||||
|
||||
for cog, cog_commands in mapping.items():
|
||||
if cog is not None and not cog.show_in_help:
|
||||
continue
|
||||
|
||||
bot_commands += cog_commands
|
||||
|
||||
embed = discord.Embed(
|
||||
author=self.context.bot.embed_author,
|
||||
title="Payphone, call and connect with other people.",
|
||||
description=(
|
||||
"Here is the list of all available commands. You can see more details about a "
|
||||
f"specific command with `{self.context.clean_prefix}help <command>`."
|
||||
),
|
||||
thumbnail=self.context.bot.user.display_avatar.url,
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Commands",
|
||||
value=formatting.monospace_grid(
|
||||
[c.name for c in bot_commands], space=12
|
||||
),
|
||||
)
|
||||
],
|
||||
color=constants.EMBED_COLOR,
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"Use {self.context.clean_prefix}help <command> to get more information about a specific command."
|
||||
),
|
||||
)
|
||||
|
||||
await self.context.send(embed=embed)
|
||||
|
||||
async def send_command_help(self, command: commands.Command) -> None:
|
||||
command_name = (
|
||||
command.name
|
||||
if isinstance(command, discord.SlashCommand)
|
||||
else command.qualified_name
|
||||
)
|
||||
command_options = "".join(
|
||||
[f" <{option.name}>" for option in command.options]
|
||||
if isinstance(command, discord.SlashCommand)
|
||||
else [
|
||||
f" [<{name}>]"
|
||||
if typing_utils.is_optional(info.annotation)
|
||||
else f" <{name}>"
|
||||
for name, info in command.params.items()
|
||||
if name not in ["self", "ctx"]
|
||||
]
|
||||
)
|
||||
command_description = (
|
||||
command.description if command.description else command.help
|
||||
).replace("{prefix}", self.context.clean_prefix)
|
||||
|
||||
full_command_usage = (
|
||||
self.context.clean_prefix + command.qualified_name + command_options
|
||||
)
|
||||
reply_argument = (
|
||||
command.extras.get("reply_argument", None) if command.extras else None
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
author=discord.EmbedAuthor(
|
||||
name="Payphone", icon_url=self.context.bot.user.display_avatar.url
|
||||
),
|
||||
title=(
|
||||
command.extras["help_name"]
|
||||
if command.extras and "help_name" in command.extras
|
||||
else command_name.title()
|
||||
)
|
||||
+ " Command",
|
||||
description=command_description,
|
||||
thumbnail=self.context.bot.user.display_avatar.url,
|
||||
color=constants.EMBED_COLOR,
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Usage",
|
||||
value=f"{f"`┌─ [<{reply_argument}>]`\n" if reply_argument else ""}`{full_command_usage}`",
|
||||
)
|
||||
]
|
||||
+ (
|
||||
[
|
||||
discord.EmbedField(
|
||||
name="Aliases",
|
||||
value=", ".join(
|
||||
[
|
||||
f"`{self.context.clean_prefix}{alias}`"
|
||||
for alias in command.aliases
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
if len(command.aliases) > 0
|
||||
else []
|
||||
),
|
||||
footer=discord.EmbedFooter(
|
||||
f"Use {self.context.clean_prefix}help <command> to get more information about a specific command."
|
||||
),
|
||||
)
|
||||
|
||||
await self.context.send(embed=embed)
|
||||
|
||||
async def send_group_help(self, group):
|
||||
return await self.send_command_help(group)
|
||||
|
||||
async def send_error_message(self, error):
|
||||
await self.context.send(f"❌ {error}")
|
||||
@@ -0,0 +1,25 @@
|
||||
import typing
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
def is_optional(type_hint) -> bool:
|
||||
"""Checks if the type_hint is optional or not.
|
||||
|
||||
Args:
|
||||
type_hint (typing.Type): The type hint.
|
||||
|
||||
Returns:
|
||||
bool: Whether the type hint is optional or not.
|
||||
"""
|
||||
|
||||
origin = typing.get_origin(type_hint)
|
||||
|
||||
if origin is typing.Union:
|
||||
args = typing.get_args(type_hint)
|
||||
return type(None) in args
|
||||
|
||||
if isinstance(type_hint, commands.Greedy):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,104 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "call" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ended_at" TIMESTAMPTZ
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "server" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"prefix" VARCHAR(3) NOT NULL DEFAULT 'p.'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "channel" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"enabled" BOOL,
|
||||
"webhook_id" BIGINT,
|
||||
"webhook_url" TEXT,
|
||||
"call_id" INT REFERENCES "call" ("id") ON DELETE CASCADE,
|
||||
"server_id" BIGINT NOT NULL REFERENCES "server" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"username" VARCHAR(32) NOT NULL,
|
||||
"is_masked" BOOL NOT NULL DEFAULT False,
|
||||
"is_moderator" BOOL NOT NULL DEFAULT False
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "profile" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"avatar_url" TEXT NOT NULL,
|
||||
"name" VARCHAR(32) NOT NULL,
|
||||
"color" INT,
|
||||
"server_id" BIGINT REFERENCES "server" ("id") ON DELETE CASCADE,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_profile_user_id_903480" UNIQUE ("user_id", "server_id")
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "calluserchannelrelationship" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"is_masked" BOOL NOT NULL,
|
||||
"mask" BIGINT NOT NULL,
|
||||
"call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE,
|
||||
"channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||
"profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_callusercha_call_id_fad9b8" UNIQUE ("call_id", "user_id", "channel_id")
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "message" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"mask" BIGINT,
|
||||
"was_deleted" BOOL NOT NULL DEFAULT False,
|
||||
"propagation_id" BIGINT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE,
|
||||
"channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||
"profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE,
|
||||
"propagation_channel_id" BIGINT REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||
"replying_to_id" BIGINT REFERENCES "message" ("id") ON DELETE SET NULL,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_message_propaga_f0803c" ON "message" ("propagation_id");
|
||||
CREATE TABLE IF NOT EXISTS "messagecontent" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"content" VARCHAR(4096) NOT NULL,
|
||||
"was_propagated" BOOL NOT NULL DEFAULT True,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"message_id" BIGINT NOT NULL REFERENCES "message" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "report" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"reason" VARCHAR(500) NOT NULL,
|
||||
"priority" INT NOT NULL,
|
||||
"status" SMALLINT NOT NULL DEFAULT 2,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"reported_by_id" BIGINT REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
"call_id" INT NOT NULL UNIQUE REFERENCES "call" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON COLUMN "report"."status" IS 'ACCEPTED: 0\nDECLINED: 1\nON_REVIEW: 2';
|
||||
CREATE TABLE IF NOT EXISTS "ban" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"notification_message_id" BIGINT,
|
||||
"reason" VARCHAR(500) NOT NULL,
|
||||
"appeal" VARCHAR(500),
|
||||
"status" SMALLINT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ,
|
||||
"banned_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
"report_id" INT REFERENCES "report" ("id") ON DELETE SET NULL,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE NO ACTION
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_ban_notific_ea7b58" ON "ban" ("notification_message_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");
|
||||
COMMENT ON COLUMN "ban"."status" IS 'VALID: 0\nON_REVIEW: 1\nAPPEAL_ACCEPTED: 2\nAPPEAL_DECLINED: 3';
|
||||
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"version" VARCHAR(255) NOT NULL,
|
||||
"app" VARCHAR(100) NOT NULL,
|
||||
"content" JSONB NOT NULL
|
||||
);"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
"""
|
||||
+211
-34
@@ -4,23 +4,42 @@ import enum
|
||||
|
||||
from tortoise import models, fields
|
||||
|
||||
from discord import webhook
|
||||
|
||||
import aiohttp
|
||||
|
||||
from payphone.core import config, constants
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
id: int = fields.BigIntField(primary_key=True)
|
||||
username: str = fields.CharField(max_length=32)
|
||||
is_masked: bool = fields.BooleanField(default=False)
|
||||
is_moderator: bool = fields.BooleanField(default=False)
|
||||
profiles: fields.BackwardFKRelation["Profile"]
|
||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
||||
|
||||
|
||||
class Call(models.Model):
|
||||
started_at = fields.DatetimeField(auto_now_add=True)
|
||||
ended_at = fields.DatetimeField(null=True, default=None)
|
||||
|
||||
channels: fields.BackwardFKRelation["Channel"]
|
||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
||||
report: fields.BackwardOneToOneRelation["Report"]
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
||||
"models.User", related_name="profiles"
|
||||
"models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
server: fields.ForeignKeyRelation["Server"] = fields.ForeignKeyField(
|
||||
"models.Server", related_name="profiles"
|
||||
server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField(
|
||||
"models.Server",
|
||||
related_name="profiles",
|
||||
null=True,
|
||||
on_delete=fields.OnDelete.CASCADE,
|
||||
)
|
||||
image_url: str = fields.TextField()
|
||||
avatar_url: str = fields.TextField()
|
||||
name: str = fields.CharField(max_length=32)
|
||||
color: int | None = fields.IntField(null=True, default=None)
|
||||
|
||||
@@ -32,6 +51,7 @@ class Profile(models.Model):
|
||||
|
||||
class Server(models.Model):
|
||||
id: int = fields.BigIntField(primary_key=True)
|
||||
prefix: str = fields.CharField(max_length=3, default="p.")
|
||||
channels: fields.BackwardFKRelation["Channel"]
|
||||
profiles: fields.BackwardFKRelation["Profile"]
|
||||
|
||||
@@ -39,32 +59,105 @@ class Server(models.Model):
|
||||
class Channel(models.Model):
|
||||
id = fields.BigIntField(primary_key=True)
|
||||
server: fields.ForeignKeyRelation[Server] = fields.ForeignKeyField(
|
||||
"models.Server", related_name="channels"
|
||||
"models.Server", related_name="channels", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
is_nsfw = fields.BooleanField()
|
||||
call = fields.ForeignKeyField("models.Call", null=True, related_name="channels")
|
||||
webhook_id = fields.BigIntField(null=True)
|
||||
webhook_url = fields.TextField()
|
||||
call: fields.ForeignKeyNullableRelation["Call"] = fields.ForeignKeyField(
|
||||
"models.Call", null=True, related_name="channels"
|
||||
)
|
||||
enabled: bool | None = fields.BooleanField(null=True, default=None)
|
||||
webhook_id: int | None = fields.BigIntField(null=True)
|
||||
webhook_url: str | None = fields.TextField(null=True)
|
||||
|
||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
||||
messages: fields.BackwardFKRelation["Message"]
|
||||
propagated_messages: fields.BackwardFKRelation["Message"]
|
||||
|
||||
def webhook(self, session: aiohttp.ClientSession) -> webhook.Webhook | None:
|
||||
"""Initializes the channel's webhook instance.
|
||||
|
||||
class Call(models.Model):
|
||||
is_nsfw = fields.BooleanField()
|
||||
started_at = fields.DatetimeField(auto_now_add=True)
|
||||
ended_at = fields.DatetimeField(null=True, default=None)
|
||||
Args:
|
||||
session (aiohttp.ClientSession): An initialized aiohttp client session.
|
||||
|
||||
channels: fields.BackwardFKRelation["Channel"]
|
||||
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
|
||||
Returns:
|
||||
webhook.Webhook | None: The webhook, or None if not initialized.
|
||||
"""
|
||||
|
||||
if self.webhook_url is None:
|
||||
return None
|
||||
|
||||
return webhook.Webhook.from_url(
|
||||
self.webhook_url, session=session, bot_token=config.TOKEN
|
||||
)
|
||||
|
||||
class Meta:
|
||||
index = [("server_id", "enabled")]
|
||||
|
||||
|
||||
class CallUserChannelRelationship(models.Model):
|
||||
call = fields.ForeignKeyField("models.Call", related_name="call_rss")
|
||||
user = fields.ForeignKeyField("models.User", related_name="call_rss")
|
||||
profile = fields.ForeignKeyField("models.Profile", related_name="call_rss")
|
||||
channel = fields.ForeignKeyField("models.Channel", related_name="call_rss")
|
||||
is_masked = fields.BooleanField()
|
||||
mask = fields.BigIntField()
|
||||
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
|
||||
"models.Call", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
||||
"models.User", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField(
|
||||
"models.Profile", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField(
|
||||
"models.Channel", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
is_masked: bool = fields.BooleanField()
|
||||
mask: int = fields.BigIntField()
|
||||
|
||||
@property
|
||||
def mask_name(self) -> str:
|
||||
"""Returns the mask's name.
|
||||
|
||||
Returns:
|
||||
str: The mask's name.
|
||||
"""
|
||||
|
||||
return constants.Mask(self.mask).name.replace("_", " ").lower().title()
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Returns the displayed name.
|
||||
|
||||
Returns:
|
||||
str: The displayed name.
|
||||
"""
|
||||
|
||||
if self.is_masked:
|
||||
return self.mask_name
|
||||
|
||||
return self.profile.name
|
||||
|
||||
@property
|
||||
def display_avatar(self) -> str:
|
||||
"""Returns the displayed avatar URL.
|
||||
|
||||
Returns:
|
||||
str: The displayed avatar URL.
|
||||
"""
|
||||
|
||||
if self.is_masked:
|
||||
mask = constants.Mask(self.mask)
|
||||
return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png"
|
||||
|
||||
return self.profile.avatar_url
|
||||
|
||||
@property
|
||||
def mask_emoji(self) -> str:
|
||||
"""Returns the mask's emoji.
|
||||
|
||||
Returns:
|
||||
str: The mask's emoji (<:name:id>).
|
||||
"""
|
||||
|
||||
mask = constants.Mask(self.mask)
|
||||
mask_name = mask.name.lower().replace("_", "")
|
||||
|
||||
return f"<:{mask_name}:{self.mask}>"
|
||||
|
||||
class Meta:
|
||||
unique_together = [("call_id", "user_id", "channel_id")]
|
||||
@@ -72,19 +165,91 @@ class CallUserChannelRelationship(models.Model):
|
||||
|
||||
class Message(models.Model):
|
||||
id = fields.BigIntField(primary_key=True)
|
||||
channel = fields.ForeignKeyField("models.Channel")
|
||||
user = fields.ForeignKeyField("models.User")
|
||||
profile = fields.ForeignKeyField("models.Profile")
|
||||
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
|
||||
"models.Call", related_name="messages", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField(
|
||||
"models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
user = fields.ForeignKeyRelation("models.User", on_delete=fields.OnDelete.CASCADE)
|
||||
profile = fields.ForeignKeyField(
|
||||
"models.Profile", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField(
|
||||
"models.Message",
|
||||
related_name="replies",
|
||||
on_delete=fields.OnDelete.SET_NULL,
|
||||
null=True,
|
||||
default=None,
|
||||
)
|
||||
mask = fields.BigIntField(null=True)
|
||||
was_deleted = fields.BooleanField(default=False)
|
||||
was_propagated = fields.BooleanField(default=False)
|
||||
propagation_id = fields.BigIntField(null=True, db_index=True)
|
||||
propagation_channel: fields.ForeignKeyNullableRelation["Channel"] = (
|
||||
fields.ForeignKeyField(
|
||||
"models.Channel", related_name="propagated_messages", null=True
|
||||
)
|
||||
)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
contents: fields.BackwardFKRelation["MessageContent"]
|
||||
|
||||
@property
|
||||
def mask_name(self) -> str:
|
||||
"""Returns the mask's name.
|
||||
|
||||
Returns:
|
||||
str: The mask's name.
|
||||
"""
|
||||
|
||||
return constants.Mask(self.mask).name.replace("_", " ").lower().title()
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Returns the displayed name.
|
||||
|
||||
Returns:
|
||||
str: The displayed name.
|
||||
"""
|
||||
|
||||
if self.mask is not None:
|
||||
return self.mask_name
|
||||
|
||||
return self.profile.name
|
||||
|
||||
@property
|
||||
def display_avatar(self) -> str:
|
||||
"""Returns the displayed avatar URL.
|
||||
|
||||
Returns:
|
||||
str: The displayed avatar URL.
|
||||
"""
|
||||
|
||||
if self.mask is not None:
|
||||
mask = constants.Mask(self.mask)
|
||||
return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png"
|
||||
|
||||
return self.profile.avatar_url
|
||||
|
||||
@property
|
||||
def mask_emoji(self) -> str:
|
||||
"""Returns the mask's emoji.
|
||||
|
||||
Returns:
|
||||
str: The mask's emoji (<:name:id>).
|
||||
"""
|
||||
|
||||
mask = constants.Mask(self.mask)
|
||||
mask_name = mask.name.lower().replace("_", "")
|
||||
|
||||
return f"<:{mask_name}:{self.mask}>"
|
||||
|
||||
|
||||
class MessageContent(models.Model):
|
||||
message = fields.ForeignKeyField("models.Message", related_name="contents")
|
||||
message = fields.ForeignKeyField(
|
||||
"models.Message", related_name="contents", on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
content = fields.CharField(max_length=4096)
|
||||
was_propagated = fields.BooleanField()
|
||||
was_propagated = fields.BooleanField(default=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
|
||||
@@ -95,29 +260,41 @@ class ReportStatus(enum.IntEnum):
|
||||
|
||||
|
||||
class Report(models.Model):
|
||||
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField("models.Call")
|
||||
call: fields.OneToOneRelation["Call"] = fields.OneToOneField(
|
||||
"models.Call", related_name="report"
|
||||
)
|
||||
reported_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField(
|
||||
"users.User", null=True
|
||||
"models.User", null=True, on_delete=fields.OnDelete.CASCADE
|
||||
)
|
||||
reason: str = fields.CharField(max_length=500)
|
||||
priority: int = fields.IntField()
|
||||
status: ReportStatus = fields.IntEnumField(ReportStatus)
|
||||
status: ReportStatus = fields.IntEnumField(
|
||||
ReportStatus, default=ReportStatus.ON_REVIEW
|
||||
)
|
||||
created_at: datetime = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class BanStatus(enum.IntEnum):
|
||||
VALID = 0
|
||||
APPEALED = 1
|
||||
ON_REVIEW = 1
|
||||
APPEAL_ACCEPTED = 2
|
||||
APPEAL_DECLINED = 3
|
||||
|
||||
|
||||
class Ban(models.Model):
|
||||
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
||||
"models.User", related_name="bans"
|
||||
"models.User", related_name="bans", on_delete=fields.OnDelete.NO_ACTION
|
||||
)
|
||||
notification_message_id: int = fields.BigIntField(null=True)
|
||||
report: fields.ForeignKeyNullableRelation["Report"] = fields.ForeignKeyField(
|
||||
"models.Report",
|
||||
related_name="bans",
|
||||
null=True,
|
||||
on_delete=fields.OnDelete.SET_NULL,
|
||||
)
|
||||
notification_message_id: int = fields.BigIntField(null=True, db_index=True)
|
||||
reason: str = fields.CharField(max_length=500)
|
||||
appeal: str | None = fields.CharField(max_length=500, null=True)
|
||||
status: BanStatus = fields.IntEnumField(BanStatus)
|
||||
status: BanStatus = fields.IntEnumField(BanStatus, default=BanStatus.VALID)
|
||||
banned_by: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
|
||||
"models.User", related_name="bans_created"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import tortoise
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.core import config
|
||||
|
||||
|
||||
async def init() -> None:
|
||||
"""Initialize the database and it's connections.
|
||||
"""
|
||||
|
||||
await tortoise.Tortoise.init(config.TORTOISE_ORM, use_tz=True)
|
||||
|
||||
await tortoise.Tortoise.generate_schemas(safe=True)
|
||||
|
||||
await models.Call.filter(ended_at=None).update(ended_at=datetime.now(timezone.utc))
|
||||
await models.Channel.exclude(call_id=None).update(call_id=None)
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import Tuple
|
||||
|
||||
import discord
|
||||
|
||||
from payphone.db import models
|
||||
|
||||
|
||||
async def get_channel(channel: discord.TextChannel) -> models.Channel:
|
||||
"""Returns a channel from the database, creating or updating it if it already exists.
|
||||
|
||||
Args:
|
||||
channel (discord.TextChannel): The channel to fetch.
|
||||
|
||||
Returns:
|
||||
models.Channel: The channel row.
|
||||
"""
|
||||
|
||||
row, _ = await models.Channel.update_or_create(
|
||||
id=channel.id, server=channel.guild.id
|
||||
)
|
||||
|
||||
channel_name = channel.name.replace("discord", "d").replace("wumpus", "w")
|
||||
webhook_name = f"Payphone #{channel_name}"
|
||||
|
||||
if row.webhook_id is None:
|
||||
webhook = await channel.create_webhook(
|
||||
name=webhook_name, reason="First call started in channel."
|
||||
)
|
||||
|
||||
row.webhook_id = webhook.id
|
||||
row.webhook_url = webhook.url
|
||||
|
||||
await row.save(update_fields=["webhook_id", "webhook_url"])
|
||||
|
||||
else:
|
||||
found = False
|
||||
|
||||
for webhook in await channel.webhooks():
|
||||
if webhook.id != row.webhook_id:
|
||||
if webhook.token is None:
|
||||
await webhook.delete(reason="Webhook is unaccessible by Payphone.")
|
||||
break
|
||||
|
||||
else:
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
webhook = await channel.create_webhook(
|
||||
name=webhook_name, reason="Previous webhook was deleted."
|
||||
)
|
||||
|
||||
row.webhook_id = webhook.id
|
||||
row.webhook_url = webhook.url
|
||||
|
||||
await row.save(update_fields=["webhook_id", "webhook_url"])
|
||||
|
||||
return row
|
||||
|
||||
|
||||
async def get_user(
|
||||
user: discord.User | discord.Member,
|
||||
) -> Tuple[models.User, models.Profile | None]:
|
||||
"""Returns a user from the database, creating or updating it if it already exists.
|
||||
|
||||
Args:
|
||||
user (discord.User | discord.Member): The user or member to fetch.
|
||||
|
||||
Returns:
|
||||
Tuple[models.User, models.Profile]: The user and profile.
|
||||
"""
|
||||
|
||||
user_row, _ = await models.User.update_or_create(id=user.id, username=user.name)
|
||||
|
||||
# TODO: Make per-server profiles a premium feature.
|
||||
profile_row, _ = await models.Profile.update_or_create(
|
||||
user=user_row,
|
||||
server_id=None, # user.guild.id if isinstance(user, discord.Member) else None,
|
||||
avatar_url=user.display_avatar.url,
|
||||
name=user.display_name,
|
||||
)
|
||||
|
||||
return (user_row, profile_row)
|
||||
Reference in New Issue
Block a user