diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..826c3a7 --- /dev/null +++ b/compose.yml @@ -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: diff --git a/payphone/bot.py b/payphone/bot.py index cd0cf6d..2dc7568 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -1,6 +1,373 @@ -from discord.ext import bridge +from typing import Literal + +from datetime import datetime, timezone + +import discord +from discord import abc +from discord.ext import commands +from discord.ext.commands import errors + +from tortoise import expressions, transactions + +import redis.asyncio as aredis + +from payphone.db import setup, models +from payphone.core import config, constants, exceptions, formatting, help_command # TODO: Make it an AutoShardedBot on production. -class Payphone(bridge.Bot): - pass +class Payphone(commands.AutoShardedBot): + redis: aredis.Redis + + def __init__(self) -> None: + intents = discord.Intents.default() + intents.message_content = True + + super().__init__( + intents=intents, + command_prefix=self.get_prefix, + help_command=help_command.HelpCommand(), + ) + + async def on_ready(self) -> None: + """Initializes the bot. + + Initializes the bot's DB and connects to Redis, and then prints a ready message to the + console. + """ + + # Import from inside the function to prevent circular import errors. + from payphone.cogs.mods import views as mod_views + + await setup.init() + + self.redis = aredis.Redis(host="localhost" if config.IS_DEBUG else "redis") + await self.redis.ping() + + await self.redis.delete( + constants.CHANNELS_ONCALL_SET, constants.CHANNELS_SEARCH_QUEUE + ) + + self.add_view(mod_views.BanNotificationView(self, models.Ban())) + + print(f"Ready! Logged in as {self.user.display_name}.") + + async def on_shard_ready(self, shard_id: int) -> None: + """Sets the activity for the shard. + + Args: + shard_id (int): The ID of the initialized shard. + """ + + await self.change_presence( + activity=discord.CustomActivity( + name=f"Listening to p.help | Shard {shard_id + 1}" + ), + status=discord.Status.online, + shard_id=shard_id, + ) + + print(f"Shard is ready! Shard ID: {shard_id}") + + async def on_shard_resumed(self, shard_id: int) -> None: + """Sets the activity for the shard. + + Args: + shard_id (int): The ID of the resumed shard. + """ + + await self.change_presence( + activity=discord.CustomActivity( + name=f"Listening to p.help | Shard {shard_id + 1}" + ), + status=discord.Status.online, + shard_id=shard_id, + ) + + print(f"Shard was resumed! Shard ID: {shard_id}") + + async def get_or_fetch_channel( + self, channel_id: int + ) -> abc.GuildChannel | abc.PrivateChannel | None: + """Get's a channel from the bot's cache, or fetches it from Discord if it's not available. + + Args: + channel_id (int): The channel's ID. + + Returns: + (abc.GuildChannel | abc.PrivateChannel | None): The channel, or None if not found. + """ + + channel = self.get_channel(channel_id) + + if channel is None: + return await self.fetch_channel(channel_id) + + return channel + + async def is_banned( + self, user: discord.User | discord.Member | models.User | int + ) -> bool: + """Checks whether a user is banned. + + Checks whether a user is banned, and caches the result in Redis. The cache expires in 5 + minutes. + + Args: + user (discord.User | discord.Member | models.User | int): The user to check or their + ID. + + Returns: + bool: Whether the user is banned or not. + """ + + user_id = user if isinstance(user, int) else user.id + key = constants.BAN_TEMPLATE % user_id + + is_banned = await self.redis.get(key) + + if is_banned is not None: + return bool(int(is_banned)) + + is_banned = await models.Ban.filter( + expressions.Q(user_id=user_id) + & expressions.Q( + expires_at=None, + expires_at__lt=datetime.now(timezone.utc), + join_type="OR", + ) + ).exists() + + await self.redis.set(key, int(is_banned), ex=300) + + return is_banned + + @transactions.atomic() + async def ban( + self, + user: discord.User | discord.Member | models.User | int, + until: datetime | None, + reason: str, + moderator: discord.User | discord.Member | models.User | int | None = None, + ) -> models.Ban: + """Bans a user from Payphone. + + Args: + user (discord.User | discord.Member | models.User | int): The user to ban or their ID. + until (datetime | None): The expiry date, or None if it does not expire. + reason (str): The reason of the ban. + moderator (discord.User | discord.Member | models.User | int | None, optional): The + moderator who performed the action. + + Returns: + models.Ban: The newly created ban. + """ + + user_id = user if isinstance(user, int) else user.id + key = constants.BAN_TEMPLATE % user_id + + mod_id = ( + moderator.id + if isinstance(moderator, (discord.User, discord.Member, models.User)) + else moderator + ) + + row = await models.Ban.create( + user_id=user_id, + reason=reason, + expires_at=until, + banned_by_id=mod_id, + ) + + await self.redis.set(key, "1", ex=300) + + return row + + @transactions.atomic() + async def unban( + self, user: discord.User | discord.Member | models.User | int + ) -> None: + """Unbans a user from Payphone. + + Args: + user (discord.User | discord.Member | models.User | int): The user to unban or their + ID. + """ + + user_id = user if isinstance(user, int) else user.id + key = constants.BAN_TEMPLATE % user_id + + await models.Ban.filter( + expressions.Q( + expires_at=None, + expires_at__gt=datetime.now(timezone.utc), + join_type="OR", + ), + user_id=user_id, + ).update(expires_at=datetime.now(timezone.utc)) + + await self.redis.set(key, "0", ex=300) + + async def get_prefix(self, message: discord.Message) -> str | list[str]: + """Returns the prefix for the message. + + The prefix is case-insensitive, meaning that it'll adapt to the message to be valid not + depending of the case. In case that the prefix is invalid, the default or set prefix in its + original case will be returned. + + Args: + message (discord.Message): The message to check the prefix for. + + Returns: + str: The prefix for the message. + """ + + if message.guild is None: + return ["p.", "P."] + + server_id = message.guild.id + + key = constants.PREFIX_TEMPLATE % server_id + + prefix: bytes | None = await self.redis.get(key) + + if prefix is not None: + prefix = prefix.decode() + + else: + server, _ = await models.Server.get_or_create(id=server_id) + + await self.redis.set(key, server.prefix, ex=60 * 60) + + prefix = server.prefix + + if message.content.lower().startswith(prefix.lower()): + return message.content[: len(prefix)] + + return prefix + + async def set_prefix( + self, server: discord.Guild | models.Server | int, prefix: str = "p." + ) -> None: + """Update a server's prefix. + + Args: + server (discord.Guild | models.Server | int): The server to update or it's ID. + prefix (str, optional): The new prefix. It cannot be longer than 3 characters. Defaults + to "p.". + + Raises: + ValueError: The prefix provided is invalid. + """ + + if len(prefix) > 3 or len(prefix) == 0: + raise ValueError( + "The prefix must not be longer than 3 characters and must not be an empty string." + ) + + server_id = server if isinstance(server, int) else server.id + + await models.Server.filter(id=server_id).update(prefix=prefix) + + await self.redis.set(constants.PREFIX_TEMPLATE % server_id, prefix, ex=60 * 60) + + async def on_command_error( + self, context: commands.Context, exception: commands.CommandError + ) -> None: + """Handles each of the errors raised by commands. + + Args: + context (commands.Context): The context of the command. + exception (commands.CommandError): The exception raised. + """ + + if isinstance(exception, exceptions.ChannelDisabledError): + await context.send( + "❌ You cannot run commands in this channel.", delete_after=20.0 + ) + + elif isinstance(exception, exceptions.BannedError): + await context.send("❌ You have been banned from Payphone.") + + elif isinstance(exception, exceptions.NotOnCallError): + await context.send("❌ You need to be on a call to run this command.") + + elif isinstance(exception, exceptions.NotAModeratorError): + await context.send("❌ You're not a bot moderator.") + + elif isinstance(exception, errors.MissingRequiredArgument): + await context.send( + f"❌ `{exception.param.name}` is missing. Use `{context.clean_prefix}{context.command.name}` to learn how to use it." + ) + + elif isinstance(exception, errors.BotMissingPermissions): + await context.send( + f"❌ Payphone's missing {formatting.text_join([f"`{perm.replace("_", " ").title()}`" for perm in exception.missing_permissions])} permissions to run this command." + ) + + elif isinstance(exception, errors.NoPrivateMessage): + await context.send("❌ This command can only be run in a server.") + + elif isinstance(exception, errors.MissingPermissions): + await context.send("❌ You don't have permission to run this command.") + + elif isinstance(exception, errors.CommandOnCooldown): + await context.send( + f"❌ You're going too fast! Wait `{int(exception.retry_after)}` seconds before trying again." + ) + + elif isinstance(exception, errors.CommandNotFound): + return + + else: + return await super().on_command_error(context, exception) + + @property + def embed_author(self) -> discord.EmbedAuthor: + """The embed author for embeds sent by Payphone. + + Returns: + discord.EmbedAuthor: The embed author object. + """ + + return discord.EmbedAuthor( + name=self.user.display_name, icon_url=self.user.display_avatar.url + ) + + +bot = Payphone() + +bot.load_extension("payphone.cogs.admin.cog") +bot.load_extension("payphone.cogs.calls.cog") +bot.load_extension("payphone.cogs.mods.cog") +bot.load_extension("payphone.cogs.users.cog") +bot.load_extension("payphone.cogs.utility.cog") + + +@bot.check +async def is_channel_enabled(ctx: commands.Context) -> Literal[True]: + """Checks whether the current channel is enabled to run a command or not. + + Args: + ctx (commands.Context): The current context. + + Raises: + exceptions.ChannelDisabledError: The current channel is disabled. + """ + + if ctx.guild is None: + return True + + server_id = ctx.guild.id + channel_id = ctx.channel.id + + await models.Server.get_or_create(id=server_id) + channel, _ = await models.Channel.get_or_create(id=channel_id, server_id=server_id) + + if channel.enabled: + return True + + if await models.Channel.filter(server_id=server_id, enabled=True).exists(): + raise exceptions.ChannelDisabledError() + + return True diff --git a/payphone/cogs/admin/__init__.py b/payphone/cogs/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py new file mode 100644 index 0000000..ddd6b42 --- /dev/null +++ b/payphone/cogs/admin/cog.py @@ -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 ` 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: + - ``: 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)) diff --git a/payphone/cogs/base.py b/payphone/cogs/base.py new file mode 100644 index 0000000..3ae1872 --- /dev/null +++ b/payphone/cogs/base.py @@ -0,0 +1,7 @@ +from discord.ext import commands + + +class Cog(commands.Cog): + """Base cog with Payphone-specific properties.""" + + show_in_help: bool = True diff --git a/payphone/cogs/calls/__init__.py b/payphone/cogs/calls/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payphone/cogs/calls/cog.py b/payphone/cogs/calls/cog.py new file mode 100644 index 0000000..9141aa8 --- /dev/null +++ b/payphone/cogs/calls/cog.py @@ -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: + - `[]`: The ID of the call to report. + - ``: 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)) diff --git a/payphone/cogs/calls/embeds.py b/payphone/cogs/calls/embeds.py new file mode 100644 index 0000000..4c1d6b5 --- /dev/null +++ b/payphone/cogs/calls/embeds.py @@ -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. + """ diff --git a/payphone/cogs/mods/__init__.py b/payphone/cogs/mods/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payphone/cogs/mods/cog.py b/payphone/cogs/mods/cog.py new file mode 100644 index 0000000..6692f47 --- /dev/null +++ b/payphone/cogs/mods/cog.py @@ -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: + - ``: The user to ban or their ID. + - `[