feat(bot): Everything.

This commit is contained in:
Nyeki.py
2024-10-16 13:21:04 -03:00
parent 3059ec8446
commit 5011ab5f90
34 changed files with 3444 additions and 39 deletions
+23
View File
@@ -0,0 +1,23 @@
name: "payphone"
services:
database:
image: postgres:16.3
environment:
- POSTGRES_DB=payphone
- POSTGRES_USER=payphone
- POSTGRES_PASSWORD=payphone
volumes:
- database:/var/lib/postgresql/data
ports:
- 5432:5432
restart: on-failure
redis:
image: redis:7.4.1-alpine
ports:
- 6379:6379
restart: on-failure
volumes:
database:
+370 -3
View File
@@ -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. # TODO: Make it an AutoShardedBot on production.
class Payphone(bridge.Bot): class Payphone(commands.AutoShardedBot):
pass 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
View File
+61
View File
@@ -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))
+7
View File
@@ -0,0 +1,7 @@
from discord.ext import commands
class Cog(commands.Cog):
"""Base cog with Payphone-specific properties."""
show_in_help: bool = True
View File
+798
View File
@@ -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))
+12
View File
@@ -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.
"""
View File
+210
View File
@@ -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))
+46
View File
@@ -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,
)
+45
View File
@@ -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),
)
+47
View File
@@ -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))
View File
+255
View File
@@ -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))
View File
+40
View File
@@ -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))
View File
+439
View File
@@ -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
+51
View File
@@ -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)
+27
View File
@@ -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")
+179
View File
@@ -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"
+76
View File
@@ -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()
+21
View File
@@ -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."""
+46
View File
@@ -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]) + "```"
+119
View File
@@ -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}")
+25
View File
@@ -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
View File
@@ -4,23 +4,42 @@ import enum
from tortoise import models, fields from tortoise import models, fields
from discord import webhook
import aiohttp
from payphone.core import config, constants
class User(models.Model): class User(models.Model):
id: int = fields.BigIntField(primary_key=True) id: int = fields.BigIntField(primary_key=True)
username: str = fields.CharField(max_length=32) username: str = fields.CharField(max_length=32)
is_masked: bool = fields.BooleanField(default=False) is_masked: bool = fields.BooleanField(default=False)
is_moderator: bool = fields.BooleanField(default=False)
profiles: fields.BackwardFKRelation["Profile"] profiles: fields.BackwardFKRelation["Profile"]
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] 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): class Profile(models.Model):
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( 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( server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField(
"models.Server", related_name="profiles" "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) name: str = fields.CharField(max_length=32)
color: int | None = fields.IntField(null=True, default=None) color: int | None = fields.IntField(null=True, default=None)
@@ -32,6 +51,7 @@ class Profile(models.Model):
class Server(models.Model): class Server(models.Model):
id: int = fields.BigIntField(primary_key=True) id: int = fields.BigIntField(primary_key=True)
prefix: str = fields.CharField(max_length=3, default="p.")
channels: fields.BackwardFKRelation["Channel"] channels: fields.BackwardFKRelation["Channel"]
profiles: fields.BackwardFKRelation["Profile"] profiles: fields.BackwardFKRelation["Profile"]
@@ -39,32 +59,105 @@ class Server(models.Model):
class Channel(models.Model): class Channel(models.Model):
id = fields.BigIntField(primary_key=True) id = fields.BigIntField(primary_key=True)
server: fields.ForeignKeyRelation[Server] = fields.ForeignKeyField( 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.ForeignKeyNullableRelation["Call"] = fields.ForeignKeyField(
call = fields.ForeignKeyField("models.Call", null=True, related_name="channels") "models.Call", null=True, related_name="channels"
webhook_id = fields.BigIntField(null=True) )
webhook_url = fields.TextField() 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"] 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): Args:
is_nsfw = fields.BooleanField() session (aiohttp.ClientSession): An initialized aiohttp client session.
started_at = fields.DatetimeField(auto_now_add=True)
ended_at = fields.DatetimeField(null=True, default=None)
channels: fields.BackwardFKRelation["Channel"] Returns:
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] 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): class CallUserChannelRelationship(models.Model):
call = fields.ForeignKeyField("models.Call", related_name="call_rss") call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
user = fields.ForeignKeyField("models.User", related_name="call_rss") "models.Call", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
profile = fields.ForeignKeyField("models.Profile", related_name="call_rss") )
channel = fields.ForeignKeyField("models.Channel", related_name="call_rss") user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
is_masked = fields.BooleanField() "models.User", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
mask = fields.BigIntField() )
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: class Meta:
unique_together = [("call_id", "user_id", "channel_id")] unique_together = [("call_id", "user_id", "channel_id")]
@@ -72,19 +165,91 @@ class CallUserChannelRelationship(models.Model):
class Message(models.Model): class Message(models.Model):
id = fields.BigIntField(primary_key=True) id = fields.BigIntField(primary_key=True)
channel = fields.ForeignKeyField("models.Channel") call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
user = fields.ForeignKeyField("models.User") "models.Call", related_name="messages", on_delete=fields.OnDelete.CASCADE
profile = fields.ForeignKeyField("models.Profile") )
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) mask = fields.BigIntField(null=True)
was_deleted = fields.BooleanField(default=False) 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) 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): 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) content = fields.CharField(max_length=4096)
was_propagated = fields.BooleanField() was_propagated = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
@@ -95,29 +260,41 @@ class ReportStatus(enum.IntEnum):
class Report(models.Model): 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( 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) reason: str = fields.CharField(max_length=500)
priority: int = fields.IntField() 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) created_at: datetime = fields.DatetimeField(auto_now_add=True)
class BanStatus(enum.IntEnum): class BanStatus(enum.IntEnum):
VALID = 0 VALID = 0
APPEALED = 1 ON_REVIEW = 1
APPEAL_ACCEPTED = 2
APPEAL_DECLINED = 3
class Ban(models.Model): class Ban(models.Model):
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( 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) reason: str = fields.CharField(max_length=500)
appeal: str | None = fields.CharField(max_length=500, null=True) 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( banned_by: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
"models.User", related_name="bans_created" "models.User", related_name="bans_created"
) )
+18
View File
@@ -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)
+82
View File
@@ -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)
+17 -1
View File
@@ -1,10 +1,26 @@
[project] [project]
name = "payphone-1" name = "payphone"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aerich==0.7.1",
"aiohttp>=3.10.10",
"py-cord>=2.6.1", "py-cord>=2.6.1",
"python-dotenv>=1.0.1",
"redis[hiredis]>=5.1.1",
"tortoise-orm[asyncpg]>=0.21.7", "tortoise-orm[asyncpg]>=0.21.7",
] ]
[tool]
[tool.aerich]
tortoise_orm = "payphone.core.config.TORTOISE_ORM"
location = "./payphone/db/migrations"
src_folder = "."
+5
View File
@@ -0,0 +1,5 @@
from payphone import bot
from payphone.core import config
bot.bot.run(config.TOKEN)
Generated
+110 -1
View File
@@ -5,6 +5,22 @@ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
] ]
[[package]]
name = "aerich"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "dictdiffer" },
{ name = "pydantic" },
{ name = "tomlkit" },
{ name = "tortoise-orm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/e0/865a5229cb804b9fa68e095c827dcd1c68d2a35413e813a89a8f0693a760/aerich-0.7.1.tar.gz", hash = "sha256:501f6598ae51e3ce19b1bfc21ff365f582e7447e6aa5215629451c40341d6eb8", size = 27597 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/80/23b894e48f2bb068ab21288ff2eee2737814488e54d0673013d563112efb/aerich-0.7.1-py3-none-any.whl", hash = "sha256:ca1c46e777c448c8344961bb591ee8cc7bfcb2fc7527bea4da43647b5fbf8fbe", size = 37251 },
]
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
version = "2.4.3" version = "2.4.3"
@@ -118,6 +134,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 },
] ]
[[package]]
name = "click"
version = "8.1.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "dictdiffer"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/7b/35cbccb7effc5d7e40f4c55e2b79399e1853041997fcda15c9ff160abba0/dictdiffer-0.9.0.tar.gz", hash = "sha256:17bacf5fbfe613ccf1b6d512bd766e6b21fb798822a133aa86098b8ac9997578", size = 31513 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754 },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.4.1" version = "1.4.1"
@@ -142,6 +188,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/10/466fe96dae1bff622021ee687f68e5524d6392b0a2f80d05001cd3a451ba/frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", size = 11552 }, { url = "https://files.pythonhosted.org/packages/83/10/466fe96dae1bff622021ee687f68e5524d6392b0a2f80d05001cd3a451ba/frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", size = 11552 },
] ]
[[package]]
name = "hiredis"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/80/740fb0dfa7a42416ce8376490f41dcdb1e5deed9c3739dfe4200fad865a9/hiredis-3.0.0.tar.gz", hash = "sha256:fed8581ae26345dea1f1e0d1a96e05041a727a45e7d8d459164583e23c6ac441", size = 87581 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/09/0a3eace00115d8c82a8e7d8e58e60aacec10334f4f1512f09ffbac3252e3/hiredis-3.0.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:484025d2eb8f6348f7876fc5a2ee742f568915039fcb31b478fd5c242bb0fe3a", size = 81540 },
{ url = "https://files.pythonhosted.org/packages/1c/e8/1a7a5ded4fb11e91aafc5ba5518392f22883d54e79c4b47f188fb712ea46/hiredis-3.0.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:fcdb552ffd97151dab8e7bc3ab556dfa1512556b48a367db94b5c20253a35ee1", size = 44814 },
{ url = "https://files.pythonhosted.org/packages/3b/f5/4e055dc9b55484644afb18063f28649cdbd19be4f15bc152bd633dccd6f7/hiredis-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bb6f9fd92f147ba11d338ef5c68af4fd2908739c09e51f186e1d90958c68cc1", size = 42478 },
{ url = "https://files.pythonhosted.org/packages/65/7b/e06f55b9dcdf10cb6b3f08d7917d3080096cd83deaef1bd4927720fbb280/hiredis-3.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa86bf9a0ed339ec9e8a9a9d0ae4dccd8671625c83f9f9f2640729b15e07fbfd", size = 168303 },
{ url = "https://files.pythonhosted.org/packages/f4/16/081e90137bb896acd9dc2e1e68480cc84d652af4d959e75e52d6ce9dd602/hiredis-3.0.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e194a0d5df9456995d8f510eab9f529213e7326af6b94770abf8f8b7952ddcaa", size = 179151 },
{ url = "https://files.pythonhosted.org/packages/1e/0f/f5aba1c82977f4b639e5b450c0d8685333f1200cd1972647eb3f4d972e55/hiredis-3.0.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a1df39d74ec507d79c7a82c8063eee60bf80537cdeee652f576059b9cdd15c", size = 168580 },
{ url = "https://files.pythonhosted.org/packages/60/86/aa24c20f6d3038bf244bc60a2fe8cde61fb3c0d6a82e2bed30b08d55f96c/hiredis-3.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f91456507427ba36fd81b2ca11053a8e112c775325acc74e993201ea912d63e9", size = 169147 },
{ url = "https://files.pythonhosted.org/packages/6e/03/a4c7a28b6320ef3e36062c1c51e9d66e889c9e09ee7d7ae38b8a2ffdb365/hiredis-3.0.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9862db92ef67a8a02e0d5370f07d380e14577ecb281b79720e0d7a89aedb9ee5", size = 164722 },
{ url = "https://files.pythonhosted.org/packages/cd/66/d60106b56ba0ddd9789656d204a577591ff0cd91ab94178bb96c84d0d918/hiredis-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d10fcd9e0eeab835f492832b2a6edb5940e2f1230155f33006a8dfd3bd2c94e4", size = 162561 },
{ url = "https://files.pythonhosted.org/packages/6a/30/f33f2b782096efe9fe6b24c67a4df13b5055d9c859f615a74fb4f18cce41/hiredis-3.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:48727d7d405d03977d01885f317328dc21d639096308de126c2c4e9950cbd3c9", size = 161388 },
{ url = "https://files.pythonhosted.org/packages/45/02/34d9b151f9ea4655bfe00e0230f7db8fd8a52c7b7bd728efdf1c17655860/hiredis-3.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e0bb6102ebe2efecf8a3292c6660a0e6fac98176af6de67f020bea1c2343717", size = 173561 },
{ url = "https://files.pythonhosted.org/packages/cf/54/68285d208918b6d83e32d872d8dcbf8d479ed2c74b863b836e48a2702a3f/hiredis-3.0.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:df274e3abb4df40f4c7274dd3e587dfbb25691826c948bc98d5fead019dfb001", size = 165914 },
{ url = "https://files.pythonhosted.org/packages/56/4f/5f36865f9f032caf00d603ff9cbde21506d2b1e0e0ce0b5d2ce2851411c9/hiredis-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:034925b5fb514f7b11aac38cd55b3fd7e9d3af23bd6497f3f20aa5b8ba58e232", size = 163968 },
{ url = "https://files.pythonhosted.org/packages/d3/ee/c38693bd1dbce34806ecc3536dc425e87e420030de7018194865511860c2/hiredis-3.0.0-cp312-cp312-win32.whl", hash = "sha256:120f2dda469b28d12ccff7c2230225162e174657b49cf4cd119db525414ae281", size = 20189 },
{ url = "https://files.pythonhosted.org/packages/4e/67/f50b45071bb8652fa9a28a84ee470a02042fb7a096a16f3c08842f2a5c2b/hiredis-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e584fe5f4e6681d8762982be055f1534e0170f6308a7a90f58d737bab12ff6a8", size = 21971 },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.10" version = "3.10"
@@ -200,17 +269,25 @@ wheels = [
] ]
[[package]] [[package]]
name = "payphone-1" name = "payphone"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aerich" },
{ name = "aiohttp" },
{ name = "py-cord" }, { name = "py-cord" },
{ name = "python-dotenv" },
{ name = "redis", extra = ["hiredis"] },
{ name = "tortoise-orm", extra = ["asyncpg"] }, { name = "tortoise-orm", extra = ["asyncpg"] },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aerich", specifier = "==0.7.1" },
{ name = "aiohttp", specifier = ">=3.10.10" },
{ name = "py-cord", specifier = ">=2.6.1" }, { name = "py-cord", specifier = ">=2.6.1" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.1.1" },
{ name = "tortoise-orm", extras = ["asyncpg"], specifier = ">=0.21.7" }, { name = "tortoise-orm", extras = ["asyncpg"], specifier = ">=0.21.7" },
] ]
@@ -325,6 +402,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/49/61d69d18b90b5fc43d63d0aaa14851356fcce7e89e1b2186d6c70db96682/pypika_tortoise-0.2.1-py3-none-any.whl", hash = "sha256:e91a1c5a78c6753ead1a9ba1aa169a1f1282c5035170e5462f0073564bc18886", size = 48314 }, { url = "https://files.pythonhosted.org/packages/ec/49/61d69d18b90b5fc43d63d0aaa14851356fcce7e89e1b2186d6c70db96682/pypika_tortoise-0.2.1-py3-none-any.whl", hash = "sha256:e91a1c5a78c6753ead1a9ba1aa169a1f1282c5035170e5462f0073564bc18886", size = 48314 },
] ]
[[package]]
name = "python-dotenv"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
]
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2024.2" version = "2024.2"
@@ -334,6 +420,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 },
] ]
[[package]]
name = "redis"
version = "5.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/58/dcf97c3c09d429c3bb830d6075322256da3dba42df25359bd1c82b442d20/redis-5.1.1.tar.gz", hash = "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72", size = 4607302 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/f1/feeeaaaac0f589bcbc12c02da357cf635ee383c9128b77230a1e99118885/redis-5.1.1-py3-none-any.whl", hash = "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24", size = 261283 },
]
[package.optional-dependencies]
hiredis = [
{ name = "hiredis" },
]
[[package]]
name = "tomlkit"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
]
[[package]] [[package]]
name = "tortoise-orm" name = "tortoise-orm"
version = "0.21.7" version = "0.21.7"