diff --git a/.dockerignore b/.dockerignore index 75fa7bf..f7fae1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -161,4 +161,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -k8s/ \ No newline at end of file +k8s/ +.vscode/ +.git/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 51f7c52..efa407c 100644 --- a/.gitignore +++ b/.gitignore @@ -123,7 +123,6 @@ celerybeat.pid # Environments .env -.env.shared .venv env/ venv/ @@ -160,4 +159,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +#.idea/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 74bb012..610c3a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,4 @@ { - "conventionalCommits.scopes": ["bot", "db", "tasks", "scripts"], - "yaml.schemas": { - "kubernetes://schema/apps/v1%40deployment": "file:///c%3A/Users/rafab/Documents/Repos/Payphone-1/k8s.yml" - } + "pylint.args": ["--extension-pkg-allow-list=orjson"], + "conventionalCommits.scopes": ["shard"] } diff --git a/assets/avatar.png b/assets/avatar.png deleted file mode 100644 index 2ce5967..0000000 Binary files a/assets/avatar.png and /dev/null differ diff --git a/assets/banner.png b/assets/banner.png deleted file mode 100644 index 2a91047..0000000 Binary files a/assets/banner.png and /dev/null differ diff --git a/bot.py b/bot.py index 74d16b1..ca1460b 100644 --- a/bot.py +++ b/bot.py @@ -1,29 +1,8 @@ -import os - from payphone import bot -from payphone.db import setup -from payphone.core import config, logging - - -bot = bot.bot - - -bot.shard_ids = [config.SHARD_ID] -bot.shard_count = config.SHARD_COUNT +from payphone.core import logging logging.setup("Payphone") -async def init() -> None: - """Initializes the DB.""" - - if config.SHARD_ID == 0: - os.system("aerich upgrade") - - await setup.init() - - -bot.loop.run_until_complete(init()) - -bot.run(config.TOKEN) +bot.run() diff --git a/censored.txt b/censored.txt new file mode 100644 index 0000000..d7a22ea --- /dev/null +++ b/censored.txt @@ -0,0 +1,471 @@ +2g1c +2 girls 1 cup +acrotomophilia +alabama hot pocket +alaskan pipeline +anal +anilingus +anus +apeshit +arsehole +asshole +assmunch +auto erotic +autoerotic +babeland +baby batter +baby juice +ball gag +ball gravy +ball kicking +ball licking +ball sack +ball sucking +bangbros +bangbus +bareback +barely legal +barenaked +bastard +bastardo +bastinado +bbw +bdsm +beaner +beaners +beaver cleaver +beaver lips +beastiality +bestiality +big black +big breasts +big knockers +big tits +bimbos +birdlock +bitch +bitches +black cock +blonde action +blonde on blonde action +blowjob +blow job +blow your load +blue waffle +blumpkin +bollocks +bondage +boner +boob +boobs +booty call +brown showers +brunette action +bukkake +bulldyke +bullet vibe +bullshit +bung hole +bunghole +busty +butt +buttcheeks +butthole +camel toe +camgirl +camslut +camwhore +carpet muncher +carpetmuncher +chocolate rosebuds +cialis +circlejerk +cleveland steamer +clit +clitoris +clover clamps +clusterfuck +cock +cocks +coprolagnia +coprophilia +cornhole +coon +coons +creampie +cum +cumming +cumshot +cumshots +cunnilingus +cunt +darkie +date rape +daterape +deep throat +deepthroat +dendrophilia +dick +dildo +dingleberry +dingleberries +dirty pillows +dirty sanchez +doggie style +doggiestyle +doggy style +doggystyle +dog style +dolcett +domination +dominatrix +dommes +donkey punch +double dong +double penetration +dp action +dry hump +dvda +eat my ass +ecchi +ejaculation +erotic +erotism +escort +eunuch +fag +faggot +fecal +felch +fellatio +feltch +female squirting +femdom +figging +fingerbang +fingering +fisting +foot fetish +footjob +frotting +fuck buttons +fucktards +fudge packer +fudgepacker +futanari +gangbang +gang bang +gay sex +genitals +giant cock +girl on +girl on top +girls gone wild +goatcx +goatse +god damn +gokkun +golden shower +goodpoop +goo girl +goregasm +grope +group sex +g-spot +guro +hand job +handjob +hard core +hardcore +hentai +homoerotic +honkey +hooker +horny +hot carl +hot chick +how to kill +how to murder +huge fat +humping +incest +intercourse +jack off +jail bait +jailbait +jelly donut +jerk off +jigaboo +jiggaboo +jiggerboo +jizz +juggs +kike +kinbaku +kinkster +kinky +knobbing +leather restraint +leather straight jacket +lemon party +livesex +lolita +lovemaking +make me come +male squirting +masturbate +masturbating +masturbation +menage a trois +milf +missionary position +mong +motherfucker +mound of venus +mr hands +muff diver +muffdiving +nambla +nawashi +negro +neonazi +nigga +nigger +nig nog +nimphomania +nipple +nipples +nsfw images +nude +nudity +nutten +nympho +nymphomania +octopussy +omorashi +one cup two girls +one guy one jar +orgasm +orgy +paedophile +paki +panties +panty +pedobear +pedophile +pegging +penis +phone sex +piece of shit +pikey +pissing +piss pig +pisspig +playboy +pleasure chest +pole smoker +ponyplay +poof +poon +poontang +punany +poop chute +poopchute +porn +porno +pornography +prince albert piercing +pthc +pubes +pussy +queaf +queef +quim +raghead +raging boner +rape +raping +rapist +rectum +reverse cowgirl +rimjob +rimming +rosy palm +rosy palm and her 5 sisters +rusty trombone +sadism +santorum +scat +schlong +scissoring +semen +sex +sexcam +sexo +sexy +sexual +sexually +sexuality +shaved beaver +shaved pussy +shemale +shibari +shitblimp +shota +shrimping +skeet +slanteye +slut +s&m +smut +snatch +snowballing +sodomize +sodomy +spastic +spic +splooge +splooge moose +spooge +spread legs +spunk +strap on +strapon +strappado +strip club +style doggy +suicide girls +sultry women +swastika +swinger +tainted love +taste my +tea bagging +threesome +throating +thumbzilla +tied up +tight white +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 +tit +tits +titties +titty +tongue in a +topless +tosser +towelhead +tranny +tribadism +tub girl +tubgirl +tushy +twat +twink +twinkle +two girls one cup +undressing +upskirt +urethra play +urophilia +vagina +venus mound +viagra +vibrator +violet wand +vorarephilia +voyeur +voyeurweb +voyuer +vulva +wank +wetback +wet dream +white powder +whore +worldsex +wrapping men +wrinkled starfish +xxx +yellow showers +yiffy +zoophilia +🖕 +honky +kike +weed +cocaine +heroin +meth +ecstasy +lsd +marijuana +opioid +self-harm +self harm +self-injury +self injury +self-mutilation +self mutilation +hurting myself +abortion +rape +molest +pedophile +incest +abuse +trafficking +domestic violence +child abuse +neglect +victim +heathen +infidel +heretic +blasphemer +pagan +zealot +cultist +fanatic +apostate +idolater +fatty +lardass +twig +beanpole +chubby +porker +skeletor +chunk +blubber +niga +niger +nga \ No newline at end of file diff --git a/dev/compose.yml b/dev-compose.yml similarity index 100% rename from dev/compose.yml rename to dev-compose.yml diff --git a/k8s/config.yml b/k8s/config.yml index 7e565c5..262f725 100644 --- a/k8s/config.yml +++ b/k8s/config.yml @@ -3,6 +3,4 @@ kind: ConfigMap metadata: name: bot-config data: - SUGGESTIONS_CHANNEL_ID: "1287841493343801447" - SERVER_INVITE: "https://discord.gg/Cb3rCrTxmm" IS_DEBUG: "false" diff --git a/k8s/tasks.yml b/k8s/tasks.yml index fd106ce..3ed268c 100644 --- a/k8s/tasks.yml +++ b/k8s/tasks.yml @@ -4,9 +4,9 @@ metadata: name: tasks spec: containers: - - name: flush + - name: connect image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "flush"] + command: ["uv", "run", "--no-dev", "tasks.py", "connect"] resources: requests: memory: "250Mi" @@ -14,22 +14,9 @@ spec: limits: memory: "1Gi" cpu: "1" - - name: delete-accounts + - name: expire image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "delete-accounts"] - resources: - requests: - memory: "250Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "1" - - name: hangup-inactive-calls - image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "hangup-inactive-calls"] - envFrom: - - secretRef: - name: bot-secrets + command: ["uv", "run", "--no-dev", "tasks.py", "expire"] resources: requests: memory: "250Mi" diff --git a/payphone/cogs/users/__init__.py b/payphone/__init__.py similarity index 100% rename from payphone/cogs/users/__init__.py rename to payphone/__init__.py diff --git a/payphone/bot.py b/payphone/bot.py index b12f4a2..d0d2b7a 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -1,54 +1,52 @@ from typing import Literal -from datetime import datetime, timezone, timedelta - -import sys +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, exceptions as tortoise_exc +from tortoise import expressions -from redis import Redis +import redis.asyncio as redis -import asyncpg.exceptions - -from payphone.db import models -from payphone.core import ( - constants, - exceptions, - formatting, - help_command, - config, - logging, -) +from payphone.db import setup, models +from payphone.core import constants, logging, exceptions, formatting, help_command class Payphone(commands.AutoShardedBot): log = logging.logger - redis: Redis = Redis( - url="redis://localhost" if config.IS_DEBUG else "redis://redis", + redis = redis.Redis( + host=constants.REDIS_HOST, port=constants.REDIS_PORT, decode_responses=True ) - def __init__(self) -> None: + def __init__(self): intents = discord.Intents.default() - intents.message_content = True intents.guilds = True + intents.guild_messages = True + intents.guild_reactions = True + intents.message_content = True - if config.IS_DEBUG: - intents.members = True + self.shard_ids = [constants.SHARD_ID] + self.shard_count = constants.SHARD_COUNT super().__init__( intents=intents, - command_prefix=self.get_prefix, - help_command=help_command.HelpCommand(), + command_prefix="p.", strip_after_prefix=True, case_insensitive=True, + help_command=help_command.HelpCommand(), ) + for cog in ["calls", "admin", "mods"]: + self.load_extension(f"payphone.cogs.{cog}.cog") + self.log.info("Loaded cog %s.", cog) + + from payphone.core import calls + + self.call_handler = calls.CallHandler(self) + async def on_ready(self) -> None: """Runs when the bot has finished initializing.""" @@ -69,7 +67,7 @@ class Payphone(commands.AutoShardedBot): shard_id=shard_id, ) - async def on_shard_connect(self, shard_id: int) -> None: + async def on_shard_connect(self, _shard_id: int) -> None: """Runs when the shard connects. Args: @@ -102,14 +100,15 @@ class Payphone(commands.AutoShardedBot): async def get_or_fetch_channel( self, channel_id: int - ) -> abc.GuildChannel | abc.PrivateChannel | discord.Thread | None: + ) -> discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread | 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 | discord.Thread | None): The channel, or None if not found. + (abc.GuildChannel | abc.PrivateChannel | discord.Thread | None): The channel, or None + if not found. """ channel = self.get_channel(channel_id) @@ -119,142 +118,6 @@ class Payphone(commands.AutoShardedBot): 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__gt=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, - duration: timedelta | None, - reason: str, - moderator: discord.User | discord.Member | models.User | int | None = None, - report_id: 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. - duration (timedelta | 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. - report_id (int | None, optional): The ID of the report the ban is related to. - - 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 - ) - - until = datetime.now(timezone.utc) + duration if duration is not None else None - - if duration is not None: - last_ban = ( - await models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ), - user_id=user_id, - ) - .order_by("-created_at") - .limit(1) - .first() - ) - - if last_ban is not None: - until = ( - duration + last_ban.expires_at - if last_ban.expires_at is not None - else None - ) - - row = await models.Ban.create( - user_id=user_id, - reason=reason, - expires_at=until, - banned_by_id=mod_id, - report_id=report_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, - unbanned_by: discord.User | discord.Member | models.User | int | None = None, - ) -> None: - """Unbans a user from Payphone. - - Args: - user (discord.User | discord.Member | models.User | int): The user to unban or their - ID. - unbanned_by (discord.User | discord.Member | models.User | int | None, optional): The - moderator who unbanned the user or their ID. - """ - - user_id = user if isinstance(user, int) else user.id - unbanned_by_id = unbanned_by if isinstance(unbanned_by, int) else unbanned_by.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), unbanned_by_id=unbanned_by_id) - - await self.redis.set(key, "0", ex=300) - async def get_prefix( self, message: discord.Message | None = None, server_id: int | None = None ) -> str | list[str]: @@ -278,7 +141,7 @@ class Payphone(commands.AutoShardedBot): ValueError: Either `message` or `server_id` must be set, but not both. """ - default_prefixes = ["p.", "P.", self.user.mention] + default_prefixes = ["p.", "P.", self.user.mention, f"<@!{self.user.id}>"] if message is not None and server_id is not None: raise ValueError( @@ -303,7 +166,7 @@ class Payphone(commands.AutoShardedBot): server_id = server_id if server_id is not None else message.guild.id - key = constants.PREFIX_TEMPLATE % server_id + key = f"prefix:{server_id}" prefix: str | None = await self.redis.get(key) @@ -326,30 +189,126 @@ class Payphone(commands.AutoShardedBot): return prefix - async def set_prefix( - self, server: discord.Guild | models.Server | int, prefix: str = "p." - ) -> None: - """Update a server's prefix. + def clean_message(self, message: discord.Message, call) -> str: + """Cleans a message. 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 5 characters. Defaults - to "p.". + message (discord.Message): The message to clean. + call (Call): The call the message is in. - Raises: - ValueError: The prefix provided is invalid. + Returns: + (str): The cleaned message. """ - if len(prefix) > 5 or len(prefix) == 0: - raise ValueError( - "The prefix must not be longer than 5 characters and must not be an empty string." + content = message.content + + content = content.replace( + "@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone" + ).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here") + + for mention in message.mentions + message.role_mentions: + content = content.replace( + f"<@{mention.id}>", f"@**{mention.display_name}**" ) - server_id = server if isinstance(server, int) else server.id + for mention in message.channel_mentions: + content = content.replace( + f"<@{mention.id}>", f"#**{mention.display_name}**" + ) - await models.Server.filter(id=server_id).update(prefix=prefix) + return content - await self.redis.set(constants.PREFIX_TEMPLATE % server_id, prefix, ex=60 * 60) + async def is_moderator(self, user_id: int) -> bool: + """Checks whether a user is a moderator. + + Args: + user_id (int): The user to check's ID. + + Returns: + (bool): Whether the user is a moderator. + """ + + user = await models.User.get_cache_or_db(self.redis, user_id) + + return user.is_moderator + + async def is_banned(self, user_id: 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_id (int): The user to check's ID. + + Returns: + bool: Whether the user is banned or not. + """ + + key = f"banned:{user_id}" + + is_banned = await self.redis.get(key) + + if is_banned is not None: + return bool(int(is_banned)) + + ban = await models.Ban.filter( + expressions.Q(user_id=user_id), + expressions.Q( + expires_at=None, + expires_at__gt=datetime.now(timezone.utc), + join_type="OR", + ), + ).first() + + await self.redis.set( + key, int(ban is not None), exat=ban.expires_at if ban else None + ) + + return is_banned + + async def ban( + self, + user_id: int, + report: models.Report | None, + expires_at: datetime | None, + created_by_id: int | None, + ) -> models.Ban: + """Bans a user. + + Args: + user_id (int): The user to ban's ID. + report (Report | None): The report that caused the ban. + expires_at (datetime | None): When the ban expires. + created_by_id (int | None): The ID of the user who created the ban. + + Returns: + (Ban): The ban. + """ + + ban = models.Ban( + user_id=user_id, + report=report, + expires_at=expires_at, + created_by_id=created_by_id, + ) + + await ban.save() + + await self.redis.set(f"banned:{user_id}", 1, exat=expires_at) + + return ban + + async def unban(self, user_id: int) -> None: + """Unbans a user. + + Args: + user_id (int): The user to unban's ID. + """ + + await models.Ban.filter(user_id=user_id).delete() + + await self.redis.delete(f"banned:{user_id}") async def on_command_error( self, context: commands.Context, exception: commands.CommandError @@ -402,82 +361,77 @@ class Payphone(commands.AutoShardedBot): elif isinstance(exception, errors.CommandNotFound): return - elif isinstance(exception, tortoise_exc.TransactionManagementError): - # Tortoise ORM bug. This may need to be logged for debugging, but for now they just - # clutter the logs. - return - else: return await super().on_command_error(context, exception) - async def on_error( - self, event_method: str, *args: discord.Any, **kwargs: discord.Any - ) -> None: - _, exception, _ = sys.exc_info() - - if isinstance( - exception, - (asyncpg.exceptions.InterfaceError, asyncpg.exceptions.PostgresError), - ): - # IMPORTANT! These are just spam at the moment, but may cause trouble in the future as - # the bot grows. - return - - return await super().on_error(event_method, *args, **kwargs) - @property def embed_author(self) -> discord.EmbedAuthor: - """The embed author for embeds sent by Payphone. - - Returns: - discord.EmbedAuthor: The embed author object. - """ + """The author of the embeds.""" return discord.EmbedAuthor( - name=self.user.display_name, icon_url=self.user.display_avatar.url + name=self.user.display_name, + icon_url=self.user.display_avatar.url, ) + @commands.check + async def is_channel_enabled(self, ctx: commands.Context) -> Literal[True]: + """Checks whether the current channel is enabled to run a command or not. -bot = Payphone() + Args: + ctx (commands.Context): The current context. -bot.load_extension("payphone.cogs.admin.cog") -bot.load_extension("payphone.cogs.calls.cog") -bot.load_extension("payphone.cogs.easter.cog") -bot.load_extension("payphone.cogs.mods.cog") -bot.load_extension("payphone.cogs.users.cog") -bot.load_extension("payphone.cogs.utility.cog") + Raises: + exceptions.ChannelDisabledError: The current channel is disabled. + """ + if ctx.guild is None: + return True -@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. + if ctx.command.qualified_name in ["config enable", "config disable"]: + return True - Args: - ctx (commands.Context): The current context. + server_id = ctx.guild.id + channel_id = ctx.channel.id - Raises: - exceptions.ChannelDisabledError: The current channel is disabled. - """ + channels = await self.redis.hgetall(f"channels:{server_id}") - if ctx.guild is None: - return True + if str(channel_id) not in channels: + channel, _ = await models.Channel.get_or_create( + server_id=server_id, id=channel_id + ) - if ctx.command.qualified_name in ["config enable", "config disable"]: - return True + if channel.is_enabled: + await self.redis.hset(f"channels:{server_id}", channel_id, 1) + return True - server_id = ctx.guild.id - channel_id = ctx.channel.id + channels = await models.Channel.filter(server_id=server_id, is_enabled=True) - await models.Server.get_or_create(id=server_id) - channel, _ = await models.Channel.get_or_create(id=channel_id, server_id=server_id) + if not channels: + await self.redis.hset(f"channels:{server_id}", channel_id, 1) + return True - if channel.enabled: - return True + await self.redis.hset(f"channels:{server_id}", channel_id, 0) + raise exceptions.ChannelDisabledError() + + if channels[str(channel_id)] == "1": + return True - if channel.enabled is False: raise exceptions.ChannelDisabledError() - if await models.Channel.filter(server_id=server_id, enabled=True).exists(): - raise exceptions.ChannelDisabledError() - return True +def run(): + """Runs the bot.""" + + bot = Payphone() + + try: + import uvloop + + uvloop.install() + + except ImportError: + pass + + bot.loop.run_until_complete(setup.init()) + + bot.run(constants.TOKEN) diff --git a/payphone/cogs/utility/__init__.py b/payphone/cogs/__init__.py similarity index 100% rename from payphone/cogs/utility/__init__.py rename to payphone/cogs/__init__.py diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py index 79bac6c..df36da3 100644 --- a/payphone/cogs/admin/cog.py +++ b/payphone/cogs/admin/cog.py @@ -1,209 +1,132 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import discord -from discord import errors -from discord.ext import commands, tasks +from discord.ext import commands -from tortoise import transactions, functions, exceptions - -from payphone import bot -from payphone.db import utils as dbutils, models +from payphone.db import models +from payphone.bot import Payphone from payphone.cogs import base -from payphone.core import utils +from payphone.cogs.admin import embeds, views +from payphone.core import checks class Admin(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance + """Administration in Payphone.""" - self.update_leaderboard.add_exception_type(exceptions.ConfigurationError) - self.update_leaderboard.start() + show_in_help = False - def cog_unload(self) -> None: - self.update_leaderboard.cancel() - - @tasks.loop(minutes=15) - async def update_leaderboard(self) -> None: - """Updates the leaderboard.""" - - calls = ( - await models.Call.annotate(messages_count=functions.Count("messages")) - .filter( - started_at__month=datetime.now(timezone.utc).month, - started_at__year=datetime.now(timezone.utc).year, - messages_count__gte=25, - ) - .prefetch_related( - "call_rss", "call_rss__channel", "call_rss__channel__server" - ) - ) - - # ID: Count - servers_map: dict[int, int] = {} - servers: dict[int, models.Server] = {} - - for call in calls: - for rs in utils.deduplicate(call.call_rss, lambda i: i.channel.server_id): - server_id = rs.channel.server_id - servers[server_id] = rs.channel.server - - if server_id in servers_map: - servers_map[server_id] += 1 - - else: - servers_map[server_id] = 1 - - server_counts = sorted(servers_map.items(), key=lambda i: i[1], reverse=True) - - for pos, (server_id, calls_count) in enumerate(server_counts): - servers[server_id].leaderboard_calls_count = calls_count - servers[server_id].leaderboard_position = pos + 1 - - await models.Server.bulk_update( - servers.values(), - ["leaderboard_calls_count", "leaderboard_position"], - batch_size=500, - ) - - @commands.Cog.listener() - async def on_guild_update( - self, _before: discord.Guild, after: discord.Guild - ) -> None: - """Updates a server. + def __init__(self, bot: Payphone) -> None: + """Initializes the Calls cog. Args: - _before (discord.Guild): The server before it being updated. - after (discord.Guild): The server after it being updated. + bot (Payphone): The bot instance. """ - vanity_code = None + self.bot = bot - try: - vanity = await after.vanity_invite() + @commands.command(aliases=["mod"]) + @commands.is_owner() + async def moderator( + self, ctx: commands.Context, user: discord.User, toggle: bool = True + ) -> None: + """Gives the user the Moderator role. + + Options: + - ``: The user to give the role. + - `[toggle]`: Whether to give or remove the role. - if vanity is not None: - vanity_code = vanity.code + Permissions: + - Bot Owner + """ - except errors.DiscordException: - pass - - await models.Server.update_or_create( - {"name": after.name, "vanity_code": vanity_code}, id=after.id + instance, _ = await models.User.update_or_create( + id=user.id, defaults={"is_moderator": toggle} ) - - @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. - - `enable`: Enable a (or all) channel(s) to run commands in it. Non-enabled channels will pass to be disabled. - - `disable`: Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. - - You can use `{prefix}help config ` 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 5 characters.") - return - - await ctx.send(f"✅ The prefix was set to `{prefix}`!") - - @config.command(extras={"help_name": "Enable Channel"}) - @commands.guild_only() - @commands.has_guild_permissions(manage_channels=True) - @transactions.atomic() - async def enable( - self, ctx: commands.Context, channel: discord.TextChannel | str | None - ) -> None: - """ - Enable a channel to run commands in it. Non-enabled channels will pass to be disabled. - - Options: - - `[]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled. - - Permissions: - - Manage Channels - """ - - server_id = ctx.guild.id - channel = channel or ctx.channel - - if isinstance(channel, str) and channel.lower() == "all": - await models.Channel.filter(server_id=server_id).update(enabled=None) - - await ctx.send( - "✅ All channels were enabled! You can now run commands in any channel." - ) - - else: - row = await dbutils.get_channel(channel) - - row.enabled = True - await row.save() - - await ctx.send( - f"✅ {channel.mention} was enabled! Other non-enabled channels were disabled." - ) - - @config.command(extras={"help_name": "Disable Channel"}) - @commands.guild_only() - @commands.has_guild_permissions(manage_channels=True) - @transactions.atomic() - async def disable( - self, ctx: commands.Context, channel: discord.TextChannel | None - ) -> None: - """ - Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. - - Options: - - `[]`: The channel to disable. If no channel is specified, the current channel will be disabled. - - Permissions: - - Manage Channels - """ - - channel = channel or ctx.channel - - row = await dbutils.get_channel(channel) - - row.enabled = False - await row.save() + await instance.set_cache(self.bot.redis) await ctx.send( - f"✅ {channel.mention} was disabled. Commands cannot be ran in this channel anymore." + f"Toggled the moderator role {'on' if toggle else 'off'} for {user.mention}." + ) + + @commands.command(aliases=["ub"]) + @checks.is_moderator() + async def unban( + self, ctx: commands.Context, user: discord.User | None = None + ) -> None: + """Unbans the user from the bot. + + Options: + - `[user]`: The user to unban. If none is provided, the author will be unbanned. + + Permissions: + - Bot Moderator + """ + + user = user or ctx.author + + await self.bot.unban(user.id) + + await ctx.send(f"{user.mention} was unbanned from Payphone.") + + try: + await user.send("🔔 You were unbanned from Payphone!") + + except discord.Forbidden: + pass + + @commands.command(aliases=["sup"]) + @commands.is_owner() + async def supporter( + self, ctx: commands.Context, user: discord.User, toggle: bool = True + ) -> None: + """Gives the user the Supporter role. + + Options: + - ``: The user to give the role. + - `[toggle]`: Whether to give or remove the role. + + Permissions: + - Bot Owner + """ + + instance, _ = await models.User.update_or_create( + id=user.id, + defaults={ + "is_supporter_until": ( + datetime.now(timezone.utc) + timedelta(days=30) if toggle else None + ) + }, + ) + await instance.set_cache(self.bot.redis) + + await ctx.send( + f"Gave the Supporter role to {ctx.author.mention} until in one month." + ) + + @commands.command() + @checks.is_moderator() + async def reports(self, ctx: commands.Context) -> None: + """Shows the reports. + + Permissions: + - Bot Moderator + """ + + report = await models.Report.filter(resolved=False).first() + + if report is None: + return await ctx.send("✅ No reports left, have this cookie 🍪 !!") + + await ctx.send( + embed=embeds.report_embed(report), view=views.ReportView(self.bot, report) ) -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. +def setup(bot: Payphone) -> None: + """Loads the Admin cog into the bot. Args: - b (bot.Payphone): The bot instance to load the cog into. + bot (Payphone): The bot instance. """ - b.add_cog(Admin(b)) + bot.add_cog(Admin(bot)) diff --git a/payphone/cogs/admin/embeds.py b/payphone/cogs/admin/embeds.py new file mode 100644 index 0000000..4294d86 --- /dev/null +++ b/payphone/cogs/admin/embeds.py @@ -0,0 +1,33 @@ +import discord + +from payphone.db import models +from payphone.core import constants + + +def report_embed(report: models.Report) -> discord.Embed: + """Creates an embed for a report. + + Args: + report (models.Report): The report to create an embed for. + + Returns: + (discord.Embed): The embed. + """ + + return discord.Embed( + color=constants.PRIMARY_COLOR, + title="Message Report", + description=( + f"**ID:** {report.id}\n" + f"**User ID**: {report.user_id}\n" + f"**User Name**: {report.user_name}\n" + f"**Channel ID**: {report.channel_id}\n" + f"**Created At**: \n" + ), + fields=[ + discord.EmbedField( + name="Message Content", value=report.message_content, inline=False + ) + ], + thumbnail=report.user_avatar_url, + ) diff --git a/payphone/cogs/admin/views.py b/payphone/cogs/admin/views.py new file mode 100644 index 0000000..a712d27 --- /dev/null +++ b/payphone/cogs/admin/views.py @@ -0,0 +1,197 @@ +from datetime import datetime, timedelta, timezone + +import discord + +from payphone.db import models +from payphone.bot import Payphone +from payphone.core import constants +from payphone.cogs.admin import embeds + + +class ReportView(discord.ui.View): + """The report view.""" + + def __init__(self, bot: Payphone, report: models.Report) -> None: + """Initializes the ReportView. + + Args: + bot (Payphone): The bot. + report (models.Report): The report. + """ + + super().__init__(disable_on_timeout=True) + + self.bot = bot + self.report = report + + @discord.ui.select( + placeholder="Select an action", + options=[ + discord.SelectOption( + value="warn", + label="Warn", + description="Send a warning to the user. This is done automatically.", + ), + discord.SelectOption( + value="ban", + label="Ban", + description="Ban the user. The duration of the ban will be applied automatically.", + ), + discord.SelectOption( + value="ban_permanently", + label="Permanent Ban", + description="Ban the user permanently. Use this when the user must not be allowed back.", + ), + ], + ) + async def action_callback( + self, select: discord.SelectMenu, interaction: discord.Interaction + ) -> None: + """Handles the action select.""" + + user = await models.User.get_cache_or_db(self.bot.redis, self.report.user_id) + + if select.values[0] == "warn": + user.warns_count += 1 + await user.save(force_update=True) + await user.set_cache(self.bot.redis) + + embed = discord.Embed( + color=constants.MASK_COLOR, + title="Warned", + description=f"You've been warned. You've received `{user.warns_count}` warnings in total.", + fields=[ + discord.EmbedField( + name="Message Content", + value=f"You sent the following message in a call:\n> {self.report.message_content}", + ), + discord.EmbedField( + name="User Name", + value=self.report.user_name, + ), + discord.EmbedField( + name="User Avatar", + value=f"[Click to open your avatar URL](<{self.report.user_avatar_url}>)", + ), + ], + footer=discord.EmbedFooter( + text="⚠️ Repeating this action will result in a ban." + ), + ) + + try: + await (await self.bot.get_or_fetch_user(self.report.user_id)).send( + embed=embed + ) + + except discord.Forbidden: + pass + + else: + user.bans_count += 1 + await user.save(force_update=True) + await user.set_cache(self.bot.redis) + + expires_at = None + + if select.values[0] == "ban": + expiry_times = { + 1: datetime.now(timezone.utc) + timedelta(days=1), + 2: datetime.now(timezone.utc) + timedelta(days=7), + 3: datetime.now(timezone.utc) + timedelta(days=30), + } + + expires_at = expiry_times.get(user.bans_count, None) + + await self.bot.ban( + self.report.user_id, + self.report, + expires_at=expires_at, + created_by_id=interaction.user.id, + ) + + embed = discord.Embed( + color=constants.MODERATOR_COLOR, + title="Banned", + description=f"You've been banned. You've been banned `{user.bans_count}` times.", + fields=[ + discord.EmbedField( + name="Message Content", + value=f"You sent the following message in a call:\n> {self.report.message_content}", + ), + discord.EmbedField( + name="User Name", + value=self.report.user_name, + ), + discord.EmbedField( + name="User Avatar", + value=f"[Click to open your avatar URL](<{self.report.user_avatar_url}>)", + ), + discord.EmbedField( + name="Expires At", + value=( + expires_at.strftime("%Y-%m-%d %H:%M:%S") + + f" " + if expires_at + else "Never" + ), + ), + ], + footer=discord.EmbedFooter( + text=( + "⚠️ In case you get banned again, your ban will last longer. Multiple bans will result in a permanent ban." + if expires_at is not None + else "⚠️ You've been permanently banned." + ) + ), + ) + + try: + await (await self.bot.get_or_fetch_user(self.report.user_id)).send( + embed=embed + ) + + except discord.Forbidden: + pass + + self.report.resolved = True + await self.report.save() + + report = await models.Report.filter(resolved=False).first() + + if report is None: + self.stop() + + return await interaction.response.edit_message( + content="✅ No reports left, have this cookie 🍪 !!", + embeds=[], + view=None, + ) + + await interaction.response.edit_message( + embed=embeds.report_embed(report), view=ReportView(self.bot, report) + ) + + @discord.ui.button(label="Skip", style=discord.ButtonStyle.gray) + async def skip_callback( + self, _button: discord.ui.Button, interaction: discord.Interaction + ) -> None: + """Skips the report.""" + + self.report.resolved = True + await self.report.save() + + report = await models.Report.filter(resolved=False).first() + + if report is None: + self.stop() + + return await interaction.response.edit_message( + content="✅ No reports left, have this cookie 🍪 !!", + embeds=[], + view=None, + ) + + await interaction.response.edit_message( + embed=embeds.report_embed(report), view=ReportView(self.bot, report) + ) diff --git a/payphone/cogs/calls/cog.py b/payphone/cogs/calls/cog.py index 7ba9fa7..386f216 100644 --- a/payphone/cogs/calls/cog.py +++ b/payphone/cogs/calls/cog.py @@ -1,1148 +1,498 @@ -from datetime import datetime, timezone, timedelta +import asyncio -import secrets +import random import discord -from discord import errors -from discord.ext import commands, tasks -from discord.utils import find - -from tortoise import transactions, exceptions +from discord.ext import commands import aiohttp -import orjson - -from payphone import bot -from payphone.db import utils as dbutils, models +from payphone.db import models +from payphone.bot import Payphone from payphone.cogs import base -from payphone.cogs.calls import embeds -from payphone.core import constants, checks, censorship, formatting +from payphone.core import constants, censorship, messages, masks, checks class Calls(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance + """Calls in Payphone.""" - def cog_unload(self) -> None: - return super().cog_unload() + def __init__(self, bot: Payphone) -> None: + """Initializes the Calls cog. + + Args: + bot (Payphone): The bot instance. + """ + + self.bot = bot @commands.command(aliases=["c"]) - @commands.guild_only() - @commands.cooldown(1, 15, commands.BucketType.channel) - @checks.is_unbanned() - @transactions.atomic() + @checks.is_not_banned() 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 + if await self.bot.call_handler.is_on_call(ctx.channel.id): + return await ctx.send( + f"☎️ You're already on a call! Use `{ctx.clean_prefix}help` to hang up." + ) - user_id = ctx.author.id - channel_id = ctx.channel.id + if await self.bot.call_handler.is_searching(ctx.channel.id): + return await ctx.send( + f"☎️ You're already searching for a call. Use `{ctx.clean_prefix}hangup` to hang up the phone." + ) - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) + await self.bot.call_handler.set_searching(ctx.channel.id, True) + + await ctx.send( + ( + f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" + f"☎️ {random.choice(messages.CALLING)}" + ) ) - 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 = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None - ) - 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 - - # self.bot.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: - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) - ) - - other_channel_id = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), - None, - ) - - if other_channel_id is None: - break - - continue - - break - - channel = await dbutils.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 dbutils.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() - @commands.cooldown(1, 15, commands.BucketType.channel) - @checks.is_unbanned() - @transactions.atomic() + @checks.is_not_banned() async def skip(self, ctx: commands.Context) -> None: """ - Hang up the current call and search for another. + Skip the current call and search for another one. """ - if ctx.channel.is_nsfw(): - await ctx.send("❌ Calls cannot be started in NSFW channels.") - return + if not await self.bot.call_handler.is_on_call(ctx.channel.id): + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." + ) - user_id = ctx.author.id - channel_id = ctx.channel.id + channels = await self.bot.call_handler.skip(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" - ) - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - 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) - - if other_channel is not None: - 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)}" + for channel in channels: + if channel == ctx.channel.id: + await ctx.send( + ( + f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" + "☎️ You skipped the call. Searching for another one..." + ) ) - except errors.DiscordException: - pass - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - *( - [str(channel_id)] - + ([str(other_channel.id)] if other_channel is not None else []) + else: + await self.bot.get_partial_messageable(channel).send( + f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}" + ) + + @commands.command(aliases=["h"]) + @checks.is_not_banned() + async def hangup(self, ctx: commands.Context) -> None: + """ + Hang up the call. + """ + + if not await self.bot.call_handler.is_on_call(ctx.channel.id): + if await self.bot.call_handler.is_searching(ctx.channel.id): + return await ctx.send(f"☎️ {random.choice(messages.NO_ANSWER)}") + + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." + ) + + channels = await self.bot.call_handler.hangup(ctx.channel.id) + + other_channel = channels[0] if channels[0] != ctx.channel.id else channels[1] + + await asyncio.gather( + ctx.send(f"☎️ {random.choice(messages.HANGUP)}"), + self.bot.get_partial_messageable(other_channel).send( + f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}" ), ) - 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 = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None - ) - 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 - - # self.bot.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: - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) - ) - - other_channel_id = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), - None, - ) - - if other_channel_id is None: - break - - 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 dbutils.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 dbutils.get_channel(ctx.channel) - - try: - call = await models.Call.get(id=channel.call_id).prefetch_related( - "channels" - ) - - except exceptions.DoesNotExist: - await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, channel_id) - - await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}") - - return - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - 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)] if other_channel is not None else []) - ), - ) - - if other_channel is not None: - 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 - - other_channel.call = None - - await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}") - - channel.call = None - - await models.Channel.bulk_update( - [channel] + ([other_channel] if other_channel is not None else []), - ["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 dbutils.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 - - reviewed_reports = await models.Report.filter( - reported_by_id=ctx.author.id, - status__in=[models.ReportStatus.ACCEPTED, models.ReportStatus.DECLINED], - ) - - priority = 0.5 - - if len(reviewed_reports) > 3: - accepted_reports = len( - [ - report - for report in reviewed_reports - if report.status == models.ReportStatus.ACCEPTED - ] - ) - - priority = accepted_reports / len(reviewed_reports) - - await models.Report.create( - call_id=call_id, reported_by_id=user.id, reason=reason, priority=priority - ) - - message = "✅ The call has been reported." - - if call.ended_at is None: - call.ended_at = datetime.now(timezone.utc) - await call.save() - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - 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() + @commands.command(aliases=["fr", "friend"]) + @checks.is_not_banned() async def friendreq(self, ctx: commands.Context) -> None: """ - Ask the other party to send you a friend request. + Send a friend request to the other party. """ - user_id = ctx.author.id - channel_id = ctx.channel.id + call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id) - call = await models.Call.get(channels__id=channel_id).prefetch_related( - "channels", "call_rss", "call_rss__profile" - ) - - 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 ""}{call_rs.display_name}** (`{ctx.author.name}`) wants you to add them as a friend on Discord." + if call is None: + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." ) - except errors.DiscordException: - await ctx.send("⚠️ Your friend request couldn't be sent.") - return + other_channel = ( + call.channels[0] if call.channels[0] != ctx.channel.id else call.channels[1] + ) - await ctx.send("✅ Your friend request was sent!") + await asyncio.gather( + self.bot.get_partial_messageable(other_channel).send( + f"📬 **{ctx.author.display_name}** wants you to send them a friend request on Discord! Their username is `{ctx.author.name}`." + ), + ctx.send("📬 Your friend request has been sent!"), + ) + + @commands.command(aliases=["m"]) + @checks.is_not_banned() + async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None: + """ + Toggle your mask. + + Options: + - `[toggle]`: Whether to toggle your mask on or off. If no toggle is specified, your mask will be displayed. + """ + + call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id) + user = await models.User.get_cache_or_db(self.bot.redis, ctx.author.id) + + if toggle is not None: + user.is_masked = toggle + + await user.save(force_update=True) + await user.set_cache(self.bot.redis) + + message = f"🎭 Your mask {"is" if toggle is None else "was set to"} {"`on`" if user.is_masked else "`off`"}." + + if call is not None: + participant = await call.get_participant(ctx.author.id) + + if user.is_masked: + message += f" You're currently being displayed as **{masks.get_mask_emoji(participant.mask)} {masks.get_mask_name(participant.mask)}**." + + message += ( + " You can toggle your mask on or off using `p.mask on` or `p.mask off`." + ) + + return await ctx.send(message) @commands.Cog.listener() - @commands.guild_only() async def on_message(self, message: discord.Message) -> None: - """Processes every message received. - - Args: - message (discord.Message): The message received. + """ + Handle messages in calls. """ - if ( - message.author.bot - or message.is_system() - or len(message.stickers) > 0 - or not message.content - ): + ctx = await self.bot.get_context(message) + + if message.author.bot or ctx.valid: return - if (await self.bot.get_context(message)).valid: + if await self.bot.is_banned(message.author.id): return - user_id = message.author.id - channel_id = message.channel.id + call = await self.bot.call_handler.get_call_by_channel_id(message.channel.id) - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) + if call is None: + return + + participant, was_mask_toggled = await call.set_participant( + message.author.id, + message.author.display_name, + message.author.display_avatar.url, + message.channel.id, ) - if not is_on_call: - return + other_channel = ( + call.channels[0] + if call.channels[0] != message.channel.id + else call.channels[1] + ) - is_banned = await self.bot.is_banned(user_id) - - if is_banned: - return - - channel = await dbutils.get_channel(message.channel) - - try: - call = await models.Call.get(id=channel.call_id).prefetch_related( - "channels" + blocked_section = ( + censorship.censor( + message.content, + participant.user.flag & models.UserFlag.SUPPORTER, ) - - except exceptions.DoesNotExist: - await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, str(channel_id)) - return - - other_channel: models.Channel = find( - lambda c: c.id != channel_id, call.channels + if not (participant.user.flag & models.UserFlag.MODERATOR) + else None ) - if other_channel is None: - # BUG: The call wasn't properly set up. - return - - user, profile = await dbutils.get_user(message.author) - - call_rss = await models.CallUserChannelRelationship.filter( - call=call, channel=channel - ).select_related("profile") - used_masks = [rs.mask for rs in call_rss] - - call_rs, _ = await models.CallUserChannelRelationship.get_or_create( - { - "mask": secrets.choice( - [i for i in constants.MASKS_LIST if i not in used_masks] + if blocked_section: + return await asyncio.gather( + message.channel.send( + ( + "❌ Your message wasn't sent because it contained a blocked word or " + f"pattern: `{blocked_section}`" + ), + delete_after=10, + ), + self.bot.get_partial_messageable(other_channel).send( + ( + f"❌ **{message.author.display_name}** sent a message that was blocked.\n" + "-# If you see an inappropiate message, please report it using `Apps > " + "Report Message`." + ), ), - "is_masked": user.is_masked, - "profile": profile, - }, - call=call, - user=user, - channel=channel, - ) - - # Since .select_related() can't be used and profile was already fetched, we need to - # manually set it. - call_rs.profile = profile - - blocked_word = censorship.moderate(message.content) - - webhook_message = None - cleaned_content = formatting.trim(formatting.clean(message, call_rss), 2000) - - replying_to_id = None - - if blocked_word: - try: - await self.bot.get_partial_messageable(channel_id).send( - f"Your message wasn't delivered!\nBlocked word: `{formatting.trim(blocked_word, 10)}`." - ) - - except errors.DiscordException: - pass - - prefix = await self.bot.get_prefix(server_id=other_channel.server_id) - - try: - await self.bot.get_partial_messageable(other_channel.id).send( - f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** said something that's not allowed. If you think they're doing something inappropiate, use `{prefix}report`." - ) - - except errors.DiscordException: - pass - - else: - async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - embed = None - - if message.reference: - embed, replying_message = await embeds.ReplyEmbed( - self.bot.redis, message.reference, call_id=call.id - ) - - if replying_message is not None: - replying_to_id = replying_message.id - - try: - webhook_message = await webhook.send( - cleaned_content, - username=call_rs.display_name, - avatar_url=call_rs.display_avatar, - embed=embed, - wait=True, - ) - - except errors.NotFound: - other_channel.webhook_id = None - other_channel.webhook_url = None - - await other_channel.save() - - try: - await dbutils.get_channel( - await self.bot.get_or_fetch_channel(other_channel.id) - ) - - finally: - pass - - pipe = self.bot.redis.pipeline() - - pipe.set(constants.CALL_IS_ACTIVE % call.id, "", ex=60 * 15) - - pipe.hset( - constants.CALL_MESSAGES_TEMPLATE % call.id, - { - message.id: orjson.dumps( - { - "id": message.id, - "call_id": call.id, - "channel_id": channel.id, - "user_id": user.id, - "profile_id": profile.id, - "mask": call_rs.mask if call_rs.is_masked else None, - "propagation_id": ( - webhook_message.id if webhook_message else None - ), - "propagation_channel_id": ( - other_channel.id if webhook_message else None - ), - "replying_to_id": replying_to_id, - "created_at": datetime.now(timezone.utc), - } - ).decode() - }, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call.id, - { - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, 1): orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode() - }, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call.id, - {message.id: 1}, - ) - - if webhook_message is not None: - pipe.hset( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call.id, - {webhook_message.id: message.id}, ) - if replying_to_id is not None: - other_replies = await self.bot.redis.hget( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, replying_to_id - ) + message_content = self.bot.clean_message(message, call) - if other_replies is None: - pipe.hset( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - {replying_to_id: message.id}, - ) + if message.stickers: + if participant.user.flag & ( + models.UserFlag.SUPPORTER | models.UserFlag.MODERATOR + ): + sticker = message.stickers[0] + + if sticker.format != discord.StickerFormatType.lottie: + message_content = f"[Sticker: {sticker.name}]({sticker.url})" else: - pipe.hset( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - {replying_to_id: other_replies + f",{message.id}"}, + return await message.channel.send( + "💖 Stickers and GIFs are only allowed to donators! You can become one at [our Ko-Fi " + "page]()." ) - await pipe.execute() + embeds = [] + + if message.reference: + if await call.is_message_from_call(message.reference.message_id): + replied_message = message.reference.cached_message + + if replied_message is None: + replied_message = await message.channel.fetch_message( + message.reference.message_id + ) + + original_message_id = ( + await self.bot.call_handler.get_original_message_id( + replied_message.id + ) + ) + + if ( + original_message_id is not None + and original_message_id != replied_message.id + ): + replied_message = await self.bot.get_partial_messageable( + other_channel + ).fetch_message(original_message_id) + + replied_message_content = self.bot.clean_message(replied_message, call) + replied_participant = await call.get_participant( + replied_message.author.id + ) + + if replied_participant is None: + message_content += ( + "\n-# ⓘ In reply to a message from outside this call." + ) + + else: + embeds.append( + discord.Embed( + color=constants.PRIMARY_COLOR, + description=replied_message_content, + author=discord.EmbedAuthor( + name=( + replied_participant.display_name + if replied_participant.channel_id + != message.channel.id + else replied_participant.name + ), + icon_url=( + replied_participant.display_avatar_url + if replied_participant.channel_id + != message.channel.id + else replied_participant.avatar_url + ), + ), + ) + ) + + else: + message_content += ( + "\n-# ⓘ In reply to a message from outside this call." + ) + + if was_mask_toggled: + if not participant.is_masked: + if participant.user.flag & models.UserFlag.SUPPORTER: + embeds.append( + discord.Embed( + color=constants.SUPPORTER_COLOR, + description=f"🌟 **{participant.display_name}** is a Payphone supporter!", + footer=random.choice( + [ + discord.EmbedFooter( + text="You can also become a supporter at our Ko-Fi page." + ) + ] + + [None] * 3 + ), + ) + ) + + if participant.user.flag & models.UserFlag.MODERATOR: + embeds.append( + discord.Embed( + color=constants.MODERATOR_COLOR, + description=f"🛡️ **{participant.display_name}** is a Payphone moderator!", + ) + ) + + else: + embeds.append( + discord.Embed( + color=constants.MASK_COLOR, + description=f"🎭 **{participant.display_name}** is wearing a mask! Their real identity is hidden.", + footer=discord.EmbedFooter( + text="You can set your mask using p.mask on." + ), + ) + ) + + async with aiohttp.ClientSession() as session: + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url(other_channel), + session=session, + bot_token=constants.TOKEN, + ) + + propagated_msg = await webhook.send( + content=message_content, + username=participant.display_name, + avatar_url=participant.display_avatar_url, + embeds=embeds, + wait=True, + ) + + await call.set_message( + message.id, + message.channel.id, + propagated_msg.id, + message.author.id, + ) @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. + """ + Handle deleted messages in calls. """ - message_id = payload.message_id - channel_id = payload.channel_id + call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id) - channel = await models.Channel.get_or_none(id=channel_id) - - if channel is None: + if call is None: return - if channel.call_id is None: + propagation = await call.get_message_propagation(payload.message_id) + + if propagation is None: return - message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, message_id - ) - - if message_json is None: - return - - message_dict = orjson.loads(message_json) - - message = models.Message(**message_dict) - - message_dict["was_deleted"] = True - - await self.bot.redis.hset( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, - {message.id: orjson.dumps(message_dict).decode()}, - ) - - other_channel = await models.Channel.get_or_none( - id=message.propagation_channel_id - ) - - if other_channel is None: - return - - if other_channel.call_id != message.call_id: - return + propagation_channel, propagation_id = propagation async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - try: - await webhook.delete_message(message.propagation_id) - - except errors.DiscordException: - # This message was already deleted. - pass - - message_replies = await self.bot.redis.hget( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % channel.call_id, message.id + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url( + propagation_channel + ), + session=session, + bot_token=constants.TOKEN, ) - if message_replies is None: - return - - for message_id in message_replies.split(","): - try: - await webhook.edit_message(message_id, embed=None) - - except errors.DiscordException: - pass + await webhook.delete_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. + """ + Handle edited messages in calls. """ - if ( - payload.cached_message is not None - and ( - payload.cached_message.author.bot or payload.cached_message.is_system() + call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id) + + if call is None: + return + + blocked_section = censorship.censor(payload.data["content"]) + + if blocked_section: + return ( + await self.bot.get_partial_messageable(payload.channel_id) + .get_partial_message(payload.message_id) + .reply( + ( + "❌ Your message wasn't sent because it contained a blocked word or " + f"pattern: `{blocked_section}`" + ), + delete_after=10, + ) ) - or "content" not in payload.data - ): + + propagation = await call.get_message_propagation(payload.message_id) + + if propagation is None: return - message_id = payload.message_id - channel_id = payload.channel_id - content = formatting.trim(payload.data["content"], 2000) + propagation_channel, propagation_id = propagation - channel = await models.Channel.get_or_none(id=channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, message_id - ) - - if message_json is None: - return - - message = models.Message(**orjson.loads(message_json)) - - other_channel = await models.Channel.get(id=message.propagation_channel_id) - - if other_channel.call_id != message.call_id: - return - - original_message = await self.bot.get_partial_messageable( - channel_id - ).fetch_message(message.id) - - if original_message is None: - return - - call_rss = await models.CallUserChannelRelationship.filter( - call_id=channel.call_id - ).select_related("profile") - - cleaned_content = formatting.clean(original_message, call_rss) - - blocked_word = censorship.moderate(content) - - iteration = int( - await self.bot.redis.hget( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % channel.call_id, - message.id, + async with aiohttp.ClientSession() as session: + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url( + propagation_channel + ), + session=session, + bot_token=constants.TOKEN, ) - ) - pipe = self.bot.redis.pipeline() + await webhook.edit_message(propagation_id, content=payload.data["content"]) - pipe.hincrby( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % channel.call_id, - message.id, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % channel.call_id, - { - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, iteration + 1): orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode() + @commands.message_command(name="Report Message") + async def report_message( + self, ctx: discord.ApplicationContext, message: discord.Message + ) -> None: + """ + Report a message in a call. + """ + + if self.bot.is_banned(ctx.author.id): + return await ctx.send_response( + "❌ You're banned from using Payphone.", ephemeral=True + ) + + author_id = await self.bot.call_handler.get_message_author_id(message.id) + + if author_id is None: + return await ctx.send_response( + ( + "❌ More than 15 minutes have passed since this message was sent.\n" + "-# You'll also see this error if the message wasn't sent during a call." + ), + ephemeral=True, + ) + + await ctx.send_response("✅ The message has been reported.", ephemeral=True) + + message_id: int + + if message.author.bot: + message_id = await self.bot.call_handler.get_original_message_id(message.id) + + else: + message_id = message.id + + if message_id != message.id: + cached_message = await self.bot.call_handler.get_message(message_id) + + message = await self.bot.get_partial_messageable( + cached_message.channel_id + ).fetch_message(message_id) + + await models.Report.update_or_create( + message_id=message_id, + defaults={ + "channel_id": message.channel.id, + "user_id": author_id, + "user_name": message.author.display_name, + "user_avatar_url": message.author.display_avatar.url, + "message_content": message.content, }, ) - await pipe.execute() - async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - try: - await webhook.edit_message( - message.propagation_id, - content=( - cleaned_content - if blocked_word is None - else "*This message was edited and censored.*" - ), - ) - - except errors.DiscordException: - # This message was deleted. - pass - - # TODO: Edit replies. - - @commands.Cog.listener() - async def on_raw_reaction_add( - self, payload: discord.RawReactionActionEvent - ) -> None: - if payload.user_id == self.bot.user.id: - return - - channel = await models.Channel.get_or_none(id=payload.channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - call_id = channel.call_id - - other_channel_id = None - other_channel_message_id = await self.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - payload.message_id, - ) - - if other_channel_message_id is None: - other_channel_message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, payload.message_id - ) - - if other_channel_message_json is None: - return - - other_channel_message_dict = orjson.loads(other_channel_message_json) - - if other_channel_message_dict["propagation_id"] is None: - # The message wasn't propagated - return - - other_channel_id = other_channel_message_dict["propagation_channel_id"] - other_channel_message_id = other_channel_message_dict["propagation_id"] - - else: - other_channel = await models.Channel.get_or_none( - call_id=call_id, id__not=payload.channel_id - ) - - if other_channel is not None: - other_channel_id = other_channel.id - - else: - return - - other_channel_message_id = int(other_channel_message_id) - - channel = await self.bot.get_or_fetch_channel(other_channel_id) - - if channel is None: - return - - await channel.get_partial_message(other_channel_message_id).add_reaction( - payload.emoji - ) - - @commands.Cog.listener() - async def on_raw_reaction_remove( - self, payload: discord.RawReactionActionEvent - ) -> None: - if payload.user_id == self.bot.user.id: - return - - channel = await models.Channel.get_or_none(id=payload.channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - call_id = channel.call_id - - other_channel_id = None - other_channel_message_id = await self.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - payload.message_id, - ) - - if other_channel_message_id is None: - other_channel_message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, payload.message_id - ) - - if other_channel_message_json is None: - return - - other_channel_message_dict = orjson.loads(other_channel_message_json) - - if other_channel_message_dict["propagation_id"] is None: - # The message wasn't propagated - return - - other_channel_id = other_channel_message_dict["propagation_channel_id"] - other_channel_message_id = other_channel_message_dict["propagation_id"] - - else: - other_channel = await models.Channel.get_or_none( - call_id=call_id, id__not=payload.channel_id - ) - - if other_channel is not None: - other_channel_id = other_channel.id - - else: - return - - other_channel_message_id = int(other_channel_message_id) - - channel = await self.bot.get_or_fetch_channel(other_channel_id) - - if channel is None: - return - - await channel.get_partial_message(other_channel_message_id).remove_reaction( - payload.emoji, self.bot.user - ) - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. +def setup(bot: Payphone) -> None: + """Loads the Calls cog into the bot. Args: - b (bot.Payphone): The bot instance to load the cog into. + bot (Payphone): The bot instance. """ - b.add_cog(Calls(b)) + bot.add_cog(Calls(bot)) diff --git a/payphone/cogs/calls/embeds.py b/payphone/cogs/calls/embeds.py deleted file mode 100644 index 6828997..0000000 --- a/payphone/cogs/calls/embeds.py +++ /dev/null @@ -1,104 +0,0 @@ -import discord - -import orjson - -from redis import Redis - -from payphone.db import models -from payphone.core import constants, formatting - - -async def ReplyEmbed( - redis: Redis, reference: discord.MessageReference, call_id: int -) -> tuple[discord.Embed | None, models.Message | None]: - """Generates the embed sent on calls to represent replies. - - Args: - redis (redis.Redis): An instance of a Redis connection handler. - reference (discord.MessageReference): The message reference. - call_id (int): The ID of the call the message was sent in. - - Returns: - tuple[discord.Embed | None, models.Message | None]: The generated embed and the replied - message. - """ - - message = None - - unflushed_message = await redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, reference.message_id - ) - - if unflushed_message is None: - message_id = await redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - reference.message_id, - ) - - if message_id is not None: - unflushed_message = await redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, message_id - ) - - if unflushed_message is None: - return (None, None) - - message = models.Message(**orjson.loads(unflushed_message)) - - unflushed_message_content_iteration = int( - await redis.hget( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call_id, message.id - ) - ) - unflushed_message_content = await redis.hget( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id, - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, unflushed_message_content_iteration), - ) - - last_content = models.MessageContent(**orjson.loads(unflushed_message_content)) - - message.channel = await models.Channel.get(id=message.channel_id) - message.profile = await models.Profile.get(id=message.profile_id) - message.propagation_channel = await models.Channel.get( - id=message.propagation_channel_id - ) - - if message is None: - return None, None - - is_this_channel = message.id == reference.message_id - - if last_content is None: - for content in message.contents: - if last_content is None: - last_content = content - continue - - if last_content.message_id < content.message_id: - last_content = content - - author_name = message.display_name if is_this_channel else message.profile.name - author_avatar_url = ( - message.display_avatar if is_this_channel else message.profile.avatar_url - ) - - embed = discord.Embed( - color=( - constants.EMBED_COLOR - if not is_this_channel and message.mask is not None - else message.profile.color or constants.EMBED_COLOR - ), - description=( - formatting.trim(last_content.content) - if last_content.was_propagated or is_this_channel - else "*This message was censored.*" - ), - author=discord.EmbedAuthor( - name=f"Replied to {author_name}", - icon_url=author_avatar_url, - url=message.jump_url(is_this_channel), - ), - ) - - return embed, message diff --git a/payphone/cogs/easter/__init__.py b/payphone/cogs/easter/__init__.py deleted file mode 100644 index 5a1ea2d..0000000 --- a/payphone/cogs/easter/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -import enum - - -class EasterEggCommand(enum.IntFlag): - """Flags representing the easter eggs. - - This is used to keep track of which easter eggs did the user find.""" - - CATMASK = 1 - USERPHONE = 2 diff --git a/payphone/cogs/easter/checks.py b/payphone/cogs/easter/checks.py deleted file mode 100644 index 5e78f4d..0000000 --- a/payphone/cogs/easter/checks.py +++ /dev/null @@ -1,35 +0,0 @@ -from discord.ext import commands - -from payphone.db import utils -from payphone.cogs.easter import EasterEggCommand - - -def easter_egg(command: EasterEggCommand): - """Send rewards to the user when they find an easter egg.""" - - async def predicate(ctx: commands.Context) -> bool: - if ctx.author.bot: - return False - - user, _ = await utils.get_user(ctx.author) - - if command not in user.found_easter_eggs: - user.found_easter_eggs |= command - - remaining_easter_eggs = len(list(EasterEggCommand)) - len( - user.found_easter_eggs.name.split("|") - ) - await ctx.send( - "✨ You found an easter egg! " - + ( - f"There are {remaining_easter_eggs} easter eggs remaining." - if remaining_easter_eggs > 0 - else "There are no easter eggs left, congratulations! 🎉" - ) - ) - - await user.save() - - return True - - return commands.check(predicate) diff --git a/payphone/cogs/easter/cog.py b/payphone/cogs/easter/cog.py deleted file mode 100644 index ef91f0c..0000000 --- a/payphone/cogs/easter/cog.py +++ /dev/null @@ -1,66 +0,0 @@ -import secrets - -from discord.ext import commands - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.cogs import base -from payphone.core import constants -from payphone.cogs.easter import EasterEggCommand, checks - - -class Easter(base.Cog): - show_in_help = False - - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - @commands.command() - @checks.easter_egg(EasterEggCommand.CATMASK) - @transactions.atomic() - async def catmask(self, ctx: commands.Context) -> None: - """ - This is a secret command! - """ - - await ctx.message.delete() - - new_mask = secrets.choice(constants.CAT_MASKS_LIST) - - channel_rs = ( - await models.CallUserChannelRelationship.filter( - channel_id=ctx.channel.id, user_id=ctx.author.id - ) - .order_by("-created_at") - .limit(1) - .select_related("call") - .first() - ) - - if channel_rs is None or channel_rs.call.ended_at is not None: - return - - channel_rs.mask = new_mask.value - channel_rs.is_masked = True - await channel_rs.save() - - @commands.command(aliases=["speakerphone"]) - @checks.easter_egg(EasterEggCommand.USERPHONE) - async def userphone(self, ctx: commands.Context) -> None: - """ - The second easter egg! You found it. - """ - - await ctx.send("☎️ Please insert 25 cents to complete this call.") - - -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(Easter(b)) diff --git a/payphone/cogs/mods/cog.py b/payphone/cogs/mods/cog.py index 0b1156c..3f1c2f8 100644 --- a/payphone/cogs/mods/cog.py +++ b/payphone/cogs/mods/cog.py @@ -1,443 +1,139 @@ -from typing import Awaitable - -from datetime import datetime, timezone, timedelta - -import asyncio - import discord -from discord import errors from discord.ext import commands -from tortoise import transactions, expressions, functions - -from procs import flush_messages - -from payphone import bot -from payphone.db import utils, models +from payphone.db import models +from payphone.bot import Payphone from payphone.cogs import base -from payphone.cogs.mods import embeds, views -from payphone.core import checks, converters, constants, formatting as fmt class Mods(base.Cog): - show_in_help = False + """Administration in Payphone.""" - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance + def __init__(self, bot: Payphone) -> None: + """Initializes the Calls cog. - @commands.command(extras={"reply_argument": "user"}) - @checks.is_moderator() - @transactions.atomic() - async def ban( - self, - ctx: commands.Context, - user: converters.DiscordUserID, - duration: converters.Timedelta | None = None, - *, - reason: str | None = "", + Args: + bot (Payphone): The bot instance. + """ + + self.bot = bot + + @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: """ - Ban a user by their ID, pinging them, or replying to their message. + Set up Payphone for the current server. - Options: - - ``: The user to ban or their ID. - - `[]`: The amount of time to ban the user for. For example, `1d 5h`. - - `[]`: The reason of their ban. + Settings: + - `prefix`: Set a custom prefix. + - `enable`: Enable a (or all) channel(s) to run commands in it. Non-enabled channels will pass to be disabled. + - `disable`: Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. + + You can use `{prefix}help config ` to get help about a specific configuration. """ - user_id = user + await ctx.send_help("config") - profile = await models.Profile.filter(user_id=user_id).limit(1).first() + @config.command(extras={"help_name": "Prefix"}) + @commands.guild_only() + @commands.has_guild_permissions(manage_guild=True) + async def prefix(self, ctx: commands.Context, prefix: str = "p.") -> None: + """ + Set a custom bot prefix. - row = await self.bot.ban( - user_id, - duration, - reason, - ctx.author, + Options: + - ``: The new custom prefix. + + Permissions: + - Manage Server + """ + + if len(prefix) > 10: + return await ctx.send( + "❌ The prefix must not be longer than 10 characters." + ) + + server, _ = await models.Server.update_or_create( + id=ctx.guild.id, defaults={"name": ctx.guild.name} ) - message = f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was banned" + await server.set_prefix(self.bot.redis, prefix) - if row.expires_at: - message += f"{f" for `{fmt.time_to_text(duration)}`" if duration is not None else ""} until ()" + await ctx.send(f"✅ The prefix was set to `{prefix}`!") + + @config.command(extras={"help_name": "Enable Channel"}) + @commands.guild_only() + @commands.has_guild_permissions(manage_channels=True) + async def enable( + self, ctx: commands.Context, channel: discord.TextChannel | str | None + ) -> None: + """ + Enable a channel to run commands in it. Non-enabled channels will pass to be disabled. + + Options: + - `[]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled. + + Permissions: + - Manage Channels + """ + + server_id = ctx.guild.id + channel = channel or ctx.channel + + if isinstance(channel, str) and channel.lower() == "all": + await models.Channel.filter(server_id=server_id).update(is_enabled=None) + await ctx.bot.redis.delete(f"channels:{server_id}") + + await ctx.send( + "✅ All channels were enabled! You can now run commands in any channel." + ) else: - message += " permanently" - - 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), + await models.Channel.update_or_create( + id=channel.id, server_id=server_id, is_enabled=True ) + await ctx.bot.redis.hset(f"channels:{server_id}", channel.id, "1") - 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: converters.DiscordUserID, - ) -> 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: - - ``: The user to unban or their ID. - """ - - user_id = user - - await self.bot.unban(user_id, ctx.author) - - 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: converters.DiscordUserID, - *, - reason: str, - ) -> None: - """ - Warn a user by their ID or mention. - - Options: - - ``: The user's mention or their ID. - - ``: The reason of the warn. - """ - - user_id = user - - 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." + f"✅ {channel.mention} was enabled! Other non-enabled channels were disabled." ) - 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() - @checks.is_moderator() - @transactions.atomic() - async def reports(self, ctx: commands.Context) -> None: - """ - Shows the next report to review. - """ - - report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - if report is None: - await ctx.send("✅ No reports left, have this cookie 🍪 !!") - return - - await ctx.send( - embed=embeds.ReportEmbed(self.bot, report), - view=views.ReportView(self.bot, report, total), - ) - - @commands.command(aliases=["mod"]) - @commands.is_owner() - @transactions.atomic() - async def moderator( - self, - ctx: commands.Context, - user: discord.User | discord.Member, - toggle: bool | None = None, + @config.command(extras={"help_name": "Disable Channel"}) + @commands.guild_only() + @commands.has_guild_permissions(manage_channels=True) + async def disable( + self, ctx: commands.Context, channel: discord.TextChannel | None ) -> None: """ - Toggles a user's moderator status. It can only be ran by Payphone's owner. + Disables a channel to run commands in it. Non-disabled channels will stay enabled by default. Options: - - ``: The user to toggle the moderation status for. - - `[]`: The moderator status to set to the user. + - `[]`: The channel to disable. If no channel is specified, the current channel will be disabled. + + Permissions: + - Manage Channels """ - user_row, profile_row = await utils.get_user(user) + channel = channel or ctx.channel - 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 models.Channel.update_or_create( + id=channel.id, server_id=ctx.guild.id, is_enabled=False + ) + await ctx.bot.redis.hset(f"channels:{ctx.guild.id}", channel.id, "0") await ctx.send( - f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`." + f"✅ {channel.mention} was disabled. Commands cannot be ran in this channel anymore." ) - @commands.command() - @commands.is_owner() - async def stats( - self, ctx: commands.Context, iterations: int = 4, wait: int = 5 - ) -> None: - """ - Shows all Payphone's stats. - Options: - - ``: The amount of iterations (to display progress). Defaults to 5. - - ``: The amount of seconds to wait between iterations. Defaults to 5. - """ - - async def count_banned_users() -> int: - return ( - await models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ) - ) - .annotate(unique_users_count=functions.Count("user_id", distinct=True)) - .values("unique_users_count") - )[0]["unique_users_count"] - - stats = [ - ( - "On-call Channels", - lambda: self.bot.redis.scard(constants.CHANNELS_ONCALL_SET), - ), - ( - "Searching Channels", - lambda: self.bot.redis.scard(constants.CHANNELS_SEARCH_QUEUE), - ), - ( - "Messages", - lambda: models.Message.all().count(), - ), - ( - "Message Contents", - lambda: models.MessageContent.all().count(), - ), - ("Calls", lambda: models.Call.all().count()), - ( - "Ongoing Calls", - lambda: models.Call.filter(ended_at=None).count(), - ), - ("Bans", lambda: models.Ban.all().count()), - ( - "Ongoing Bans", - lambda: models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ) - ).count(), - ), - ( - "Banned Users", - count_banned_users, - ), - ("Reports", lambda: models.Report.all().count()), - ( - "On-review Reports", - lambda: models.Report.filter( - status=models.ReportStatus.ON_REVIEW - ).count(), - ), - ("Users", lambda: models.User.all().count()), - ( - "Profiles", - lambda: models.Profile.all().count(), - ), - ("Servers", lambda: models.Server.all().count()), - ("Shards", lambda: self.bot.shard_count), - ("Current Shard", lambda: ctx.guild.shard_id + 1), - ] - - rows = [[stat[0]] for stat in stats] - rows_with_diff = [[stat[0]] for stat in stats] - - i = 0 - message = None - - while i < iterations: - i += 1 - - for stat_i, stat in enumerate(stats): - value = stat[1]() - - if isinstance(value, Awaitable): - value = await value - - rows[stat_i].append(value) - prev_value = rows[stat_i][-2] - - if isinstance(prev_value, str): - rows_with_diff[stat_i].append(f"{value:,}") - - else: - diff = value - prev_value - - rows_with_diff[stat_i].append( - f"{value:,}" - if diff == 0 - else (f"{value:,}", f"({"+" if diff > 0 else ""}{diff})") - ) - - table = fmt.monospace_table( - rows_with_diff, - [""] + [f"{wait * j}s" for j in range(i)], - padding=2, - ) - - if message is None: - message = await ctx.send(f"```\n{table}\n```") - - else: - message = await message.edit(f"```\n{table}\n```") - - if i < iterations: - await asyncio.sleep(wait) - - @commands.command() - @checks.is_moderator() - async def user(self, ctx: commands.Context, user: converters.DiscordUserID) -> None: - """ - Shows all the information about a user. - - Options: - - `[]`: The user, their ID, or `.` or `reply` replying to their message. - """ - - user_id = user - - user = await models.User.get_or_none(id=user_id).prefetch_related("profiles") - profile: models.Profile | None = user.profiles[0] if user is not None else None - - bans = await models.Ban.filter( - user_id=user_id, - ).order_by("-created_at") - - ongoing_bans = [ - ban - for ban in bans - if ban.expires_at is None or ban.expires_at > datetime.now(timezone.utc) - ] - expired_bans = [ - ban - for ban in bans - if ban.expires_at is not None - and ban.expires_at < datetime.now(timezone.utc) - ] - - embed = discord.Embed( - color=( - (profile.color or constants.EMBED_COLOR) - if profile is not None - else constants.EMBED_COLOR - ), - title="Profile Information", - timestamp=datetime.now(timezone.utc), - fields=[ - discord.EmbedField( - name="Ongoing Bans", - value=fmt.trim( - "\n".join( - [ - f"- `ID: {ban.id}`{f" (by <@{ban.banned_by_id}>)" if ban.banned_by_id else ""}: {ban.reason if ban.reason else "*No reason*"}. Banned {f"until " if ban.expires_at is not None else "permanently"}." - for ban in ongoing_bans - ] - ), - 1024, - ) - or "*No ongoing bans.*", - ), - discord.EmbedField( - name="Past Bans", - value=fmt.trim( - "\n".join( - [ - f"- `ID: {ban.id}`{f" (by <@{ban.banned_by_id}>)" if ban.banned_by_id else None}: {ban.reason if ban.reason else "*No reason*"}. {"Expired" if ban.unbanned_by_id is None else f"Unbanned by <@{ban.unbanned_by_id}>"} ." - for ban in expired_bans - ] - ), - 1024, - ) - or "*No expired bans.*", - ), - ], - author=( - profile.embed_author if profile is not None else self.bot.embed_author - ), - thumbnail=( - profile.avatar_url - if profile is not None - else self.bot.user.display_avatar.url - ), - ) - - await ctx.send(embed=embed) - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. +def setup(bot: Payphone) -> None: + """Loads the Mods cog into the bot. Args: - b (bot.Payphone): The bot instance to load the cog into. + bot (Payphone): The bot instance. """ - b.add_cog(Mods(b)) + bot.add_cog(Mods(bot)) diff --git a/payphone/cogs/mods/embeds.py b/payphone/cogs/mods/embeds.py deleted file mode 100644 index 1a8f475..0000000 --- a/payphone/cogs/mods/embeds.py +++ /dev/null @@ -1,84 +0,0 @@ -import discord -from discord.utils import find - -from payphone import bot -from payphone.db import models -from payphone.core import constants, formatting - - -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" ()" - if ban.expires_at - else "*Does not expire.*" - ), - ), - ], - 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, - ) - - -class ReportEmbed(discord.Embed): - """The report embed shown to moderators.""" - - def __init__(self, bot: bot.Payphone, report: models.Report) -> None: - """Initializes the embed. - - Args: - bot (bot.Payphone): The instance of the bot. - report (models.Report): The report. - """ - - reported_by_profile: models.Profile = find( - lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss - ).profile - - super().__init__( - color=constants.EMBED_COLOR, - title="Report", - description=f"{reported_by_profile.link} reported a call.", - fields=[ - discord.EmbedField(name="Reason", value=report.reason), - discord.EmbedField( - name="Participants", - value=( - f"Channel 1: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id == report.call.call_rss[0].channel_id, report.call.call_rss)))}\n" - f"Channel 2: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id != report.call.call_rss[0].channel_id, report.call.call_rss)))}" - ), - ), - discord.EmbedField( - name="Duration", - value=f"{formatting.time_to_text(report.call.ended_at - report.call.started_at)}, from to .", - ), - ], - author=bot.embed_author, - footer=discord.EmbedFooter( - text=f"Call ID: {report.call.id} | Report ID: {report.id}" - ), - thumbnail=bot.user.display_avatar.url, - ) diff --git a/payphone/cogs/mods/modals.py b/payphone/cogs/mods/modals.py deleted file mode 100644 index a2edc6d..0000000 --- a/payphone/cogs/mods/modals.py +++ /dev/null @@ -1,90 +0,0 @@ -import discord -from discord import errors - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.core import formatting -from payphone.cogs.mods import embeds - - -class BanUserModal(discord.ui.Modal): - """Modal sent to ban a user.""" - - def __init__( - self, bot: bot.Payphone, profile: models.Profile, report_id: int | None = None - ) -> None: - """Initializes the modal. - - Args: - bot (bot.Bot): The bot. - report_id (int, optional): The ID of the report, if any. - profile (models.Profile): The profile of the user to ban. - """ - - self.bot = bot - self.profile = profile - self.report_id = report_id - - super().__init__( - discord.ui.InputText( - style=discord.InputTextStyle.long, - label="Reason", - max_length=500, - required=False, - ), - discord.ui.InputText( - label="Duration", placeholder="30d", value="30d", required=False - ), - title=f"Ban {profile.name}", - ) - - @transactions.atomic() - async def callback(self, interaction: discord.Interaction): - await interaction.response.defer() - - duration = None - - if self.children[1].value: - try: - duration = formatting.parse_timedelta(self.children[1].value) - - except ValueError: - await interaction.response.send_message( - f"❌ The ban's duration (`{self.children[1].value}`) is not a valid time. Time must be formatted like `28d 3h 5s`." - ) - return - - ban = await self.bot.ban( - self.profile.user_id, - duration, - self.children[0].value, - interaction.user.id, - self.report_id - ) - - try: - notification_message = await ( - await self.bot.get_or_fetch_user(self.profile.user_id) - ).send( - embed=embeds.BanNotificationEmbed(self.bot, ban) - ) - - ban.notification_message_id = notification_message.id - await ban.save() - - except errors.DiscordException: - pass - - message = f"✅ **{self.profile.name}** (User ID: {self.profile.user_id}) was banned by {interaction.user.mention}" - - if ban.expires_at is not None: - message += f"{f" for `{formatting.time_to_text(duration)}`" if duration is not None else ""} until ()" - - else: - message += " permanently" - - message += "." - - await interaction.channel.send(message) diff --git a/payphone/cogs/mods/views.py b/payphone/cogs/mods/views.py deleted file mode 100644 index 5e09e6f..0000000 --- a/payphone/cogs/mods/views.py +++ /dev/null @@ -1,233 +0,0 @@ -import discord -from discord import errors -from discord.ext import pages -from discord.utils import find - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.core import constants, formatting as fmt -from payphone.cogs.mods import modals, views, embeds - - -class ReportView(discord.ui.View): - """The view sent together with the report embed.""" - - def __init__( - self, bot: bot.Payphone, report: models.Report, remaining_reports: int - ) -> None: - """Initializes the view. - - Args: - bot (bot.Payphone): The bot. - report (models.Report): The report. - remaining_reports (int): The amount of reports that need to be reviewed remaining. - """ - - super().__init__(timeout=900, disable_on_timeout=True) - - self.bot = bot - self.report = report - - option_labels = set( - f"Ban {rs.profile.name} [ID: {rs.user_id}]" for rs in report.call.call_rss - ) - - actions = discord.ui.Select( - placeholder="Actions", - options=[discord.SelectOption(label=label) for label in option_labels], - ) - - async def actions_callback(interaction: discord.Interaction) -> None: - # interaction.data = {'values': ['Ban Nyeki (JPP)'], 'custom_id': 'staff_call_report:actions', 'component_type': 3} - value = interaction.data["values"][0] - - user_id = int(value.rsplit("[ID: ", 1)[1][:-1]) - - await interaction.response.send_modal( - modals.BanUserModal( - bot, - find( - lambda rs: rs.user_id == user_id, report.call.call_rss - ).profile, - report.id, - ) - ) - - actions.callback = actions_callback - - self.add_item(actions) - self.add_item( - discord.ui.Button( - label=f"Remaining: {remaining_reports}", disabled=True, row=2 - ) - ) - - @discord.ui.button(label="Message Logs", style=discord.ButtonStyle.gray, row=2) - async def message_logs( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - messages = ( - await models.Message.filter(call=report.call) - .order_by("id") - .prefetch_related("contents") - ) - - logs = [] - - reporter_channel_id = find( - lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss - ).channel_id - - profiles_to_relationship_mapping = { - rs.profile_id: rs for rs in report.call.call_rss - } - - last_user_id = None - last_channel_id = None - - current_block = "" - - for message in messages: - is_new_block = ( - message.user_id != last_user_id or last_channel_id != message.channel_id - ) - - if is_new_block: - logs.append(current_block) - current_block = "" - - current_block += f"{"+" if message.channel_id == reporter_channel_id else "-"} {f"{profiles_to_relationship_mapping[message.profile_id].profile.name}{f" (Masked as {profiles_to_relationship_mapping[message.profile_id].mask_name})" if profiles_to_relationship_mapping[message.profile_id].is_masked else ""}" if message.profile_id in profiles_to_relationship_mapping else "Unknown"}:" - - for content in message.contents: - current_block += ( - f"\n{"!" if not content.was_propagated else "X" if message.was_deleted else " "} {content.content}" - ) - - last_user_id = message.user_id - last_channel_id = message.channel_id - - log_pages = [] - - for current_block in logs: - if len(log_pages) > 0 and len(log_pages[-1]) + len(current_block) < 1000: - log_pages[-1] += "\n\n" + current_block.replace( - "```", - f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", - ) - - else: - log_pages.append( - current_block.replace( - "```", - f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", - ) - ) - - await pages.Paginator( - [f"```diff\n{page}\n```" for page in log_pages], - timeout=300, - ).respond(interaction, ephemeral=True) - - @discord.ui.button(label="Accept", style=discord.ButtonStyle.green, row=2) - @transactions.atomic() - async def accept( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - report.status = models.ReportStatus.ACCEPTED - report.reviewed_by_id = interaction.user.id - await report.save() - - next_report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - banned_user_ids = ( - await models.Ban.filter(report=report) - .distinct() - .values_list("user_id", flat=True) - ) - - if next_report is None: - await interaction.response.edit_message( - content="✅ No reports left, have this cookie 🍪 !!", - embed=None, - view=None, - ) - - else: - await interaction.response.edit_message( - embed=embeds.ReportEmbed(self.bot, next_report), - view=views.ReportView(self.bot, next_report, total), - ) - - if len(banned_user_ids) > 0: - profiles = [ - rs.profile - for rs in report.call.call_rss - if rs.user_id in banned_user_ids - ] - - try: - await (await self.bot.get_or_fetch_user(report.reported_by_id)).send( - f"🔔 Your report for call `{report.call_id}` helped us ban " - + fmt.text_join( - [ - ( - f"`{p.name}`" - if p.user_id != report.reported_by_id - else "`you`" - ) - for p in profiles - ] - ) - + ". Thank you for your report!" - ) - - except errors.DiscordException: - pass - - @discord.ui.button(label="Decline", style=discord.ButtonStyle.red, row=2) - @transactions.atomic() - async def decline( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - report.status = models.ReportStatus.DECLINED - report.reviewed_by_id = interaction.user.id - await report.save() - - report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - if report is None: - await interaction.response.edit_message( - content="✅ No reports left, have this cookie 🍪 !!", - embed=None, - view=None, - ) - return - - await interaction.response.edit_message( - embed=embeds.ReportEmbed(self.bot, report), - view=views.ReportView(self.bot, report, total), - ) diff --git a/payphone/cogs/users/cog.py b/payphone/cogs/users/cog.py deleted file mode 100644 index 896b148..0000000 --- a/payphone/cogs/users/cog.py +++ /dev/null @@ -1,592 +0,0 @@ -import io - -from datetime import datetime, timezone - -import discord -from discord.ext import commands, pages, tasks -from discord.utils import find - -from tortoise import transactions, functions, expressions, exceptions - -import colorthief - -import httpx - -from payphone import bot -from payphone.db import models, utils -from payphone.core import constants -from payphone.cogs import base -from payphone.cogs.users import views - - -class Users(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - self.update_leaderboard.add_exception_type(exceptions.ConfigurationError) - self.update_leaderboard.start() - - def cog_unload(self) -> None: - self.update_leaderboard.cancel() - - @tasks.loop(minutes=15) - async def update_leaderboard(self) -> None: - """Updates the leaderboard.""" - - calls = ( - await models.Call.annotate(messages_count=functions.Count("messages")) - .filter( - started_at__month=datetime.now(timezone.utc).month, - started_at__year=datetime.now(timezone.utc).year, - messages_count__gte=25, - ) - .prefetch_related("call_rss", "call_rss__user") - ) - - # ID to count - users_count_map: dict[int, int] = {} - - users_map: dict[int, models.User] = { - rs.user.id: rs.user for call in calls for rs in call.call_rss - } - - for call in calls: - for rs in call.call_rss: - if rs.user_id in users_count_map: - users_count_map[rs.user_id] += 1 - - else: - users_count_map[rs.user_id] = 0 - - user_counts = list(users_count_map.items()) - - user_counts.sort(key=lambda i: i[1], reverse=True) - - for i, user_map in enumerate(user_counts): - users_map[user_map[0]].leaderboard_position = i + 1 - users_map[user_map[0]].leaderboard_calls_count = user_map[1] - - await models.User.bulk_update( - users_map.values(), ["leaderboard_position", "leaderboard_calls_count"], 500 - ) - - @commands.Cog.listener() - @transactions.atomic() - async def on_user_update(self, _before: discord.User, after: discord.User) -> None: - """Updates a user's Profile. - - Args: - _before (discord.User): The user before the change. - after (discord.User): The user after the change. - """ - - profile = await models.Profile.get_or_none(user_id=after.id, server_id=None) - - is_updated = False - - if profile is None: - return - - if profile.avatar_url != after.display_avatar.url: - async with httpx.AsyncClient() as client: - response = await client.get(after.display_avatar.url) - - if response.status_code not in range(200, 300): - return - - f = io.BytesIO(response.content) - - (r, g, b) = colorthief.ColorThief(f).get_color(quality=1) - - profile.color = (r << 16) | (g << 8) | b - - is_updated = True - - if profile.name != after.display_name: - profile.name = after.display_name - is_updated = True - - if is_updated: - await profile.save() - - @commands.Cog.listener() - @transactions.atomic() - async def on_member_update( - self, _before: discord.Member, after: discord.Member - ) -> None: - """Updates a member's profile. - - Args: - _before (discord.Member): The member's profile before the update. - after (discord.Member): The member's profile after the update. - """ - - profile = await models.Profile.get_or_none( - user_id=after.id, server_id=after.guild.id - ) - - is_updated = False - - if profile is None: - return - - if profile.avatar_url != after.display_avatar.url: - async with httpx.AsyncClient() as client: - response = await client.get(after.display_avatar.url) - - if response.status_code not in range(200, 300): - return - - f = io.BytesIO(response.content) - - (r, g, b) = colorthief.ColorThief(f).get_color(quality=1) - - profile.color = (r << 16) | (g << 8) | b - - is_updated = True - - if profile.name != after.display_name: - profile.name = after.display_name - is_updated = True - - if is_updated: - await profile.save() - - @commands.command() - @transactions.atomic() - async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None: - """ - Hide your identity during calls with a mask. - - Options: - - `[]`: On or off. If not set, your current status and mask will be displayed. - """ - - 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: - message += f" You're currently being displayed as **{call_rs.mask_emoji} {call_rs.display_name}**." - - 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 toggle 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 toggle: - message += f" Your profile will be 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: - if toggle: - message += f" Use `{ctx.clean_prefix}mask off` to turn it off." - - else: - message += f" Use `{ctx.clean_prefix}mask on` to turn it on." - - await ctx.send(message) - - @commands.command() - @transactions.atomic() - async def history(self, ctx: commands.Context) -> None: - """ - See your calls history. Links will take you to the beginning of the call. - """ - - 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") - .order_by("-id") - .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) - .exclude(propagation_id=None) - .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"- `ID: {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( - color=profile.color, - 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) - - @commands.command() - @transactions.atomic() - async def me(self, ctx: commands.Context) -> None: - """ - Shows your user statistics. - """ - - user, profile = await utils.get_user(ctx.author) - - messages_count = await models.Message.filter(user=user).count() - - channel_ids = ( - await models.CallUserChannelRelationship.filter(user=user) - .distinct() - .values_list("channel_id", flat=True) - ) - call_ids = ( - await models.CallUserChannelRelationship.filter(user=user) - .distinct() - .values_list("call_id", flat=True) - ) - - people_met_count = await models.CallUserChannelRelationship.filter( - call_id__in=call_ids, channel_id__not_in=channel_ids - ).count() - ban = ( - await models.Ban.filter( - expressions.Q( - expires_at__gt=datetime.now(timezone.utc), - expires_at=None, - join_type="OR", - ), - user_id=user.id, - ) - .limit(1) - .first() - ) - - reviewed_reports = ( - (await models.Report.filter(reviewed_by_id=user.id).count()) - if user.is_moderator - else None - ) - bans_made = ( - (await models.Ban.filter(banned_by_id=user.id).count()) - if user.is_moderator - else None - ) - bans_unmade = ( - (await models.Ban.filter(unbanned_by_id=user.id).count()) - if user.is_moderator - else None - ) - - embed = discord.Embed( - color=profile.color or constants.EMBED_COLOR, - timestamp=datetime.now(timezone.utc), - fields=[ - discord.EmbedField( - name="Messages", value=str(messages_count), inline=True - ), - discord.EmbedField(name="Calls", value=len(call_ids), inline=True), - discord.EmbedField( - name="People Met", value=people_met_count, inline=True - ), - discord.EmbedField( - name="Joined At", - value=f" ", - inline=True, - ), - discord.EmbedField( - name="Leaderboard", - value=( - f"#{user.leaderboard_position} (Monthly)" - if user.leaderboard_position is not None - else "*No calls yet.*" - ), - inline=True, - ), - discord.EmbedField( - name="Banned", - value=( - "*Not banned.*" - if ban is None - else ( - "*Permanent.*" - if ban.expires_at is None - else f"Until ()" - ) - ), - inline=True, - ), - ] - + ( - [ - discord.EmbedField( - name="Reviewed Reports", value=reviewed_reports, inline=True - ), - discord.EmbedField(name="Bans Made", value=bans_made, inline=True), - discord.EmbedField( - name="Bans Unmade", value=bans_unmade, inline=True - ), - ] - if user.is_moderator - else [] - ), - author=discord.EmbedAuthor(name=profile.name, icon_url=profile.avatar_url), - footer=discord.EmbedFooter( - text="Thank you for using Payphone! Leaderboard updates every 15 minutes." - ), - thumbnail=profile.avatar_url, - ) - - await ctx.send(embed=embed, view=views.MeView(user)) - - @commands.command(aliases=["l", "leader", "ranking"]) - async def leaderboard(self, ctx: commands.Context, board: str = "users") -> None: - """ - Shows the leaderboard for the month. - - Options: - - `[]`: The board to show (users/servers). Defaults to users. - """ - - users_board_options = ["u", "user", "users"] - server_board_options = ["s", "server", "servers", "g", "guild", "guilds"] - - board = board.lower() - - if board not in users_board_options + server_board_options: - await ctx.send( - f"❌ This leaderboard does not exist. The leaderboards are `servers` and `users`, for example `{ctx.clean_prefix}leaderboard users`." - ) - return - - table = "" - table_name = "users" if board in users_board_options else "servers" - - if board in users_board_options: - top_users = ( - await models.User.filter(leaderboard_position__range=[1, 5]) - .prefetch_related("profiles") - .order_by("leaderboard_position") - .limit(5) - ) - user_rows = [ - f"**#{pos + 1} - {user.profiles[0].name}**{" (You!)" if user.id == ctx.author.id else ""}\nParticipated in {user.leaderboard_calls_count} calls." - for pos, user in enumerate(top_users) - ] - - table = "__**Top Users:**__\n\n" + "\n\n".join(user_rows) - - else: - top_servers = ( - await models.Server.filter(leaderboard_position__range=[1, 5]) - .order_by("leaderboard_position") - .limit(5) - ) - server_rows = [ - f"**#{pos + 1} - {server.name or "Updating..."}{f" · .gg/{server.vanity_code}" if server.vanity_code else ""}**{" (Here!)" if ctx.guild is not None and server.id == ctx.guild.id else ""}\nParticipated in {server.leaderboard_calls_count} calls." - for pos, server in enumerate(top_servers) - ] - - table = "__**Top Servers:**__\n\n" + "\n\n".join(server_rows) - - embed = discord.Embed( - color=constants.EMBED_COLOR, - title=f"Monthly Leaderboard ({datetime.now(timezone.utc).strftime("%B")})", - description=( - f"Based on the amount of calls {table_name} have participated in with more than 25 " - "messages sent (to prevent spam).\n\n" - ) - + table, - author=self.bot.embed_author, - footer=discord.EmbedFooter( - text="The leaderboard is updated every 15 minutes." - ), - thumbnail=self.bot.user.display_avatar.url, - ) - - await ctx.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(Users(b)) diff --git a/payphone/cogs/users/views.py b/payphone/cogs/users/views.py deleted file mode 100644 index c7c6632..0000000 --- a/payphone/cogs/users/views.py +++ /dev/null @@ -1,69 +0,0 @@ -import discord - -from payphone.db import models - - -class MeView(discord.ui.View): - status: int = 0 - - def __init__(self, user: models.User) -> None: - """Initializes the view. - - Args: - user (models.user): The user to show the view for. - """ - - super().__init__(disable_on_timeout=True) - - async def callback(interaction: discord.Interaction) -> None: - """Handles the interaction of the button.""" - - if interaction.user.id != user.id: - await interaction.response.send_message( - "❌ You cannot delete other person's account.", ephemeral=True - ) - return - - if self.status == 0: - self.children[0].label = "Confirm?" - await interaction.response.edit_message(view=self) - - self.status += 1 - return - - user.is_deleting = True - await user.save() - - self.disable_all_items() - self.children[0].label = "Your account will be deleted" - await interaction.response.send_message( - ( - "👋 Your account was scheduled for deletion. It may take up to 1 hour for " - "your account information to be deleted.\n\n" - "What will be deleted:\n" - "- Your messages\n" - "- Your calls\n" - "- Your profiles and user information\n" - "- Your stats\n" - "- Your reports\n\n" - "What won't be deleted:\n" - "- Your bans and ban history" - ), - ephemeral=True, - ) - await interaction.message.edit(view=self) - - if not user.is_deleting: - button = discord.ui.Button( - style=discord.ButtonStyle.danger, label="Delete My Activity Data" - ) - button.callback = callback - - else: - button = discord.ui.Button( - style=discord.ButtonStyle.danger, - label="Your account is being deleted", - disabled=True, - ) - - self.add_item(button) diff --git a/payphone/cogs/utility/cog.py b/payphone/cogs/utility/cog.py deleted file mode 100644 index c111e46..0000000 --- a/payphone/cogs/utility/cog.py +++ /dev/null @@ -1,49 +0,0 @@ -from datetime import datetime, timezone - -import discord -from discord.ext import commands, tasks - -from payphone import bot -from payphone.cogs import base -from payphone.core import config, constants, checks - - -class Utility(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - @commands.command() - @commands.cooldown(1, 60, commands.BucketType.user) - @checks.is_unbanned() - async def suggest(self, ctx: commands.Context, *, suggestion: str) -> None: - """ - Make a suggestion to improve Payphone. - """ - - embed = discord.Embed( - color=constants.EMBED_COLOR, - title="Suggestion", - description=suggestion, - timestamp=datetime.now(timezone.utc), - author=discord.EmbedAuthor( - name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url - ), - footer=discord.EmbedFooter(text=ctx.author.name), - thumbnail=self.bot.user.display_avatar.url, - ) - - await self.bot.get_partial_messageable(config.SUGGESTIONS_CHANNEL_ID).send( - embed=embed - ) - - await ctx.send("✅ Thank you for your suggestion!") - - -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)) diff --git a/payphone/core/calls.py b/payphone/core/calls.py new file mode 100644 index 0000000..13e9a89 --- /dev/null +++ b/payphone/core/calls.py @@ -0,0 +1,625 @@ +from dataclasses import dataclass + +import orjson + +from payphone.db import models +from payphone.bot import Payphone +from payphone.core import masks + + +@dataclass +class Participant: + """A participant in Payphone.""" + + _bot: Payphone + + id: int + name: str + avatar_url: str + mask: str + is_masked: bool + channel_id: int + user: models.User | None = None + + @property + def display_name(self) -> str: + """Gets the display name of the participant. + + Returns: + str: The display name of the participant. + """ + + return self.name if not self.is_masked else masks.get_mask_name(self.mask) + + @property + def display_avatar_url(self) -> str: + """Gets the display avatar URL of the participant. + + Returns: + str: The display avatar URL of the participant. + """ + + return ( + self.avatar_url + if not self.is_masked + else masks.get_mask_image_url(self.mask) + ) + + +@dataclass +class Message: + id: int + call_id: int + channel_id: int + propagation_id: int + propagation_channel_id: int + author_id: int + + +@dataclass +class Call: + """A call in Payphone.""" + + _bot: Payphone + + id: int + channels: tuple[int, int] + masks: list[str] + + async def set_message( + self, id: int, channel_id: int, propagation_id: int, author_id: int + ) -> None: + """Sets a message as part of a call. + + Args: + id (int): The ID of the message to set. + channel_id (int): The ID of the channel to set the message in. + propagation_id (int): The ID of the propagation message. + author_id (int): The ID of the author of the message. + """ + + pipe = self._bot.redis.pipeline() + + pipe.hset("calls:messages", id, self.id) + pipe.hset("calls:messages:channels", id, channel_id) + pipe.hset("calls:messages:propagations", id, propagation_id) + pipe.hset("calls:messages:reversed", propagation_id, id) + pipe.hset("calls:messages:authors", id, author_id) + pipe.hset("calls:last-message", self.id, "") + pipe.hexpire("calls:messages", 15 * 60, id) + pipe.hexpire("calls:messages:channels", 15 * 60, id) + pipe.hexpire("calls:messages:propagations", 15 * 60, id) + pipe.hexpire("calls:messages:reversed", 15 * 60, propagation_id) + pipe.hexpire("calls:messages:authors", 15 * 60, id) + pipe.hexpire("calls:last-message", 15 * 60, self.id) + + await pipe.execute() + + async def get_message(self, id: int) -> Message | None: + """Gets a message as part of a call. + + Args: + id (int): The ID of the message to get. + + Returns: + Message | None: The message that was set. + """ + + call_id = await self._bot.redis.hget("calls:messages", id) + + if call_id is None: + return + + channel_id = await self._bot.redis.hget("calls:messages:channels", id) + propagation_id = await self._bot.redis.hget("calls:messages:propagations", id) + author_id = await self._bot.redis.hget("calls:messages:authors", id) + + return Message( + id=id, + call_id=int(call_id), + channel_id=int(channel_id), + propagation_id=int(propagation_id), + propagation_channel_id=( + self.channels[0] + if int(channel_id) != self.channels[0] + else self.channels[1] + ), + author_id=int(author_id), + ) + + async def is_message_from_call(self, id: int) -> bool: + """Checks if a message is from a call. + + Args: + id (int): The ID of the message to check. + + Returns: + bool: True if the message is from the call, False otherwise. + """ + + return bool(await self._bot.redis.hexists("calls:messages", id)) or bool( + await self._bot.redis.hexists("calls:messages:reversed", id) + ) + + async def get_message_propagation(self, id: int) -> tuple[int, int] | None: + """Gets a message as part of a call. + + Args: + id (int): The ID of the message to get. + + Returns: + tuple[int, int]: The channel and propagation ID of the message. + """ + + pipe = self._bot.redis.pipeline() + + pipe.hget("calls:messages:channels", id) + pipe.hget("calls:messages:propagations", id) + + channel_id, propagation_id = await pipe.execute() + + if channel_id is None or propagation_id is None: + return + + propagation_channel_id = ( + self.channels[0] + if int(channel_id) != self.channels[0] + else self.channels[1] + ) + + return int(propagation_channel_id), int(propagation_id) + + async def set_participant( + self, user_id: int, user_name: str, user_avatar_url: str, channel_id: int + ) -> tuple[Participant, bool]: + """Sets a participant in a call. + + Args: + user_id (int): The ID of the user to set. + user_name (str): The name of the user to set. + user_avatar_url (str): The avatar URL of the user to set. + channel_id (int): The ID of the channel the user is in. + + Returns: + (Participant, bool): The participant that was set and whether they changed their masked + status. + """ + + participant = await self.get_participant(user_id) + + user = await models.User.get_cache_or_db(self._bot.redis, user_id) + mask = ( + masks.get_random_mask(self.masks) + if participant is None + else participant.mask + ) + + data = orjson.dumps( + { + "name": user_name, + "avatar_url": user_avatar_url, + "mask": mask, + "is_masked": user.is_masked, + "channel_id": channel_id, + } + ) + + await self._bot.redis.hset(f"calls:{self.id}:participants", user_id, data) + await self._bot.redis.hexpire(f"calls:{self.id}:participants", 3600, user_id) + await self._bot.redis.sadd(f"calls:{self.id}:masks", mask) + await self._bot.redis.expire(f"calls:{self.id}:masks", 3600) + + return Participant( + _bot=self._bot, + id=user_id, + name=user_name, + avatar_url=user_avatar_url, + mask=mask, + is_masked=user.is_masked, + channel_id=channel_id, + user=user, + ), (user.is_masked != participant.is_masked if participant else True) + + async def get_participant(self, user_id: int) -> Participant | None: + """Gets a participant in a call. + + Args: + user_id (int): The ID of the user to get. + + Returns: + Participant | None: The participant that was set. + """ + + data = await self._bot.redis.hget(f"calls:{self.id}:participants", user_id) + + if data is None: + return + + data = orjson.loads(data) + + return Participant( + _bot=self._bot, + id=user_id, + name=data["name"], + avatar_url=data["avatar_url"], + mask=data["mask"], + is_masked=data["is_masked"], + channel_id=data["channel_id"], + ) + + async def get_participants(self) -> list["Participant"]: + """Gets all participants in a call. + + Returns: + list[Participant]: The participants in the call. + """ + + participants = [] + + data = await self._bot.redis.hgetall(f"calls:{self.id}:participants") + + for user_id, data in data.items(): + data = orjson.loads(data) + + participants.append( + Participant( + _bot=self._bot, + id=int(user_id), + name=data["name"], + avatar_url=data["avatar_url"], + mask=data["mask"], + is_masked=data["is_masked"], + channel_id=data["channel_id"], + ) + ) + + return participants + + +class CallHandler: + """Handles calls in Payphone.""" + + def __init__(self, bot: Payphone) -> None: + """Initializes the CallHandler. + + Args: + bot (Payphone): The bot instance. + """ + + self.bot = bot + + async def set_searching(self, channel_id: int, status: bool) -> None: + """Sets the channel as searching for a call. + + Args: + channel_id (int): The ID of the channel to set as searching. + status (bool): The status to set the channel to. + """ + + if status: + await self.bot.redis.sadd("calls:channels:searching", channel_id) + + else: + await self.bot.redis.srem("calls:channels:searching", channel_id) + + async def connect(self) -> list[tuple[int, int]]: + """Connects two channels together. + + Returns: + list[tuple[int, int]]: A list of channel pairs. + """ + + pairs = [] + + while True: + channels = await self.bot.redis.spop("calls:channels:searching", 2) + + if len(channels) < 2: + if len(channels) == 1: + await self.set_searching(channels[0], True) + + break + + last_calls = await self.bot.redis.hmget("last-calls", *channels) + + break_outer = False + + for channel in channels: + if channel in last_calls: + next_channel = await self.bot.redis.spop("calls:channels:searching") + + if next_channel is not None: + other_channel_id = channels[abs(channels.index(channel) - 1)] + await self.set_searching(other_channel_id, True) + + channels[channels.index(channel)] = next_channel + + else: + for channel in channels: + await self.set_searching(channel, True) + + break_outer = True + break + + if break_outer: + break + + pairs.append(tuple(channels)) + + mapping = {} + reverse_mapping = {} + + for pair in pairs: + last_call_id = await self.bot.redis.incr("calls:calls:count") + + mapping[pair[0]] = last_call_id + mapping[pair[1]] = last_call_id + + reverse_mapping[last_call_id] = f"{pair[0]}:{pair[1]}" + + pipe = self.bot.redis.pipeline(transaction=True) + + if mapping: + pipe.hset("calls:channels:active", mapping=mapping) + pipe.hset("calls:calls:active", mapping=reverse_mapping) + + for pair in pairs: + pipe.hmset("last-calls", {pair[0]: pair[1], pair[1]: pair[0]}) + pipe.hset("calls:last-message", mapping[pair[0]], "") + pipe.hexpire("calls:last-message", 60, mapping[pair[0]]) + + await pipe.execute() + + return pairs + + async def expire(self) -> list[tuple[int, int]]: + """ + Ends all calls that have been inactive for more than 15 seconds. + """ + + active_calls = await self.bot.redis.hgetall("calls:calls:active") + + pairs = [] + + pipe = self.bot.redis.pipeline(transaction=True) + + for call_id, channels in active_calls.items(): + channels = [int(c) for c in channels.split(":")] + + last_message = await self.bot.redis.hget("calls:last-message", call_id) + + if last_message is not None: + continue + + pipe.hdel("calls:channels:active", *channels) + pipe.hdel("calls:calls:active", call_id) + pipe.srem("calls:channels:searching", *channels) + + pairs.append(tuple(channels)) + + await pipe.execute() + + return pairs + + async def is_searching(self, channel_id: int) -> bool: + """Checks if a channel is searching for a call. + + Args: + channel_id (int): The ID of the channel to check. + + Returns: + bool: True if the channel is searching for a call, False otherwise. + """ + + return bool( + await self.bot.redis.sismember("calls:channels:searching", channel_id) + ) + + async def is_on_call(self, channel_id: int) -> bool: + """Checks if a channel is on a call. + + Args: + channel_id (int): The ID of the channel to check. + + Returns: + bool: True if the channel is on a call, False otherwise. + """ + + return await self.bot.redis.hexists("calls:channels:active", channel_id) + + async def get_call_by_channel_id(self, channel_id: int) -> Call | None: + """Gets the call that a channel is in. + + Args: + channel_id (int): The ID of the channel to get the call for. + + Returns: + Call | None: The pair of channels that are connected. + """ + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + return + + return await self.get_call(int(call_id)) + + async def get_call(self, call_id: int) -> Call | None: + """Gets a call by its ID. + + Args: + call_id (int): The ID of the call to get. + + Returns: + Call | None: The call that was found. + """ + + used_masks = await self.bot.redis.smembers(f"calls:{call_id}:masks") + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + return Call(_bot=self.bot, id=call_id, channels=channels, masks=used_masks) + + async def hangup(self, channel_id: int) -> tuple[int, int] | tuple[int]: + """Ends a call in a channel. + + Args: + channel_id (int): The ID of the channel to end the call in. + + Returns: + tuple[int, int] | tuple[int]: The pair of channels that were connected. + """ + + await self.set_searching(channel_id, False) + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + # Channel is searching. + await self.set_searching(channel_id, False) + return (channel_id,) + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + for channel in channels: + await self.set_searching(channel, False) + + await self.bot.redis.hdel("calls:channels:active", *channels) + await self.bot.redis.hdel("calls:calls:active", call_id) + + return tuple(channels) + + async def skip(self, channel_id: int) -> tuple[int, int]: + """Skips a channel in the call queue. + + Args: + channel_id (int): The ID of the channel to skip. + """ + + await self.set_searching(channel_id, True) + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + return + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + for channel in channels: + if channel != channel_id: + await self.set_searching(channel, False) + + await self.bot.redis.hdel("calls:channels:active", *channels) + + return tuple(channels) + + async def get_channel_webhook_url(self, channel_id: int) -> str: + """Gets the webhook for a channel. + + Args: + channel_id (int): The ID of the channel to get the webhook for. + + Returns: + str: The URL of the webhook. + """ + + cached_url = await self.bot.redis.hget("calls:channels:webhooks", channel_id) + + if cached_url: + return cached_url + + channel = await self.bot.get_or_fetch_channel(channel_id) + + if channel is None: + return + + for webhook in await channel.webhooks(): + if webhook.name == "Payphone" and webhook.token is not None: + await self.bot.redis.hset( + "calls:channels:webhooks", channel_id, webhook.url + ) + return webhook.url + + webhook = await channel.create_webhook(name="Payphone") + + await self.bot.redis.hset("calls:channels:webhooks", channel_id, webhook.url) + + return webhook.url + + async def get_message(self, message_id: int) -> Message | None: + """Gets a message sent in a call. + + Args: + message_id (int): The ID of the message to get. + + Returns: + Message | None: The message that was sent. + """ + + call_id = await self.bot.redis.hget("calls:messages", message_id) + + if call_id is None: + return + + call = await self.get_call(int(call_id)) + + return await call.get_message(message_id) + + async def get_message_author_id(self, id: int) -> int | None: + """Gets the author of a message as part of a call. + + Args: + id (int): The ID of the message to get. + + Returns: + int: The author ID of the message. + """ + + pipe = self.bot.redis.pipeline() + + pipe.hget("calls:messages", id) + pipe.hget("calls:messages:reversed", id) + + call_id, message_id = await pipe.execute() + + if call_id is None and message_id is None: + return + + message_id = int(message_id) if message_id else id + + author_id = await self.bot.redis.hget("calls:messages:authors", message_id) + + return int(author_id) if author_id else None + + async def get_original_message_id(self, id: int) -> int | None: + """ + Get the original message ID of a message in a call. + + Args: + id (int): The ID of the message to get. + + Returns: + int: The original message ID of the message. + """ + + message_id = await self.bot.redis.hget("calls:messages:reversed", id) + + if message_id: + return int(message_id) + + return None diff --git a/payphone/core/censorship.py b/payphone/core/censorship.py index 99b9594..e850ab1 100644 --- a/payphone/core/censorship.py +++ b/payphone/core/censorship.py @@ -1,536 +1,28 @@ import re -def create_pattern(text: str) -> str: - """Creates regex patterns for words or phrases with possible variations +words = [] - Args: - text (str): The word or phrase +with open("censored.txt", encoding="UTF-8") as f: + for line in f: + words.append(line.strip()) - 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 = [ - create_pattern(word) - for word in [ - "2g1c", - "2 girls 1 cup", - "acrotomophilia", - "alabama hot pocket", - "alaskan pipeline", - "anal", - "anilingus", - "anus", - "apeshit", - "arsehole", - # "ass", - "asshole", - "assmunch", - "auto erotic", - "autoerotic", - "babeland", - "baby batter", - "baby juice", - "ball gag", - "ball gravy", - "ball kicking", - "ball licking", - "ball sack", - "ball sucking", - "bangbros", - "bangbus", - "bareback", - "barely legal", - "barenaked", - "bastard", - "bastardo", - "bastinado", - "bbw", - "bdsm", - "beaner", - "beaners", - "beaver cleaver", - "beaver lips", - "beastiality", - "bestiality", - "big black", - "big breasts", - "big knockers", - "big tits", - "bimbos", - "birdlock", - "bitch", - "bitches", - "black cock", - "blonde action", - "blonde on blonde action", - "blowjob", - "blow job", - "blow your load", - "blue waffle", - "blumpkin", - "bollocks", - "bondage", - "boner", - "boob", - "boobs", - "booty call", - "brown showers", - "brunette action", - "bukkake", - "bulldyke", - "bullet vibe", - "bullshit", - "bung hole", - "bunghole", - "busty", - "butt", - "buttcheeks", - "butthole", - "camel toe", - "camgirl", - "camslut", - "camwhore", - "carpet muncher", - "carpetmuncher", - "chocolate rosebuds", - "cialis", - "circlejerk", - "cleveland steamer", - "clit", - "clitoris", - "clover clamps", - "clusterfuck", - "cock", - "cocks", - "coprolagnia", - "coprophilia", - "cornhole", - "coon", - "coons", - "creampie", - "cum", - "cumming", - "cumshot", - "cumshots", - "cunnilingus", - "cunt", - "darkie", - "date rape", - "daterape", - "deep throat", - "deepthroat", - "dendrophilia", - "dick", - "dildo", - "dingleberry", - "dingleberries", - "dirty pillows", - "dirty sanchez", - "doggie style", - "doggiestyle", - "doggy style", - "doggystyle", - "dog style", - "dolcett", - "domination", - "dominatrix", - "dommes", - "donkey punch", - "double dong", - "double penetration", - "dp action", - "dry hump", - "dvda", - "eat my ass", - "ecchi", - "ejaculation", - "erotic", - "erotism", - "escort", - "eunuch", - "fag", - "faggot", - "fecal", - "felch", - "fellatio", - "feltch", - "female squirting", - "femdom", - "figging", - "fingerbang", - "fingering", - "fisting", - "foot fetish", - "footjob", - "frotting", - "fuck buttons", - # "fuckin", - # "fucking", - "fucktards", - "fudge packer", - "fudgepacker", - "futanari", - "gangbang", - "gang bang", - "gay sex", - "genitals", - "giant cock", - "girl on", - "girl on top", - "girls gone wild", - "goatcx", - "goatse", - "god damn", - "gokkun", - "golden shower", - "goodpoop", - "goo girl", - "goregasm", - "grope", - "group sex", - "g-spot", - "guro", - "hand job", - "handjob", - "hard core", - "hardcore", - "hentai", - "homoerotic", - "honkey", - "hooker", - "horny", - "hot carl", - "hot chick", - "how to kill", - "how to murder", - "huge fat", - "humping", - "incest", - "intercourse", - "jack off", - "jail bait", - "jailbait", - "jelly donut", - "jerk off", - "jigaboo", - "jiggaboo", - "jiggerboo", - "jizz", - "juggs", - "kike", - "kinbaku", - "kinkster", - "kinky", - "knobbing", - "leather restraint", - "leather straight jacket", - "lemon party", - "livesex", - "lolita", - "lovemaking", - "make me come", - "male squirting", - "masturbate", - "masturbating", - "masturbation", - "menage a trois", - "milf", - "missionary position", - "mong", - "motherfucker", - "mound of venus", - "mr hands", - "muff diver", - "muffdiving", - "nambla", - "nawashi", - "negro", - "neonazi", - "nigga", - "nigger", - "nig nog", - "nimphomania", - "nipple", - "nipples", - # "nsfw", - "nsfw images", - "nude", - "nudity", - "nutten", - "nympho", - "nymphomania", - "octopussy", - "omorashi", - "one cup two girls", - "one guy one jar", - "orgasm", - "orgy", - "paedophile", - "paki", - "panties", - "panty", - "pedobear", - "pedophile", - "pegging", - "penis", - "phone sex", - "piece of shit", - "pikey", - "pissing", - "piss pig", - "pisspig", - "playboy", - "pleasure chest", - "pole smoker", - "ponyplay", - "poof", - "poon", - "poontang", - "punany", - "poop chute", - "poopchute", - "porn", - "porno", - "pornography", - "prince albert piercing", - "pthc", - "pubes", - "pussy", - "queaf", - "queef", - "quim", - "raghead", - "raging boner", - "rape", - "raping", - "rapist", - "rectum", - "reverse cowgirl", - "rimjob", - "rimming", - "rosy palm", - "rosy palm and her 5 sisters", - "rusty trombone", - "sadism", - "santorum", - "scat", - "schlong", - "scissoring", - "semen", - "sex", - "sexcam", - "sexo", - "sexy", - "sexual", - "sexually", - "sexuality", - "shaved beaver", - "shaved pussy", - "shemale", - "shibari", - "shitblimp", - "shota", - "shrimping", - "skeet", - "slanteye", - "slut", - "s&m", - "smut", - "snatch", - "snowballing", - "sodomize", - "sodomy", - "spastic", - "spic", - "splooge", - "splooge moose", - "spooge", - "spread legs", - "spunk", - "strap on", - "strapon", - "strappado", - "strip club", - "style doggy", - "suicide girls", - "sultry women", - "swastika", - "swinger", - "tainted love", - "taste my", - "tea bagging", - "threesome", - "throating", - "thumbzilla", - "tied up", - "tight white", - "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", - "tit", - "tits", - "titties", - "titty", - "tongue in a", - "topless", - "tosser", - "towelhead", - "tranny", - "tribadism", - "tub girl", - "tubgirl", - "tushy", - "twat", - "twink", - "twinkle", - "two girls one cup", - "undressing", - "upskirt", - "urethra play", - "urophilia", - "vagina", - "venus mound", - "viagra", - "vibrator", - "violet wand", - "vorarephilia", - "voyeur", - "voyeurweb", - "voyuer", - "vulva", - "wank", - "wetback", - "wet dream", - "white powder", - "whore", - "worldsex", - "wrapping men", - "wrinkled starfish", - "xxx", - # "yaoi", - "yellow showers", - "yiffy", - "zoophilia", - "🖕", - # "cracker", - "honky", - "kike", - "weed", - "cocaine", - "heroin", - "meth", - "ecstasy", - "lsd", - "marijuana", - "opioid", - # "crack", - "self-harm", - "self harm", - "self-injury", - "self injury", - "self-mutilation", - "self mutilation", - "hurting myself", - "abortion", - "rape", - "molest", - "pedophile", - "incest", - "abuse", - "trafficking", - "domestic violence", - "child abuse", - "neglect", - "victim", - "heathen", - "infidel", - "heretic", - "blasphemer", - "pagan", - "zealot", - "cultist", - "fanatic", - "apostate", - "idolater", - "fatty", - "lardass", - "twig", - "beanpole", - "chubby", - "porker", - "skeletor", - "chunk", - "blubber", - # "skinny", - "niga", - "niger", - "nga", - ] -] WORDS = re.compile(r"\w+") PATTERN = re.compile( - f"^({"|".join(BLOCKED_WORDS + [word + "s" for word in BLOCKED_WORDS])})$", + f"^({"|".join(words + [word + "s" for word in words])})$", re.IGNORECASE, ) URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE) -INVITES_PATTERN = re.compile(r"discord.gg/[^\s]+", re.IGNORECASE) +INVITES_PATTERN = re.compile(r"(discord)?.gg/[^\s]+", re.IGNORECASE) -def moderate(message: str) -> str: +def censor(message: str, is_gif_allowed: bool = False) -> str: """Returns the first censored word or pattern found. Args: message (str): The message to moderate. + is_gif_allowed (bool, optional): If GIFs are allowed. Defaults to False. Returns: str | None: The blocked pattern found as it was found. @@ -541,10 +33,13 @@ def moderate(message: str) -> str: if PATTERN.match(word): return word - match = next(URL_PATTERN.finditer(message), None) + for match in URL_PATTERN.finditer(message): + string = message[match.start() : match.end()] - if match is not None: - return message[match.start() : match.end()] + if is_gif_allowed and string.startswith("https://tenor.com/view/"): + continue + + return string match = next(INVITES_PATTERN.finditer(message), None) diff --git a/payphone/core/checks.py b/payphone/core/checks.py index 674b6be..938acc1 100644 --- a/payphone/core/checks.py +++ b/payphone/core/checks.py @@ -1,7 +1,6 @@ from discord.ext import commands -from payphone.db import utils -from payphone.core import exceptions, constants +from payphone.core import exceptions def is_moderator(): @@ -11,9 +10,7 @@ def is_moderator(): if ctx.author.bot: return False - user, _ = await utils.get_user(ctx.author) - - if user.is_moderator: + if await ctx.bot.is_moderator(ctx.author.id): return True raise exceptions.NotAModeratorError() @@ -21,11 +18,11 @@ def is_moderator(): return commands.check(predicate) -def is_unbanned(): +def is_not_banned(): """Checks that the user is not banned.""" async def predicate(ctx: commands.Context) -> bool: - if await ctx.bot.is_banned(ctx.author): + if await ctx.bot.is_banned(ctx.author.id): raise exceptions.BannedError() return True @@ -34,16 +31,12 @@ def is_unbanned(): def is_on_call(): - """Checks that a call is ongoing in the channel.""" + """Checks that the user is on a call.""" async def predicate(ctx: commands.Context) -> bool: - channel_id = ctx.channel.id + call = await ctx.bot.call_handler.get_call_by_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: + if call is None: raise exceptions.NotOnCallError() return True diff --git a/payphone/core/config.py b/payphone/core/config.py deleted file mode 100644 index 0a9e1b2..0000000 --- a/payphone/core/config.py +++ /dev/null @@ -1,40 +0,0 @@ -import os - -from dotenv import load_dotenv - -load_dotenv() - - -TOKEN = os.getenv("TOKEN") - -SHARD_ID = int(os.getenv("SHARD_ID", "0")) -SHARD_COUNT = int(os.getenv("SHARD_COUNT", "1")) - -IS_DEBUG = os.environ.get("IS_DEBUG", "false") == "true" - -TORTOISE_ORM = { - "connections": { - "default": { - "engine": "tortoise.backends.asyncpg", - "credentials": { - "host": "localhost" if IS_DEBUG else "postgresql", - "port": "5432", - "user": "payphone", - "password": "payphone", - "database": "payphone", - }, - "minsize": 5, - "maxsize": 100, - }, - }, - "apps": { - "models": { - "models": ["payphone.db.models", "aerich.models"], - "default_connection": "default", - } - }, -} - -SUGGESTIONS_CHANNEL_ID = int(os.getenv("SUGGESTIONS_CHANNEL_ID", "0")) - -SERVER_INVITE = os.getenv("SERVER_INVITE") diff --git a/payphone/core/constants.py b/payphone/core/constants.py index 591cda2..564ba50 100644 --- a/payphone/core/constants.py +++ b/payphone/core/constants.py @@ -1,228 +1,32 @@ -import enum +import os -from payphone.core import config +try: + import dotenv + dotenv.load_dotenv() -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 - CHAMELEON = 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 +except ImportError: + print("dotenv is not installed. No .env file was loaded.") +IS_DEBUG = os.getenv("IS_DEBUG", "false").lower() == "true" -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 +TOKEN = os.getenv("TOKEN") +if TOKEN is None: + raise RuntimeError( + "No token provided. Make sure to set the TOKEN environment variable." + ) -MASKS_LIST = list(Mask) -CAT_MASKS_LIST = list(CatMask) +SHARD_ID = int(os.getenv("SHARD_ID", "0")) +SHARD_COUNT = int(os.getenv("SHARD_COUNT", "1")) -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`.", - "Emojis will only show if they're from a server Payphone's in.", - "You cannot send links.", - "[Vote for Payphone on top.gg]() to help us reach more users!", - "[Write a review]() 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]() 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](). 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!", - "How high are you on the leaderboard? Use `{prefix}me`, `{prefix}leaderboard`, and `{prefix}leaderboard servers` to check it out!", -] - -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" -MESSAGES_QUEUE = "messages" -MESSAGE_CONTENTS_QUEUE = "message-contents" -MESSAGES_PROPAGATION_MAP = "messages-propagation" - -CALL_MESSAGES_TEMPLATE = "m:%s" -"""`%s`: Call ID. - -The key template for call messages hash. -""" - -CALL_MESSAGE_CONTENTS_TEMPLATE = "mc:%s" -"""`%s`: Call ID. - -The key template for call message contents hash. -""" - -CALL_MESSAGE_CONTENTS_HASH_TEMPLATE = "%s-%s" -"""`%s-%s`: Call ID - Iteration. - -The key template for call message contents hash keys. -""" - -CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE = "mfp:%s" -"""`%s`: Call ID. - -A map from propagation IDs to message IDs. -""" - -CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE = "mci:%s" -"""`%s`: Call ID. - -A map of messages to amount of iterations of their message contents. -""" - -CALL_MESSAGE_REPLIES_TEMPLATE = "mr:%s" -"""`%s`: Call ID. - -A map of message IDs to a `,`-delimited list of message IDs that reply to the message. -""" - -CALL_IS_ACTIVE = "cis:%s" -"""`%s`: Call ID. - -An expiring key that indicates whether the call is still active or not. This key will exist while -the call is still active.""" - -CALL_IDS_TO_FLUSH = "call-ids-to-flush" -"""A set of IDs of calls to flush to the DB.""" - -BAN_TEMPLATE = "b:%s" -PREFIX_TEMPLATE = "p:%s" - -EMBED_COLOR = 0xE2D9D0 +REDIS_HOST = os.getenv("REDIS_HOST", "localhost") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) INVISIBLE_CHARACTER = "\u200B" +BRAILE_CHARACTER = "\u2800" + +PRIMARY_COLOR = 0xE2D9D0 +SUPPORTER_COLOR = 0xAB9EFF +MODERATOR_COLOR = 0xFF8787 +MASK_COLOR = 0xFFC868 diff --git a/payphone/core/converters.py b/payphone/core/converters.py deleted file mode 100644 index aff16f0..0000000 --- a/payphone/core/converters.py +++ /dev/null @@ -1,112 +0,0 @@ -from datetime import timedelta - -from discord.ext import commands -from discord.ext.commands import converter - -from tortoise import expressions - -import orjson - -from payphone.db import models -from payphone.core import exceptions, constants - - -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 DiscordUserID(converter.Converter): - """Converts a URL, ID, or reply into a Discord User ID.""" - - async def convert(self, ctx: commands.Context, argument: str) -> int: - if argument.lower() == "me": - return ctx.author.id - - if argument.isdecimal() and len(argument) in range(17, 20): - return int(argument) - - if len(argument) > 17 and argument[2:-1].isdecimal(): - return int(argument[2:-1]) - - if argument.startswith("https://discord.com/users/") or argument.startswith( - "discord://-/users/" - ): - return int(argument.split("?", 1)[0].rsplit("/", 1)[1]) - - if argument.lower() in ["reply", "."]: - if ctx.message.reference is not None: - ref = ctx.message.reference - - msg = ref.cached_message or await ctx.channel.fetch_message( - ref.message_id - ) - - if msg.author.bot: - row = await models.Message.get_or_none( - expressions.Q(id=msg.id, propagation_id=msg.id, join_type="OR") - ) - - if row is not None: - return row.user_id - - channel = await models.Channel.get_or_none(id=ctx.channel.id) - - if channel is None: - raise converter.BadArgument() - - original_message_id = await ctx.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE - % channel.call_id, - ref.message_id, - ) - - if original_message_id is None: - raise converter.BadArgument() - - original_message_json = await ctx.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, - int(original_message_id), - ) - - if original_message_json is None: - raise converter.BadArgument() - - original_message_dict = orjson.loads(original_message_json) - - return original_message_dict["user_id"] - - return msg.author.id - - user = await models.User.get_or_none(username=argument) - - if user is not None: - return user.id - - raise converter.BadArgument() diff --git a/payphone/core/exceptions.py b/payphone/core/exceptions.py index 3864505..1b02823 100644 --- a/payphone/core/exceptions.py +++ b/payphone/core/exceptions.py @@ -1,21 +1,17 @@ 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.""" + """Raised when the user is not a moderator.""" class BannedError(errors.CheckFailure): - """Raised when the user running the command is banned.""" + """Raised when the user is banned.""" + + +class ChannelDisabledError(errors.CheckFailure): + """Raised when the channel is disabled.""" class NotOnCallError(errors.CheckFailure): - """Raised when the command is ran in a channel with no ongoing calls.""" + """Raised when the user is not on a call.""" diff --git a/payphone/core/formatting.py b/payphone/core/formatting.py index 4d7a717..732b3a7 100644 --- a/payphone/core/formatting.py +++ b/payphone/core/formatting.py @@ -2,11 +2,6 @@ from datetime import timedelta import re -import discord - -from payphone.db import models -from payphone.core import constants - def trim(text: str, length: int = 100) -> str: """Trims a string if it's too long. @@ -152,51 +147,6 @@ def monospace_table( return table -def clean( - message: discord.Message, call_rss: list[models.CallUserChannelRelationship] -) -> str: - """Cleans the message. - - Args: - message (discord.Message): The message to clean. - call_rss (list[models.CallUserChannelRelationship]): The call's relationships. They must - have pre-selected their `profile` relation. - - Returns: - str: The cleaned message content. - """ - - cleaned_message = message.content - - if message.mentions: - for call_rs in call_rss: - cleaned_message = cleaned_message.replace( - f"<@{call_rs.user_id}>", - ( - f"@**{call_rs.display_name}**" - if message.channel.id == call_rs.channel_id - else f"@**{call_rs.profile.name}**" - ), - ) - - for user in message.mentions: - cleaned_message = cleaned_message.replace(user.mention, "@*Not in call*") - - for channel in message.channel_mentions: - cleaned_message = cleaned_message.replace( - f"<#{channel.id}>", f"#**{channel.name}**" - ) - - for role in message.role_mentions: - cleaned_message = cleaned_message.replace(f"<@&{role.id}>", f"@**{role.name}**") - - cleaned_message = cleaned_message.replace( - "@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone" - ).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here") - - return cleaned_message - - def time_to_text(time_diff: timedelta) -> str: """Converts a timedelta to its string representation. diff --git a/payphone/core/help_command.py b/payphone/core/help_command.py index 271e47a..116b6d6 100644 --- a/payphone/core/help_command.py +++ b/payphone/core/help_command.py @@ -4,7 +4,7 @@ import discord from discord.ext import commands from payphone.cogs import base -from payphone.core import formatting, config, constants, typing_utils +from payphone.core import formatting, constants, utils class HelpView(discord.ui.View): @@ -30,7 +30,7 @@ class HelpView(discord.ui.View): discord.ui.Button( label="Server", style=discord.ButtonStyle.gray, - url=config.SERVER_INVITE, + url="https://discord.gg/payphone", ), timeout=None, ) @@ -46,7 +46,9 @@ class HelpCommand(commands.HelpCommand): if cog is not None and not cog.show_in_help: continue - bot_commands += cog_commands + for command in cog_commands: + if isinstance(command, commands.Command): + bot_commands += [command] embed = discord.Embed( author=self.context.bot.embed_author, @@ -64,7 +66,7 @@ class HelpCommand(commands.HelpCommand): ), ) ], - color=constants.EMBED_COLOR, + color=constants.PRIMARY_COLOR, footer=discord.EmbedFooter( text=f"Use {self.context.clean_prefix}help to get more information about a specific command." ), @@ -83,8 +85,8 @@ class HelpCommand(commands.HelpCommand): if isinstance(command, discord.SlashCommand) else [ ( - f" [<{name}>]" - if typing_utils.is_optional(info.annotation) + f" [{name}]" + if utils.is_optional(info.annotation) else f" <{name}>" ) for name, info in command.params.items() @@ -114,7 +116,7 @@ class HelpCommand(commands.HelpCommand): + " Command", description=command_description, thumbnail=self.context.bot.user.display_avatar.url, - color=constants.EMBED_COLOR, + color=constants.PRIMARY_COLOR, fields=[ discord.EmbedField( name="Usage", diff --git a/payphone/core/logging.py b/payphone/core/logging.py index ae117f7..d08f8c8 100644 --- a/payphone/core/logging.py +++ b/payphone/core/logging.py @@ -34,5 +34,12 @@ def setup(process_name: str): logging.basicConfig(level=logging.DEBUG, handlers=[_get_handler(process_name)]) - logging.getLogger("asyncio").addHandler(_get_handler(process_name, "asyncio")) - logging.getLogger("discord").addHandler(_get_handler(process_name, "discord")) + discord_logger = logging.getLogger("discord") + discord_logger.handlers.clear() + discord_logger.addHandler(_get_handler(process_name, "discord")) + discord_logger.propagate = False + + asyncio_logger = logging.getLogger("asyncio") + asyncio_logger.handlers.clear() + asyncio_logger.addHandler(_get_handler(process_name, "asyncio")) + asyncio_logger.propagate = False diff --git a/payphone/core/masks.py b/payphone/core/masks.py new file mode 100644 index 0000000..d638a67 --- /dev/null +++ b/payphone/core/masks.py @@ -0,0 +1,109 @@ +import random + +from payphone.core import constants + + +MASKS = { + "ferret": 1278176917123760139 if constants.IS_DEBUG else 1278173356423909448, + "octopus": 1278176904410697880 if constants.IS_DEBUG else 1278173331421397072, + "cat": 1278176891290783824 if constants.IS_DEBUG else 1278173317034934282, + "monkey": 1278176881195094049 if constants.IS_DEBUG else 1278173297456185344, + "panda": 1278176868767629455 if constants.IS_DEBUG else 1278173272390762538, + "sheep": 1278176856910073946 if constants.IS_DEBUG else 1278173251914432563, + "horse": 1278176842116894760 if constants.IS_DEBUG else 1278173237582237708, + "bee": 1278176823846506577 if constants.IS_DEBUG else 1278173216652787826, + "wild boar": 1278176806498996235 if constants.IS_DEBUG else 1278173196658413649, + "duck": 1278176794591232102 if constants.IS_DEBUG else 1278173181483679918, + "chicken": 1278176781790216233 if constants.IS_DEBUG else 1278173167218589726, + "elephant": 1278176771413512213 if constants.IS_DEBUG else 1278173150063886346, + "cow": 1278176761565413483 if constants.IS_DEBUG else 1278173125024153733, + "deer": 1278176744691597312 if constants.IS_DEBUG else 1278173110105014406, + "turkey": 1278176730619576321 if constants.IS_DEBUG else 1278173091796877363, + "god": 1278176719932751903 if constants.IS_DEBUG else 1278173070846201856, + "beaver": 1278176709530882091 if constants.IS_DEBUG else 1278173051485425705, + "camel": 1278176699921465394 if constants.IS_DEBUG else 1278173035018584086, + "koala": 1278176687493746709 if constants.IS_DEBUG else 1278173021345021952, + "hippo": 1278176675846295603 if constants.IS_DEBUG else 1278173007419932804, + "ostrich": 1278176661317095494 if constants.IS_DEBUG else 1278172994669248523, + "rhino": 1278176650340864000 if constants.IS_DEBUG else 1278172980848885832, + "squirrel": 1278176637627924570 if constants.IS_DEBUG else 1278172965590274088, + "chamaleon": 1278176625392877648 if constants.IS_DEBUG else 1278172953305022496, + "frog": 1278176614512857088 if constants.IS_DEBUG else 1278172937043710094, + "skunk": 1278176604509442079 if constants.IS_DEBUG else 1278172923495976981, + "walrus": 1278176554723311678 if constants.IS_DEBUG else 1278172912368619603, + "hedgehog": 1278176541750067252 if constants.IS_DEBUG else 1278172899911405678, + "pig": 1278176531415564332 if constants.IS_DEBUG else 1278172885239861338, + "hen": 1278176518643646485 if constants.IS_DEBUG else 1278172871302184994, + "turtle": 1278176507428343891 if constants.IS_DEBUG else 1278172858199314455, + "mouse": 1278175957047578696 if constants.IS_DEBUG else 1278172846442414081, + "rabbit": 1278175944007487489 if constants.IS_DEBUG else 1278172831921999945, + "goat": 1278175933915856938 if constants.IS_DEBUG else 1278172815421472808, + "lion": 1278175922964664382 if constants.IS_DEBUG else 1278172803534684235, + "wolf": 1278175902047535165 if constants.IS_DEBUG else 1278172791052570687, + "snake": 1278175869331968061 if constants.IS_DEBUG else 1278172776259260547, + "llama": 1278175850008674325 if constants.IS_DEBUG else 1278172764913799168, + "crab": 1278175835634925620 if constants.IS_DEBUG else 1278172752322232330, + "owl": 1278175822947029152 if constants.IS_DEBUG else 1278172738309193749, + "bat": 1278175813572886550 if constants.IS_DEBUG else 1278172726426865724, + "zebra": 1278175799962505216 if constants.IS_DEBUG else 1278172715114565753, + "fox": 1278175788818104474 if constants.IS_DEBUG else 1278172702955274292, + "gorilla": 1278175775891259456 if constants.IS_DEBUG else 1278172687797194824, + "sloth": 1278175765661356175 if constants.IS_DEBUG else 1278172676837343242, + "puffer fish": 1278175753497743360 if constants.IS_DEBUG else 1278172665093558294, + "bear": 1278175741569142867 if constants.IS_DEBUG else 1278172653076746301, + "giraffe": 1278175732601983006 if constants.IS_DEBUG else 1278172640296702073, + "penguin": 1278175721956573236 if constants.IS_DEBUG else 1278172627629899808, + "parrot": 1278175706798489642 if constants.IS_DEBUG else 1278172600702337074, +} + + +def get_random_mask(not_int: list[str] = []) -> str: + """Returns a random mask. + + Args: + not_int (list[str], optional): Masks to exclude. Defaults to []. + + Returns: + str: The mask. + """ + + return random.choice([mask for mask in MASKS if mask not in not_int]) + + +def get_mask_name(mask: str) -> str: + """Returns the name of the mask. + + Args: + mask (str): The mask. + + Returns: + str: The name of the mask. + """ + + return mask.title() + + +def get_mask_image_url(mask: str) -> str: + """Returns the URL of the mask image. + + Args: + mask (str): The mask. + + Returns: + str: The URL of the mask image. + """ + + return f"https://cdn.discordapp.com/emojis/{MASKS[mask]}.png" + + +def get_mask_emoji(mask: str) -> str: + """Returns the emoji of the mask. + + Args: + mask (str): The mask. + + Returns: + str: The emoji of the mask. + """ + + return f"<:{mask.replace(" ", "")}:{MASKS[mask]}>" diff --git a/payphone/core/messages.py b/payphone/core/messages.py new file mode 100644 index 0000000..0d67a28 --- /dev/null +++ b/payphone/core/messages.py @@ -0,0 +1,88 @@ +TIPS = [ + "You can report calls using `Apps > Report Message`.", + "Help us sharing Payphone to make it more active!", + # "You can check your stats with `{prefix}me`.", + "Emojis will only show if they're from a server Payphone's in.", + "You cannot send links.", + "[Vote for Payphone on top.gg]() to help us reach more users!", + "[Write a review]() 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]() 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](). Let's keep Payphone safe and fun for everyone!", + "Join [Payphone's official server]() to participate in polls for new features!", + # "How high are you on the leaderboard? Use `{prefix}me`, `{prefix}leaderboard`, and `{prefix}leaderboard servers` to check it out!", +] + +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!", +] + +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.", +] + +HANGUP = [ + "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.", +] + +OTHER_PARTY_HANGUP = [ + "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.", +] + +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.", +] diff --git a/payphone/core/typing_utils.py b/payphone/core/typing_utils.py deleted file mode 100644 index 693893e..0000000 --- a/payphone/core/typing_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -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 diff --git a/payphone/core/utils.py b/payphone/core/utils.py index dd2bffd..693893e 100644 --- a/payphone/core/utils.py +++ b/payphone/core/utils.py @@ -1,57 +1,25 @@ -from typing import Callable, Iterable, TypeVar, Generator +import typing + +from discord.ext import commands -T = TypeVar("T") -U = TypeVar("U") - - -def deduplicate(i: Iterable[T], key: Callable[[T], U] = lambda i: i) -> list[T]: - """Deduplicates an Iterable. +def is_optional(type_hint) -> bool: + """Checks if the type_hint is optional or not. Args: - i (Iterable[T]): The iterable with items to deduplicate. - key (Callable[[T], U], optional): The key by which to deduplicate. Defaults to - `lambda i: i`. + type_hint (typing.Type): The type hint. Returns: - list[T]: A list with the deduplicated items. + bool: Whether the type hint is optional or not. """ - found_keys = set() + origin = typing.get_origin(type_hint) - result = [] + if origin is typing.Union: + args = typing.get_args(type_hint) + return type(None) in args - for item in i: - k = key(item) - if k not in found_keys: - result.append(item) - found_keys.add(k) + if isinstance(type_hint, commands.Greedy): + return True - return result - - -def chunks(i: Iterable[T], size: int) -> Generator[list[T], None, None]: - """Chunk an iterable. - - Args: - i (Iterable[T]): The iterable. - size (int): The size of each chunk. - - Returns: - Generator[list[T], None, None]: The iterable's chunks. - """ - - if size <= 0: - raise ValueError("Chunks size can't be lower than 1.") - - chunk = [] - - for item in i: - chunk.append(item) - - if len(chunk) == size: - yield chunk - chunk = [] - - if len(chunk) > 0: - yield chunk + return False diff --git a/payphone/db/migrations/models/0_20241016124605_init.py b/payphone/db/migrations/models/0_20241016124605_init.py deleted file mode 100644 index 0abe092..0000000 --- a/payphone/db/migrations/models/0_20241016124605_init.py +++ /dev/null @@ -1,104 +0,0 @@ -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 """ - """ diff --git a/payphone/db/migrations/models/0_20241112230205_init.py b/payphone/db/migrations/models/0_20241112230205_init.py new file mode 100644 index 0000000..8d4517f --- /dev/null +++ b/payphone/db/migrations/models/0_20241112230205_init.py @@ -0,0 +1,55 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "channel" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "server_id" BIGINT NOT NULL, + "is_enabled" BOOL NOT NULL DEFAULT True +); +CREATE INDEX IF NOT EXISTS "idx_channel_server__d5ded1" ON "channel" ("server_id"); +CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled"); +COMMENT ON TABLE "channel" IS 'A channel.'; +CREATE TABLE IF NOT EXISTS "user" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "is_masked" BOOL NOT NULL DEFAULT False, + "is_moderator" BOOL NOT NULL DEFAULT False, + "is_supporter_until" TIMESTAMPTZ +); +COMMENT ON TABLE "user" IS 'A user.'; +CREATE TABLE IF NOT EXISTS "report" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "message_id" BIGINT NOT NULL UNIQUE, + "message_content" TEXT NOT NULL, + "channel_id" BIGINT NOT NULL, + "user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved" BOOL NOT NULL DEFAULT False, + "resolved_at" TIMESTAMPTZ, + "resolved_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_report_resolve_32f036" ON "report" ("resolved"); +COMMENT ON TABLE "report" IS 'A report of a message.'; +CREATE TABLE IF NOT EXISTS "ban" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ, + "created_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "expired_by_id" BIGINT REFERENCES "user" ("id") ON DELETE CASCADE, + "report_id" INT REFERENCES "report" ("id") ON DELETE CASCADE +); +CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at"); +COMMENT ON TABLE "ban" IS 'A ban.'; +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 """ + """ diff --git a/payphone/db/migrations/models/10_20241018234951_update.py b/payphone/db/migrations/models/10_20241018234951_update.py deleted file mode 100644 index 3ad169b..0000000 --- a/payphone/db/migrations/models/10_20241018234951_update.py +++ /dev/null @@ -1,15 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "leaderboard_calls_count" INT NOT NULL DEFAULT 0; - ALTER TABLE "server" ADD "leaderboard_position" INT NOT NULL DEFAULT 0; - CREATE INDEX "idx_server_leaderb_47c354" ON "server" ("leaderboard_position");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_server_leaderb_47c354"; - ALTER TABLE "server" DROP COLUMN "leaderboard_calls_count"; - ALTER TABLE "server" DROP COLUMN "leaderboard_position";""" diff --git a/payphone/db/migrations/models/11_20241019004248_update.py b/payphone/db/migrations/models/11_20241019004248_update.py deleted file mode 100644 index 03e9a27..0000000 --- a/payphone/db/migrations/models/11_20241019004248_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "vanity_code" VARCHAR(32);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" DROP COLUMN "vanity_code";""" diff --git a/payphone/db/migrations/models/12_20241019213937_update.py b/payphone/db/migrations/models/12_20241019213937_update.py deleted file mode 100644 index c42ce82..0000000 --- a/payphone/db/migrations/models/12_20241019213937_update.py +++ /dev/null @@ -1,13 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" DROP COLUMN "appeal"; - ALTER TABLE "ban" DROP COLUMN "status";""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ADD "appeal" VARCHAR(500); - ALTER TABLE "ban" ADD "status" SMALLINT NOT NULL DEFAULT 0;""" diff --git a/payphone/db/migrations/models/13_20241021153438_update.py b/payphone/db/migrations/models/13_20241021153438_update.py deleted file mode 100644 index 761daec..0000000 --- a/payphone/db/migrations/models/13_20241021153438_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "found_easter_eggs" SMALLINT NOT NULL DEFAULT 0;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "found_easter_eggs";""" diff --git a/payphone/db/migrations/models/14_20241023143325_update.py b/payphone/db/migrations/models/14_20241023143325_update.py deleted file mode 100644 index 643e7d5..0000000 --- a/payphone/db/migrations/models/14_20241023143325_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" ALTER COLUMN "priority" TYPE DOUBLE PRECISION USING "priority"::DOUBLE PRECISION;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" ALTER COLUMN "priority" TYPE INT USING "priority"::INT;""" diff --git a/payphone/db/migrations/models/15_20241025225628_update.py b/payphone/db/migrations/models/15_20241025225628_update.py deleted file mode 100644 index ce37ea4..0000000 --- a/payphone/db/migrations/models/15_20241025225628_update.py +++ /dev/null @@ -1,19 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "ban" ADD "unbanned_by_id" BIGINT; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - ALTER TABLE "ban" ADD CONSTRAINT "fk_ban_user_e41857cf" FOREIGN KEY ("unbanned_by_id") REFERENCES "user" ("id") ON DELETE CASCADE; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - ALTER TABLE "ban" DROP CONSTRAINT "fk_ban_user_e41857cf"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "ban" DROP COLUMN "unbanned_by_id"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");""" diff --git a/payphone/db/migrations/models/16_20241027161324_update.py b/payphone/db/migrations/models/16_20241027161324_update.py deleted file mode 100644 index 124e8a3..0000000 --- a/payphone/db/migrations/models/16_20241027161324_update.py +++ /dev/null @@ -1,21 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "report" ADD "reviewed_by_id" BIGINT; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");; - ALTER TABLE "report" ADD CONSTRAINT "fk_report_user_67e68360" FOREIGN KEY ("reviewed_by_id") REFERENCES "user" ("id") ON DELETE SET NULL;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" DROP CONSTRAINT "fk_report_user_67e68360"; - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "report" DROP COLUMN "reviewed_by_id"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" diff --git a/payphone/db/migrations/models/17_20241102231436_update.py b/payphone/db/migrations/models/17_20241102231436_update.py deleted file mode 100644 index 42550e7..0000000 --- a/payphone/db/migrations/models/17_20241102231436_update.py +++ /dev/null @@ -1,21 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "user" ADD "is_deleting" BOOL NOT NULL DEFAULT False; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");; - CREATE INDEX "idx_user_is_dele_2b4cfd" ON "user" ("is_deleting");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_user_is_dele_2b4cfd"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "user" DROP COLUMN "is_deleting"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" diff --git a/payphone/db/migrations/models/1_20241017013650_update.py b/payphone/db/migrations/models/1_20241017013650_update.py deleted file mode 100644 index 33e9edc..0000000 --- a/payphone/db/migrations/models/1_20241017013650_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ALTER COLUMN "banned_by_id" DROP NOT NULL;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ALTER COLUMN "banned_by_id" SET NOT NULL;""" diff --git a/payphone/db/migrations/models/1_20241112230215_update.py b/payphone/db/migrations/models/1_20241112230215_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/1_20241112230215_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/2_20241017014235_update.py b/payphone/db/migrations/models/2_20241017014235_update.py deleted file mode 100644 index 81f0dd0..0000000 --- a/payphone/db/migrations/models/2_20241017014235_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" DROP CONSTRAINT "ban_user_id_fkey";""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ADD CONSTRAINT "ban_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE NO ACTION;""" diff --git a/payphone/db/migrations/models/2_20241112230633_update.py b/payphone/db/migrations/models/2_20241112230633_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/2_20241112230633_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/3_20241017020958_update.py b/payphone/db/migrations/models/3_20241017020958_update.py deleted file mode 100644 index 7737e8f..0000000 --- a/payphone/db/migrations/models/3_20241017020958_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ALTER COLUMN "prefix" TYPE VARCHAR(5) USING "prefix"::VARCHAR(5);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ALTER COLUMN "prefix" TYPE VARCHAR(3) USING "prefix"::VARCHAR(3);""" diff --git a/payphone/db/migrations/models/3_20241112232746_update.py b/payphone/db/migrations/models/3_20241112232746_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/3_20241112232746_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/4_20241018014134_update.py b/payphone/db/migrations/models/4_20241018014134_update.py deleted file mode 100644 index 38e43b9..0000000 --- a/payphone/db/migrations/models/4_20241018014134_update.py +++ /dev/null @@ -1,17 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "calluserchannelrelationship" ADD "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; - ALTER TABLE "user" ADD "leaderboard_position" INT; - CREATE INDEX "idx_callusercha_created_e72939" ON "calluserchannelrelationship" ("created_at"); - CREATE INDEX "idx_user_leaderb_1dcee5" ON "user" ("leaderboard_position");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_callusercha_created_e72939"; - DROP INDEX "idx_user_leaderb_1dcee5"; - ALTER TABLE "user" DROP COLUMN "leaderboard_position"; - ALTER TABLE "calluserchannelrelationship" DROP COLUMN "created_at";""" diff --git a/payphone/db/migrations/models/4_20241112232813_update.py b/payphone/db/migrations/models/4_20241112232813_update.py new file mode 100644 index 0000000..6616544 --- /dev/null +++ b/payphone/db/migrations/models/4_20241112232813_update.py @@ -0,0 +1,19 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" DROP COLUMN "resolved_by_id"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" ADD "resolved_by_id" BIGINT; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/5_20241018014615_update.py b/payphone/db/migrations/models/5_20241018014615_update.py deleted file mode 100644 index 7c58be8..0000000 --- a/payphone/db/migrations/models/5_20241018014615_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "joined_at";""" diff --git a/payphone/db/migrations/models/5_20241112232936_update.py b/payphone/db/migrations/models/5_20241112232936_update.py new file mode 100644 index 0000000..e1ee5c8 --- /dev/null +++ b/payphone/db/migrations/models/5_20241112232936_update.py @@ -0,0 +1,21 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" ADD "resolved_by_id" BIGINT; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");; + ALTER TABLE "report" ADD CONSTRAINT "fk_report_user_e65f1760" FOREIGN KEY ("resolved_by_id") REFERENCES "user" ("id") ON DELETE CASCADE;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + ALTER TABLE "report" DROP CONSTRAINT "fk_report_user_e65f1760"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" DROP COLUMN "resolved_by_id"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/6_20241018031928_update.py b/payphone/db/migrations/models/6_20241018031928_update.py deleted file mode 100644 index b66c9cd..0000000 --- a/payphone/db/migrations/models/6_20241018031928_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "leaderboard_calls_count" INT;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "leaderboard_calls_count";""" diff --git a/payphone/db/migrations/models/6_20241114014251_update.py b/payphone/db/migrations/models/6_20241114014251_update.py new file mode 100644 index 0000000..e1b9a07 --- /dev/null +++ b/payphone/db/migrations/models/6_20241114014251_update.py @@ -0,0 +1,21 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" ADD "user_avatar_url" TEXT NOT NULL; + ALTER TABLE "report" ADD "user_name" TEXT NOT NULL; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" DROP COLUMN "user_avatar_url"; + ALTER TABLE "report" DROP COLUMN "user_name"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/7_20241018043647_update.py b/payphone/db/migrations/models/7_20241018043647_update.py deleted file mode 100644 index b313887..0000000 --- a/payphone/db/migrations/models/7_20241018043647_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE INDEX "idx_ban_created_490013" ON "ban" ("created_at");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_ban_created_490013";""" diff --git a/payphone/db/migrations/models/7_20241114024101_update.py b/payphone/db/migrations/models/7_20241114024101_update.py new file mode 100644 index 0000000..9607b60 --- /dev/null +++ b/payphone/db/migrations/models/7_20241114024101_update.py @@ -0,0 +1,21 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "user" ADD "warns_count" INT NOT NULL DEFAULT 0; + ALTER TABLE "user" ADD "bans_count" INT NOT NULL DEFAULT 0; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "user" DROP COLUMN "warns_count"; + ALTER TABLE "user" DROP COLUMN "bans_count"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/8_20241018132301_update.py b/payphone/db/migrations/models/8_20241018132301_update.py deleted file mode 100644 index 8509a17..0000000 --- a/payphone/db/migrations/models/8_20241018132301_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE INDEX "idx_user_usernam_9987ab" ON "user" ("username");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_user_usernam_9987ab";""" diff --git a/payphone/db/migrations/models/8_20241115225636_update.py b/payphone/db/migrations/models/8_20241115225636_update.py new file mode 100644 index 0000000..c22000b --- /dev/null +++ b/payphone/db/migrations/models/8_20241115225636_update.py @@ -0,0 +1,24 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE TABLE IF NOT EXISTS "server" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "prefix" TEXT NOT NULL, + "language" TEXT NOT NULL +); +COMMENT ON TABLE "server" IS 'A server.';; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + DROP TABLE IF EXISTS "server"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/9_20241018234807_update.py b/payphone/db/migrations/models/9_20241018234807_update.py deleted file mode 100644 index cd4e4f6..0000000 --- a/payphone/db/migrations/models/9_20241018234807_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "name" VARCHAR(100);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" DROP COLUMN "name";""" diff --git a/payphone/db/migrations/models/9_20241115230145_update.py b/payphone/db/migrations/models/9_20241115230145_update.py new file mode 100644 index 0000000..6c484c7 --- /dev/null +++ b/payphone/db/migrations/models/9_20241115230145_update.py @@ -0,0 +1,19 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "server" ADD "name" TEXT NOT NULL; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "server" DROP COLUMN "name"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/models.py b/payphone/db/models.py index e87acda..e4468e1 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -1,406 +1,201 @@ -from datetime import datetime +from datetime import datetime, timezone import enum -from tortoise import models, fields, indexes +from tortoise import models, fields -import discord -from discord import webhook +import redis.asyncio as redis -import aiohttp - -from payphone.core import config, constants -from payphone.cogs.easter import EasterEggCommand - - -class User(models.Model): - id: int = fields.BigIntField(primary_key=True) - username: str = fields.CharField(max_length=32, db_index=True) - is_masked: bool = fields.BooleanField(default=False) - is_moderator: bool = fields.BooleanField(default=False) - is_deleting: bool = fields.BooleanField(default=False, db_index=True) - found_easter_eggs: int = fields.IntEnumField(EasterEggCommand, default=0) - profiles: fields.BackwardFKRelation["Profile"] - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - leaderboard_position: int | None = fields.IntField( - default=None, null=True, db_index=True - ) - leaderboard_calls_count: int | None = fields.IntField(default=None, null=True) - joined_at: datetime = fields.DatetimeField(auto_now_add=True) - - -class Call(models.Model): - started_at = fields.DatetimeField(auto_now_add=True, db_index=True) - ended_at = fields.DatetimeField(null=True, default=None) - - channels: fields.BackwardFKRelation["Channel"] - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - report: fields.BackwardOneToOneRelation["Report"] - messages: fields.BackwardFKRelation["Message"] - - -class Profile(models.Model): - user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE - ) - user_id: int - server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField( - "models.Server", - related_name="profiles", - null=True, - on_delete=fields.OnDelete.CASCADE, - ) - avatar_url: str = fields.TextField() - name: str = fields.CharField(max_length=32) - color: int | None = fields.IntField(null=True, default=None) - - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - - @property - def jump_url(self) -> str: - """The URL that'll open the user in Discord. - - Returns: - str: The URL. - """ - - return f"discord://-/users/{self.user_id}" - - @property - def link(self) -> str: - """A markdown link that opens the user in Discord. - - Returns: - str: The markdown link. - """ - - return f"[{self.name}]({self.jump_url})" - - @property - def embed_author(self) -> discord.EmbedAuthor: - """Returns this profile as an EmbedAuthor object. - - Returns: - discord.EmbedAuthor: The EmbedAuthor object. - """ - - return discord.EmbedAuthor(name=self.name, icon_url=self.avatar_url) - - class Meta: - unique_together = [("user_id", "server_id")] - - -class Server(models.Model): - id: int = fields.BigIntField(primary_key=True) - name: str = fields.CharField(max_length=100, null=True, default=None) - prefix: str = fields.CharField(max_length=5, default="p.") - leaderboard_position: int = fields.IntField(default=0, db_index=True) - leaderboard_calls_count: int = fields.IntField(default=0) - vanity_code: str = fields.CharField(max_length=32, null=True, default=None) - channels: fields.BackwardFKRelation["Channel"] - profiles: fields.BackwardFKRelation["Profile"] - - -class Channel(models.Model): - id = fields.BigIntField(primary_key=True) - server: fields.ForeignKeyRelation[Server] = fields.ForeignKeyField( - "models.Server", related_name="channels", on_delete=fields.OnDelete.CASCADE - ) - call: fields.ForeignKeyNullableRelation["Call"] = fields.ForeignKeyField( - "models.Call", null=True, related_name="channels" - ) - enabled: bool | None = fields.BooleanField(null=True, default=None) - webhook_id: int | None = fields.BigIntField(null=True) - webhook_url: str | None = fields.TextField(null=True) - - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - messages: fields.BackwardFKRelation["Message"] - propagated_messages: fields.BackwardFKRelation["Message"] - - def webhook(self, session: aiohttp.ClientSession) -> webhook.Webhook | None: - """Initializes the channel's webhook instance. - - Args: - session (aiohttp.ClientSession): An initialized aiohttp client session. - - Returns: - webhook.Webhook | None: The webhook, or None if not initialized. - """ - - if self.webhook_url is None: - return None - - return webhook.Webhook.from_url( - self.webhook_url, session=session, bot_token=config.TOKEN - ) - - class Meta: - index = [("server_id", "enabled")] - - -class CallUserChannelRelationship(models.Model): - call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField( - "models.Call", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField( - "models.Profile", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField( - "models.Channel", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - is_masked: bool = fields.BooleanField() - mask: int = fields.BigIntField() - created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True) - - @property - def mask_name(self) -> str: - """Returns the mask's name. - - Returns: - str: The mask's name. - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return 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: - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(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>). - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - mask_name = mask.name.lower().replace("_", "") - - return f"<:{mask_name}:{self.mask}>" - - class Meta: - unique_together = [("call_id", "user_id", "channel_id")] - - -class Message(models.Model): - id = fields.BigIntField(primary_key=True) - call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField( - "models.Call", related_name="messages", on_delete=fields.OnDelete.CASCADE - ) - call_id: int - channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField( - "models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE - ) - channel_id: int - user = fields.ForeignKeyRelation("models.User", on_delete=fields.OnDelete.CASCADE) - user_id: int - profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField( - "models.Profile", on_delete=fields.OnDelete.CASCADE - ) - profile_id: int - replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField( - "models.Message", - related_name="replies", - on_delete=fields.OnDelete.SET_NULL, - null=True, - default=None, - ) - mask = fields.BigIntField(null=True) - was_deleted = fields.BooleanField(default=False) - propagation_id = fields.BigIntField(null=True, db_index=True) - propagation_channel: fields.ForeignKeyNullableRelation["Channel"] = ( - fields.ForeignKeyField( - "models.Channel", related_name="propagated_messages", null=True - ) - ) - propagation_channel_id: int - 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. - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return 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: - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(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>). - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - mask_name = mask.name.lower().replace("_", "") - - return f"<:{mask_name}:{self.mask}>" - - def jump_url(self, is_this_channel: bool = True) -> str | None: - """Returns the jump URL for this message. - - Args: - is_this_channel (bool, optional): Whether the message sent in this channel or not. - Defaults to True. - - Returns: - str | None: The URL, or None if the message was not propagated. - """ - - if not is_this_channel: - return f"https://discord.com/channels/{self.channel.server_id}/{self.channel.id}/{self.id}" - - if self.propagation_id is not None: - return f"https://discord.com/channels/{self.propagation_channel.server_id}/{self.propagation_channel.id}/{self.propagation_id}" - - return None - - -class MessageContent(models.Model): - message = fields.ForeignKeyField( - "models.Message", related_name="contents", on_delete=fields.OnDelete.CASCADE - ) - message_id: int - content = fields.CharField(max_length=4096) - was_propagated = fields.BooleanField(default=True) - created_at = fields.DatetimeField(auto_now_add=True) - - -class ReportStatus(enum.IntEnum): - ACCEPTED = 0 - DECLINED = 1 - ON_REVIEW = 2 +import orjson class Report(models.Model): - call: fields.OneToOneRelation["Call"] = fields.OneToOneField( - "models.Call", related_name="report" - ) - reported_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", null=True, on_delete=fields.OnDelete.CASCADE - ) - reviewed_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", null=True, on_delete=fields.OnDelete.SET_NULL, related_name="reports_reviewed" - ) - reason: str = fields.CharField(max_length=500) - priority: float = fields.FloatField() - status: ReportStatus = fields.IntEnumField( - ReportStatus, default=ReportStatus.ON_REVIEW, db_index=True - ) - created_at: datetime = fields.DatetimeField(auto_now_add=True) + """ + A report of a message. + """ - class Meta: - indexes = [indexes.Index(fields=("status", "priority"))] + message_id = fields.BigIntField(unique=True) + message_content = fields.TextField() + channel_id = fields.BigIntField() + user_id = fields.BigIntField() + user_name = fields.TextField() + user_avatar_url = fields.TextField() + created_at = fields.DatetimeField(auto_now_add=True) + resolved = fields.BooleanField(default=False, index=True) + resolved_at = fields.DatetimeField(null=True) + resolved_by = fields.ForeignKeyField("models.User", null=True) + + +class UserFlag(enum.IntFlag): + """ + Flags for a user. + """ + + NONE = 0 + MODERATOR = 1 + SUPPORTER = 2 + + +class User(models.Model): + """ + A user. + """ + + id = fields.BigIntField(pk=True) + + warns_count: int = fields.IntField(default=0) + bans_count: int = fields.IntField(default=0) + + is_masked = fields.BooleanField(default=False) + is_moderator = fields.BooleanField(default=False) + is_supporter_until = fields.DatetimeField(null=True) + + @property + def flag(self) -> UserFlag: + """ + The flags of the user. + """ + + flags = UserFlag.NONE + + if self.is_moderator: + flags |= UserFlag.MODERATOR + + if ( + self.is_supporter_until is not None + and self.is_supporter_until > datetime.now(timezone.utc) + ): + flags |= UserFlag.SUPPORTER + + return flags + + @classmethod + async def get_cache_or_db(cls, redis_client: redis.Redis, user_id: int) -> "User": + """ + Returns the user from the cache or the database. + + Args: + redis_client (redis.Redis): The Redis client. + user_id (int): The user ID. + + Returns: + (User): The user. + """ + + cache = await redis_client.get(f"user:{user_id}") + + instance = User(id=user_id) + + if cache is not None: + cache = orjson.loads(cache) + + instance.warns_count = cache["warns_count"] + instance.bans_count = cache["bans_count"] + instance.is_masked = cache["is_masked"] + instance.is_moderator = cache["is_moderator"] + instance.is_supporter_until = ( + datetime.fromisoformat(cache["is_supporter_until"]) + if cache["is_supporter_until"] is not None + else None + ) + + else: + instance, _ = await User.update_or_create(id=user_id) + + await instance.set_cache(redis_client) + + return instance + + async def set_cache(self, redis_client: redis.Redis) -> None: + """ + Sets the user in the cache. + + Args: + redis_client (redis.Redis): The Redis client. + """ + + await redis_client.set( + f"user:{self.id}", + orjson.dumps( + { + "warns_count": self.warns_count, + "bans_count": self.bans_count, + "is_masked": self.is_masked, + "is_moderator": self.is_moderator, + "is_supporter_until": ( + self.is_supporter_until.timestamp() + if self.is_supporter_until + else None + ), + } + ), + ex=60 * 15, + ) class Ban(models.Model): - user_id: int = fields.BigIntField() - 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) - banned_by: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="bans_created", null=True - ) - unbanned_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="bans_removed", null=True, default=None - ) - created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True) - expires_at: datetime | None = fields.DatetimeField(null=True) + """ + A ban. + """ + + user_id = fields.BigIntField() + report = fields.ForeignKeyField("models.Report", null=True) + created_at = fields.DatetimeField(auto_now_add=True) + expires_at = fields.DatetimeField(null=True) + created_by = fields.ForeignKeyField("models.User", related_name="created_bans") + expired_by = fields.ForeignKeyField("models.User", related_name="bans", null=True) class Meta: - indexes = [indexes.Index(fields=("user_id", "expires_at"))] + """ + Meta information. + """ + + indexes = [ + models.Index(fields=["user_id", "expires_at"]), + ] + + +class Channel(models.Model): + """ + A channel. + """ + + id = fields.BigIntField(pk=True) + server_id = fields.BigIntField(index=True) + + is_enabled = fields.BooleanField(null=True) + + class Meta: + """ + Meta information. + """ + + indexes = [ + models.Index(fields=["server_id", "is_enabled"]), + ] + + +class Server(models.Model): + """ + A server. + """ + + id = fields.BigIntField(pk=True) + name = fields.TextField() + prefix = fields.TextField(default="p.") + language = fields.TextField(default="en") + + async def set_prefix(self, redis_client: redis.Redis, prefix: str) -> None: + """ + Sets the prefix of the server. + + Args: + redis_client (redis.Redis): The Redis client. + prefix (str): The prefix. + """ + + self.prefix = prefix + + await self.save() + + await redis_client.set(f"prefix:{self.id}", prefix, ex=60 * 60 * 24) diff --git a/payphone/db/setup.py b/payphone/db/setup.py index 40bad08..143f0d3 100644 --- a/payphone/db/setup.py +++ b/payphone/db/setup.py @@ -1,9 +1,33 @@ import tortoise -from payphone.core import config +from payphone.core import constants + + +CONFIG = { + "connections": { + "default": { + "engine": "tortoise.backends.asyncpg", + "credentials": { + "host": "localhost" if constants.IS_DEBUG else "postgresql", + "port": "5432", + "user": "payphone", + "password": "payphone", + "database": "payphone", + }, + "minsize": 5, + "maxsize": 100, + }, + }, + "apps": { + "models": { + "models": ["payphone.db.models", "aerich.models"], + "default_connection": "default", + } + }, +} async def init() -> None: """Initialize the database and it's connections.""" - await tortoise.Tortoise.init(config.TORTOISE_ORM, use_tz=True) + await tortoise.Tortoise.init(CONFIG, use_tz=True) diff --git a/payphone/db/utils.py b/payphone/db/utils.py deleted file mode 100644 index 36ff59a..0000000 --- a/payphone/db/utils.py +++ /dev/null @@ -1,85 +0,0 @@ -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]: - """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( - {"username": user.name}, id=user.id - ) - - profile_row, _ = await models.Profile.update_or_create( - { - "avatar_url": user.display_avatar.url, - "name": user.display_name or user.name, - }, - user=user_row, - server_id=user.guild.id if isinstance(user, discord.Member) else None, - ) - - return (user_row, profile_row) diff --git a/payphone/tasks/__init__.py b/payphone/tasks/__init__.py new file mode 100644 index 0000000..a5fec44 --- /dev/null +++ b/payphone/tasks/__init__.py @@ -0,0 +1 @@ +from payphone.tasks import connect, expire \ No newline at end of file diff --git a/payphone/tasks/connect.py b/payphone/tasks/connect.py new file mode 100644 index 0000000..ad095d7 --- /dev/null +++ b/payphone/tasks/connect.py @@ -0,0 +1,32 @@ +import asyncio + +import random + +from payphone.bot import Payphone +from payphone.core import constants, messages, logging + + +async def main(): + logging.setup("Connect") + + bot = Payphone() + + await bot.login(constants.TOKEN) + + while True: + pairs = await bot.call_handler.connect() + + message = random.choice(messages.ANSWERED) + + await asyncio.gather( + *[ + bot.get_partial_messageable(channel).send(f"☎️ {message}") + for pair in pairs + for channel in pair + ] + ) + + if pairs: + logging.logger.info("Connected %s calls.", len(pairs)) + + await asyncio.sleep(1) diff --git a/payphone/tasks/expire.py b/payphone/tasks/expire.py new file mode 100644 index 0000000..2cb97b5 --- /dev/null +++ b/payphone/tasks/expire.py @@ -0,0 +1,30 @@ +import asyncio + +from payphone.bot import Payphone +from payphone.core import constants, logging + + +async def main(): + logging.setup("Expire") + + bot = Payphone() + + await bot.login(constants.TOKEN) + + while True: + pairs = await bot.call_handler.expire() + + await asyncio.gather( + *[ + bot.get_partial_messageable(channel).send( + "☎️ The call was hung up because nobody responded." + ) + for pair in pairs + for channel in pair + ] + ) + + if pairs: + logging.logger.info("Expired %s calls.", len(pairs)) + + await asyncio.sleep(1) diff --git a/procs/__init__.py b/procs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/procs/flush_messages.py b/procs/flush_messages.py deleted file mode 100644 index 896f59e..0000000 --- a/procs/flush_messages.py +++ /dev/null @@ -1,134 +0,0 @@ -from payphone.core import logging - - -log = logging.logger - - -async def flush(): - import orjson - - import asyncpg.exceptions - - import redis - - from payphone.db import models - - from payphone.core import constants, utils, config - - redis = redis.Redis("redis://localhost" if config.IS_DEBUG else "redis://redis") - - pipe = redis.pipeline() - - pipe.smembers(constants.CALL_IDS_TO_FLUSH) - pipe.delete(constants.CALL_IDS_TO_FLUSH) - - call_ids, _ = await pipe.execute() - - # Redis returns bytes. - call_ids = [int(call_id) for call_id in call_ids] - - if len(call_ids) == 0: - log.debug("Skipping flushing. No calls to flush.") - return - - log.info("Flushing messages for %s calls.", len(call_ids)) - - pipe = redis.pipeline() - - for call_id in call_ids: - pipe.hgetall(constants.CALL_MESSAGES_TEMPLATE % call_id) - pipe.hgetall(constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id) - - for call_id, (messages, message_contents) in zip( - call_ids, utils.chunks(await pipe.execute(), 2) - ): - message_rows: list[models.Message] = [] - - for message_json in messages.values(): - message_rows.append(models.Message(**orjson.loads(message_json))) - - message_content_rows: list[models.MessageContent] = [] - - for message_content_json in message_contents.values(): - message_content_rows.append( - models.MessageContent(**orjson.loads(message_content_json)) - ) - - message_rows.sort(key=lambda m: m.id) - message_content_rows.sort(key=lambda mc: mc.message_id) - - pipe = redis.pipeline() - - pipe.delete( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call_id, - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id, - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - constants.CALL_MESSAGES_TEMPLATE % call_id, - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call_id, - constants.CALL_IS_ACTIVE % call_id, - ) - - await pipe.execute() - - for _ in range(5): - try: - await models.Message.bulk_create(message_rows, batch_size=20) - break - - except ( - asyncpg.exceptions.PostgresError, - asyncpg.exceptions.InterfaceError, - ): - log.exception( - "[Call ID: %s] An error occured trying to flush messages for call ID.", - call_id, - ) - continue - - for _ in range(5): - try: - await models.MessageContent.bulk_create( - message_content_rows, batch_size=20 - ) - break - - except ( - asyncpg.exceptions.PostgresError, - asyncpg.exceptions.InterfaceError, - ): - log.exception( - "[Call ID: %s] An error occured trying to flush message contents.", - call_id, - ) - continue - - log.debug( - "[Call ID: %s] Flushed %s messages and %s message contents.", - call_id, - len(messages), - len(message_contents), - ) - - log.info("Finished flushing messages.") - - -async def loop() -> None: - """Loops in the background flushing messages to the DB.""" - - import asyncio - - from payphone.db import setup - from payphone.core import logging - - await setup.init() - - logging.setup("Flushing") - - try: - while True: - await asyncio.sleep(30) - - await flush() - - except (KeyboardInterrupt, asyncio.exceptions.CancelledError): - log.exception("") diff --git a/pyproject.toml b/pyproject.toml index dd06381..24f1625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [project] name = "payphone" -version = "0.15.0" +version = "0.18.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [ "aerich==0.7.1", "aiohttp>=3.10.10", "click>=8.1.7", "colorthief>=0.2.1", "hiredis>=3.0.0", "httpx>=0.27.2", "orjson>=3.10.7", "py-cord>=2.6.1", "python-dotenv>=1.0.1", "rich>=13.9.3", "tortoise-orm[asyncpg]>=0.21.7",] +dependencies = [ "aerich==0.7.1", "aiohttp>=3.10.10", "orjson>=3.10.11", "py-cord>=2.6.1", "redis[hiredis]>=5.2.0", "rich>=13.9.4", "tortoise-orm[accel,asyncpg]>=0.21.7", "typer>=0.13.0",] + +[dependency-groups] +dev = [ "python-dotenv>=1.0.1", "toml>=0.10.2",] [tool.aerich] -tortoise_orm = "payphone.core.config.TORTOISE_ORM" +tortoise_orm = "payphone.db.setup.CONFIG" location = "./payphone/db/migrations" src_folder = "." - -[tool.uv] -dev-dependencies = [ "toml>=0.10.2",] diff --git a/redis/__init__.py b/redis/__init__.py deleted file mode 100644 index e604da3..0000000 --- a/redis/__init__.py +++ /dev/null @@ -1,826 +0,0 @@ -from typing import TypeAlias, Awaitable, AsyncGenerator - -from urllib import parse - -from datetime import datetime, timedelta - -import asyncio - -import hiredis - - -Resp3Repr: TypeAlias = str | int | bytes | bytearray | float | bool | None - - -class _Ellipsis: - """Represents an incomplete response of Redis.""" - - -def _encode( - arg: ( - Resp3Repr - | list[Resp3Repr] - | tuple[Resp3Repr] - | dict[Resp3Repr, Resp3Repr] - | set[Resp3Repr] - ) -) -> bytes: - """Encode a value into its RESP3 representation. - - This function supports str (converted to bytes), int, bytes, bytearray, float, bool, and None - as basic types. It also supports lists (arrays), dicts (maps), and sets. - - Args: - arg (Resp3Repr | list[Resp3Repr] | tuple[Resp3Repr] | dict[Resp3Repr, Resp3Repr] | set[Resp3Repr]): - The argument to encode. - - Raises: - ValueError: The value provided is invalid or not encodable. - - Returns: - bytes: The encoded representation of the value. - """ - - if isinstance(arg, (str, int)): - arg = str(arg).encode() - - if isinstance(arg, (bytes, bytearray)): - return f"${len(arg)}\r\n".encode() + bytes(arg) + b"\r\n" - - elif isinstance(arg, int): - return f":{arg}\r\n".encode() - - elif isinstance(arg, type(None)): - return b"_\r\n" - - elif isinstance(arg, float): - result = f",{arg}\r\n".encode() - - elif isinstance(arg, bool): - if arg: - return b"#t\r\n" - - return b"#f\r\n" - - elif isinstance(arg, (list, tuple)): - return f"*{len(arg)}\r\n".encode() + b"".join([_encode(i) for i in arg]) - - elif isinstance(arg, dict): - result = f"%{len(arg.keys())}\r\n".encode() - - for key, value in arg.items(): - result += _encode(key) + _encode(value) - - return result - - elif isinstance(arg, set): - result = f"~{len(arg)}\r\n".encode() - - for item in arg: - result += _encode(item) - - return result - - raise ValueError(f"Value of type {type(arg)} is not encodable into RESP3.") - - -def _get_secs(time: int | timedelta) -> int: - """Converts an integer or timedelta into an amount of seconds. - - Args: - time (int | timedelta): The number or timedelta to convert. - - Returns: - int: The amount of seconds. - """ - - if isinstance(time, timedelta): - return int(time.total_seconds()) - - return time - - -def _get_millis(time: int | timedelta) -> int: - """Converts an integer or timedelta into an amount of milliseconds. - - Args: - time (int | timedelta): The number or timedelta to convert. - - Returns: - int: The amount of milliseconds. - """ - - if isinstance(time, timedelta): - return int(time.total_seconds() * 1000) - - return time - - -def _get_timestamp_secs(dtime: datetime | int) -> int: - """Converts a datetime or timestamp into it's seconds timestamp. - - Args: - dtime (datetime | int): The datetime object to convert or an integer value which will be - returned as-is. - - Returns: - int: The seconds timestamp. - """ - - if isinstance(dtime, datetime): - return int(dtime.timestamp()) - - return dtime - - -def _get_timestamp_millis(dtime: datetime | int) -> int: - """Converts a datetime or timestamp into it's milliseconds timestamp. - - Args: - dtime (datetime | int): The datetime object to convert or an integer value which will be - returned as-is. - - Returns: - int: The milliseconds timestamp. - """ - - if isinstance(dtime, datetime): - return int(dtime.timestamp() * 1000) - - return dtime - - -class RedisError(Exception): - """Raised whenever an error is raised from this library.""" - - -class FatalRedisError(RedisError): - """Raised whenever an error occurs that does not allow the connection to stay alive.""" - - -class Runnable: - """The base class for all Redis commands. - - This class is inherited by Connection, Pipeline, and Transaction to implement their own - functionality. - """ - - _client: "Redis" - - def __init__(self, client: "Redis") -> None: - """Initializes the runner with the client's configuration. - - Args: - client (Redis): The client that initialized the runner. - """ - - self._client = client - - def command(self, *args: tuple[Resp3Repr]) -> Resp3Repr | Awaitable[Resp3Repr]: - """Runs a command taking it as a list of arguments. - - For example: - ``` - await Runnable.command("SET", "key", "value") - ``` - - This method is not implemented by default, so you need a Connection, a Pipeline, or a - Transaction to use it. - - Returns: - Resp3Repr | Awaitable[Resp3Repr]: The value(s) returned by the execution of the - command. - """ - - raise NotImplementedError( - "This method needs to be implemented by a subclass of Runner." - ) - - def ping(self, arg: str = "PONG") -> Resp3Repr: - """Returns PONG if no argument is provided, otherwise return a copy of the argument as a - bulk. - - Args: - arg (str): The argument to return by the command. - - Returns: - Resp3Repr: The response of the command. - """ - - return self.command("PING", str(arg)) - - -class RunnableGenericMixin: - """Mixin containing all Redis generic commands.""" - - def delete(self, *keys: tuple[str]) -> None: - """Deletes one or more keys.""" - - return self.command("DEL", *[str(key) for key in keys]) - - def exists(self, *keys: tuple[str]) -> int: - """Returns if key exists. - - The user should be aware that if the same existing key is mentioned in the arguments - multiple times, it will be counted multiple times. So if somekey exists, `EXISTS somekey - somekey` will return 2. - - Args: - *keys (tuple[str]): The keys to check for existance. - - Returns: - int: The amount of keys that exist. - """ - - return self.command("EXISTS", *[str(key) for key in keys]) - - -class RunnableHashMixin: - """Mixin containing all Redis hash commands.""" - - def hget(self, key: str, field: str) -> str | None: - """Returns the value associated with field in the hash stored at key. - - Args: - key (str): The key of the hash. - field (str): The field of the hash to get. - - Returns: - str | None: The value of the field, or None if it does not exist. - """ - - return self.command("HGET", str(key), str(field)) - - def hgetall(self, key: str) -> dict[Resp3Repr, Resp3Repr]: - """Returns all fields and values of the hash stored at key. - - Args: - key (str): The key to get the fields and values from. - - Returns: - dict[Resp3Repr, Resp3Repr]: A dict containing all the fields mapped to their respective - values. - """ - - return self.command("HGETALL", str(key)) - - def hincrby(self, key: str, field: str, increment: int = 1) -> int: - """Increments the number stored at field in the hash stored at key by increment. - - If key does not exist, a new key holding a hash is created. If field does not exist the - value is set to 0 before the operation is performed. The range of values supported by - HINCRBY is limited to 64 bit signed integers. - - Args: - key (str): The key of the hash whose field to increment. - field (str): The field to increment. - increment (int, optional): The amount to increment by. Defaults to 1. - - Returns: - int: The amount returned - """ - - return self.command("HINCRBY", str(key), str(field), str(increment)) - - def hset(self, key: str, mapping: dict[Resp3Repr, Resp3Repr]) -> int: - """Sets the specified fields to their respective values in the hash stored at key. - - This command overwrites the values of specified fields that exist in the hash. If key - doesn't exist, a new key holding a hash is created. - - Args: - key (str): The key to set the values for. - mapping (dict[Resp3Repr, Resp3Repr]): The fields mapped to their values. - - Returns: - int: The number of fields that were added. - """ - - items = [] - - for k, v in mapping.items(): - items.append(k) - items.append(v) - - return self.command("HSET", str(key), *items) - - -class RunnableSetMixin: - """Mixin containing all Redis set commands.""" - - def sadd(self, key: str, *values: tuple[str]) -> int: - """Adds the specified members to the set stored at key. - - Specified members that are already a member of this set are ignored. If key does not exist, - a new set is created before adding the specified members. An error is raised when the value - stored at key is not a set. - - Args: - key (str): The key to add the values to. - *values (tuple[str]): The values to add to the set. - - Returns: - int: The amount of new values that were added. - """ - - return self.command("SADD", str(key), *values) - - def scard(self, key: str) -> int: - """Returns the set cardinality (number of elements) of the set stored at key. - - Args: - key (str): The key of the set to get the cardinality from. - - Returns: - int: The number of elements, or 0 if it does not exist. - """ - - return self.command("SCARD", str(key)) - - def smembers(self, key: str) -> set[str]: - """Returns all the members of the set value stored at key. - - Args: - key (str): The key of the set to get the members from. - - Returns: - set[str]: The members of the set. - """ - - return self.command("SMEMBERS", str(key)) - - def sismember(self, key: str, value: str) -> bool: - """Returns whether the value is a member of the set. - - Args: - key (str): The key of the set. - value (str): The value to check whether it is a member of the set. - - Returns: - bool: Whether the value is a member of the set. - """ - - return self.command("SISMEMBER", str(key), str(value)) - - def spop(self, key: str, count: int = 1) -> str | list[str]: - """Removes and returns one or more random members from the set value store at key.""" - - return self.command("SPOP", str(key), count) - - def srem(self, key: str, *values: tuple[str]) -> int: - """Removes the specified members from the set stored at key. - - Specified members that are not a member of this set are ignored. If key does not exist, - it is treated as an empty set and this command returns 0. An error is returned when the - value stored at key is not a set. - - Args: - key (str): The key of the set. - *values (tuple[str]): The values to remove from the set. - - Returns: - int: The amount of values that were removed from the set. - """ - - return self.command("SREM", str(key), *values) - - -class RunnableStringMixin: - """Mixin containing all Redis string commands.""" - - def append(self, key: str, value: str) -> int: - """Appends a string to an existing key, or creates a new one if it does not exist. - - If key already exists and is a string, this command appends the value at the end of the - string. If key does not exist it is created and set as an empty string, so APPEND will be - similar to SET in this special case. - - Args: - key (str): The key to append to. - value (str): The string to append. - - Returns: - int: The new length of the value. - """ - - return self.command("APPEND", str(key), str(value)) - - def decr(self, key: str) -> int: - """Decrements the number stored at key by one. - - If the key does not exist, it is set to 0 before performing the operation. An error is - returned if the key contains a value of the wrong type or contains a string that can not be - represented as integer. This operation is limited to 64 bit signed integers. - - Args: - key (str): The key to decrement. - - Returns: - int: The decremented value. - """ - - return self.command("DECR", str(key)) - - def decrby(self, key: str, decrement: int) -> int: - """Reduces the value stored at the specified key by the specified decrement. - - If the key does not exist, it is initialized with a value of 0 before performing the - operation. If the key's value is not of the correct type or cannot be represented as an - integer, an error is returned. This operation is limited to 64-bit signed integers. - - Args: - key (str): The key to decrement. - decrement (int): The amount to decrement by. - - Returns: - int: The decremented value. - """ - - return self.command("DECRBY", str(key), decrement) - - def get(self, key: str) -> str | None: - """Returns the string value of a key. - - If the key does not exist None is returned. An error is returned if the value stored at key - is not a string, because GET only handles string values. - - Args: - key (str): The key to get the value from. - - Returns: - str | None: The value of the key, of None if it does not exist. - """ - - return self.command("GET", str(key)) - - def getdel(self, key: str) -> str | None: - """Returns the value of key and delete the key. - - This command is similar to GET, except for the fact that it also deletes the key on success - (if and only if the key's value type is a string). - - Args: - key (str): The key to get and delete. - - Returns: - str | None: The value of the key. - """ - - return self.command("GETDEL", str(key)) - - def set( - self, - key: str, - value: str, - nx: bool = False, - xx: bool = False, - get: bool = False, - ex: timedelta | int | None = None, - px: timedelta | int | None = None, - exat: datetime | int | None = None, - pxat: datetime | int | None = None, - keepttl: bool = False, - ) -> str | None: - """Set key to hold the string value. - - If key already holds a value, it is overwritten, regardless of its type. Any previous time - to live associated with the key is discarded on successful SET operation. - - Args: - key (str): The key to set. - value (str): The value to set to the specified key. - nx (bool, optional): Only set the key if it does not already exist. Defaults to False. - xx (bool, optional): Only set the key if it already exists. Defaults to False. - get (bool, optional): Return the old string stored at key, or None if key did not - exist. An error is returned and SET aborted if the value stored at key is not a - string. Defaults to False. - ex (timedelta | int | None, optional): Set the specified expire time, in seconds (a positive - integer). Defaults to None. - px (timedelta | int | None, optional): Set the specified expire time, in milliseconds (a positive - integer). Defaults to None. - exat (datetime | int | None, optional): Set the specified time at which the key will - expire, in seconds (a positive integer) or a datetime object. Defaults to None. - pxat (datetime | int | None, optional): Set the specified time at which the key will - expire, in milliseconds (a positive integer) or a datetime object. Defaults to None. - keepttl (bool, optional): Retain the time to live associated with the key. Defaults to - False. - - Returns: - str | None: The old key in case `get` was set, or None. - """ - - if nx and xx: - raise ValueError("Only one of XX or NX can be set, but not both.") - - if len([i for i in (ex, px, exat, pxat, keepttl) if i]) > 1: - raise ValueError( - "Only one of EX, PX, EXAT, PXAT, or KEEPTTL can be set, but not multiple of them." - ) - - command = ["SET", str(key), str(value)] - - if nx: - command += ["NX"] - - elif xx: - command += ["XX"] - - if get: - command += ["GET"] - - if ex: - command += ["EX", str(_get_secs(ex))] - - elif px: - command += ["PX", str(_get_millis(px))] - - elif exat: - command += ["EXAT", str(_get_timestamp_secs(exat))] - - elif pxat: - command += ["PXAT", str(_get_timestamp_millis(pxat))] - - elif keepttl: - command += ["KEEPTTL"] - - return self.command(*command) - - -class FullRunnable( - Runnable, - RunnableGenericMixin, - RunnableHashMixin, - RunnableSetMixin, - RunnableStringMixin, -): - """A runnable containing all the command mixins.""" - - -class Connection(FullRunnable): - _reader: asyncio.StreamReader - _writer: asyncio.StreamWriter - _parser: hiredis.Reader - _lock: asyncio.Lock - - def __init__( - self, - client: "Redis", - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - parser: hiredis.Reader = hiredis.Reader(notEnoughData=_Ellipsis), - ) -> None: - """Initializes the connection object. - - Args: - client (Redis): The client that initialized the connection. - reader (asyncio.StreamReader): The socket stream reader. - writer (asyncio.StreamWriter): The socket stream writer. - parser (hiredis.Reader, optional): The RESP3 parser. Defaults to a basic implementation - if not set. - """ - - self._client = client - self._reader = reader - self._writer = writer - self._parser = parser - self._lock = asyncio.Lock() - - super().__init__(client) - - def release(self) -> None: - """Returns the connection back to the pool.""" - - self._client._pool.append(self) - - def destroy(self) -> None: - """Closes the connection and frees it's space in the client's pool.""" - - self._client._open_connections -= 1 - - self._writer.close() - - async def __aenter__(self) -> "Connection": - return self - - async def __aexit__(self, *args) -> None: - self.release() - - async def command(self, *args: list[Resp3Repr]) -> Resp3Repr: - """Executes a command and returns it's response. - - Raises: - hiredis.ReplyError: Any errors returned by Redis. - """ - - args = list(args) - - command = _encode(args) - - async def run(): - async with self._lock: - self._writer.write(command) - await self._writer.drain() - - while True: - self._parser.feed(await self._reader.readuntil(b"\r\n")) - - result = self._parser.gets() - - if result == _Ellipsis: - continue - - elif isinstance(result, hiredis.ReplyError): - raise result - - return result - - try: - return await asyncio.wait_for(run(), self._client._command_timeout) - - except ( - hiredis.ProtocolError, - asyncio.TimeoutError, - ConnectionResetError, - ) as exc: - self.destroy() - - raise FatalRedisError() from exc - - async def hello(self, username: str = None, password: str = None) -> dict: - """Sends the initial HELLO command to the server. - - Args: - username (str, optional): The username to authenticate with. Defaults to None, or - "default" if `password` is set. - password (str, optional): The password to authenticate with. Defaults to None. - - Returns: - dict: The metadata map returned by the server. - """ - - command = ["HELLO", "3"] - - if password is not None: - command += ["AUTH", username or "default", password] - - return await self.command(*command) - - -class Pipeline(FullRunnable): - """A Redis pipeline.""" - - _commands: list[tuple[Resp3Repr]] - - def __init__(self, client: "Redis") -> None: - self._commands = [] - - super().__init__(client) - - def command(self, *args: tuple[Resp3Repr]) -> None: - self._commands.append(args) - - async def execute(self): - """Executes the pipeline. - - Returns: - tuple[Resp3Repr]: The results returned by the execution of the commands. - """ - - command = b"".join([_encode(comm) for comm in self._commands]) - - async with await self._client.connection() as conn: - - async def run(): - async with conn._lock: - conn._writer.write(command) - await conn._writer.drain() - - results = [] - - while True: - conn._parser.feed(await conn._reader.readuntil(b"\r\n")) - - result = conn._parser.gets() - - if result == _Ellipsis: - continue - - elif isinstance(result, hiredis.ReplyError): - raise RedisError( - "An error was returned from the Redis server." - ) from result - - results.append(result) - - if len(results) == len(self._commands): - return results - - try: - return tuple( - await asyncio.wait_for(run(), self._client._command_timeout) - ) - - except ( - hiredis.ProtocolError, - asyncio.TimeoutError, - ConnectionResetError, - ) as exc: - conn.destroy() - - raise FatalRedisError() from exc - - -class Redis(FullRunnable): - _pool: list[Connection] = [] - _open_connections: int = 0 - - def __init__( - self, - url: str = "", - max_connections: int = 100, - connection_timeout: int = 3, - command_timeout: int = 3, - encoding: str = "utf-8", - ) -> None: - """Initializes the client class. - - Args: - url (str): The redis URL to connect to. Defaults to "redis://localhost:6379" - max_connections (int, optional): The maximum amount of connections that can be opened. - Defaults to 100. - connection_timeout (int, optional): The amount of seconds to wait for a connection to - establish before raising a TimeoutError. Defaults to 3. - command_timeout (int, optional): The amount of seconds to wait for a command to execute - before raising a TimeoutError. Defaults to 3. - encoding (str, optional): The encoding with which to decode Redis response values. - """ - - self._client = self - - self._lock = asyncio.Lock() - self._max_connections = max_connections - self._connection_timeout = connection_timeout - self._command_timeout = command_timeout - self._encoding = encoding - - parsed_url = parse.urlparse(url) - - self._host = parsed_url.hostname or "localhost" - self._port = parsed_url.port or 6379 - self._username = parsed_url.username - self._password = parsed_url.password - - async def connection(self) -> Connection: - """Returns an available connection or creates a new one if none are available and the pool - has space remaining. - - Raises: - ConnectionError: If the pool has reached it's max size and no connections are available. - """ - - async with self._lock: - try: - connection = self._pool.pop() - return connection - - except IndexError as exc: - if self._open_connections >= self._max_connections: - raise ConnectionError( - "The pool has reached it's limit of connections and no connections are " - "available at the moment." - ) from exc - - reader, writer = await asyncio.wait_for( - asyncio.open_connection(host=self._host, port=self._port), - self._connection_timeout, - ) - - connection = Connection( - client=self, - reader=reader, - writer=writer, - parser=hiredis.Reader( - notEnoughData=_Ellipsis, encoding=self._encoding - ), - ) - - await connection.hello(username=self._username, password=self._password) - - self._open_connections += 1 - - return connection - - async def command(self, *args: tuple[Resp3Repr]) -> Awaitable[Resp3Repr]: - """Executes a command with a free connection from the pool. - - Returns: - Awaitable[Resp3Repr]: The command's response. - """ - - async with await self.connection() as conn: - return await conn.command(*args) - - def pipeline(self) -> Pipeline: - """Creates a new pipeline.""" - - return Pipeline(self) diff --git a/tasks.py b/tasks.py index afb5041..dbd3974 100644 --- a/tasks.py +++ b/tasks.py @@ -1,107 +1,26 @@ -from datetime import datetime, timezone, timedelta - import asyncio -import functools +import typer -import click - -from procs import flush_messages - -from payphone.db import setup, models -from payphone.bot import bot -from payphone.core import logging, config, constants +from payphone import tasks -logging.setup("Tasks") - -log = logging.logger +app = typer.Typer(name="Tasks") -def coroutine(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) +@app.command() +def connect() -> None: + """Connect to other people.""" - return wrapper + asyncio.run(tasks.connect.main()) -@click.group() -@coroutine -async def main(): - await setup.init() +@app.command() +def expire(): + """Expire calls.""" - -@main.command() -@coroutine -async def flush(): - """Flush all messages in Redis to the database. Runs every 5 seconds.""" - - while True: - await flush_messages.flush() - await asyncio.sleep(5) - - -@main.command() -@coroutine -async def delete_accounts(): - """Delete all accounts that were scheduled for deletion. Runs every 1 hour.""" - - while True: - total = await models.User.filter(is_deleting=True).delete() - - log.info("Deleted %s accounts scheduled for deletion.", total) - - await asyncio.sleep(60 * 60) - - -@main.command() -@coroutine -async def hangup_inactive_calls(): - """Hangs up inactive calls.""" - - await bot.login(config.TOKEN) - - await bot.redis.ping() - - while True: - await asyncio.sleep(5) - - for call in await models.Call.filter(ended_at=None).prefetch_related( - "channels" - ): - if call.started_at > datetime.now(timezone.utc) - timedelta(minutes=1): - continue - - if ( - await bot.redis.exists( - constants.CALL_MESSAGES_TEMPLATE % call.id, - constants.CALL_IS_ACTIVE % call.id, - ) - ) != 2: - call.ended_at = datetime.now(timezone.utc) - await call.save() - - log.info("Hung up call %s.", call.id) - - try: - channels = [ - await bot.get_or_fetch_channel(channel.id) - for channel in call.channels - ] - - for channel in channels: - try: - await channel.send( - "☎️ The call was hung up because nobody replied." - ) - - finally: - pass - - finally: - continue + asyncio.run(tasks.expire.main()) if __name__ == "__main__": - main() + app() diff --git a/uv.lock b/uv.lock index 44f5853..9afb9bf 100644 --- a/uv.lock +++ b/uv.lock @@ -109,19 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] -[[package]] -name = "anyio" -version = "4.6.2.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, -] - [[package]] name = "asyncpg" version = "0.30.0" @@ -156,12 +143,17 @@ wheels = [ ] [[package]] -name = "certifi" -version = "2024.8.30" +name = "ciso8601" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/bc/cf42c1b0042f91c90a6b00244f63b6fb137af15e43e29f07bb72cf955be8/ciso8601-2.3.1.tar.gz", hash = "sha256:3212c7ffe5d8080270548b5f2692ffd2039683b6628a8d2ad456122cc5793c4c", size = 31225 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/85/3d/f6496a260ba6e58135fb3dd1108799f6dd9cadf634372e020bfbf0d27fea/ciso8601-2.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f39bb5936debf21c52e5d52b89f26857c303da80c43a72883946096a6ef5e561", size = 24211 }, + { url = "https://files.pythonhosted.org/packages/c4/a9/24ffa848a5878a50009d6177826c36b60e2e8807e4d54ee94817e790897b/ciso8601-2.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:21cf83ca945bb26ecd95364ae2c9ed0276378e5fe35ce1b64d4c6d5b33038ea3", size = 15591 }, + { url = "https://files.pythonhosted.org/packages/ea/74/77fd6e67a2a3489a1ac449570142e2f4137289be25027e235d4688470d56/ciso8601-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:013410263cba46748d2de29e9894341ae41223356cde7970478c32bd0984d10c", size = 15643 }, + { url = "https://files.pythonhosted.org/packages/5b/bd/fef5524974e3ba376e16df35e4197152edffdf7ac9d5d99bc173a9fcf256/ciso8601-2.3.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b26935687ef1837b56997d8c61f1d789e698be58b261410e629eda9c89812141", size = 39805 }, + { url = "https://files.pythonhosted.org/packages/50/1c/d1cb5b2d2173abfc4d5a068981acddff763cce318e896ec87c140412c72d/ciso8601-2.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0d980a2a88030d4d8b2434623c250866a75b4979d289eba69bec445c51ace99f", size = 48335 }, + { url = "https://files.pythonhosted.org/packages/76/ef/5ef06ccb6f6c023573634119f93df237687e3f4263e2f307b51a7208d103/ciso8601-2.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:87721de54e008fb1c4c3978553b05a9c417aa25b76ddf5702d6f7e8d9b109288", size = 49449 }, ] [[package]] @@ -185,18 +177,6 @@ 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 = "colorthief" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/b2/b55b741f7a7d1299d23e1c635f00f6c57ea4d2e9b76d09e1fc5ea3ca9921/colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221", size = 6164 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/18/be03b7058e65f9df479b14e7af4e73945ce311e07aaad45cf2536e14791a/colorthief-0.2.1-py2.py3-none-any.whl", hash = "sha256:b04fc8ce5cf9c888768745e29cb19b7b688d5711af6fba26e8057debabec56b9", size = 6134 }, -] - [[package]] name = "dictdiffer" version = "0.9.0" @@ -245,15 +225,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - [[package]] name = "hiredis" version = "3.0.0" @@ -277,35 +248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/67/f50b45071bb8652fa9a28a84ee470a02042fb7a096a16f3c08842f2a5c2b/hiredis-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e584fe5f4e6681d8762982be055f1534e0170f6308a7a90f58d737bab12ff6a8", size = 21971 }, ] -[[package]] -name = "httpcore" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, -] - -[[package]] -name = "httpx" -version = "0.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, -] - [[package]] name = "idna" version = "3.10" @@ -411,24 +353,22 @@ wheels = [ [[package]] name = "payphone" -version = "0.14.0" +version = "0.18.0" source = { virtual = "." } dependencies = [ { name = "aerich" }, { name = "aiohttp" }, - { name = "click" }, - { name = "colorthief" }, - { name = "hiredis" }, - { name = "httpx" }, { name = "orjson" }, { name = "py-cord" }, - { name = "python-dotenv" }, + { name = "redis", extra = ["hiredis"] }, { name = "rich" }, - { name = "tortoise-orm", extra = ["asyncpg"] }, + { name = "tortoise-orm", extra = ["accel", "asyncpg"] }, + { name = "typer" }, ] [package.dev-dependencies] dev = [ + { name = "python-dotenv" }, { name = "toml" }, ] @@ -436,56 +376,18 @@ dev = [ requires-dist = [ { name = "aerich", specifier = "==0.7.1" }, { name = "aiohttp", specifier = ">=3.10.10" }, - { name = "click", specifier = ">=8.1.7" }, - { name = "colorthief", specifier = ">=0.2.1" }, - { name = "hiredis", specifier = ">=3.0.0" }, - { name = "httpx", specifier = ">=0.27.2" }, - { name = "orjson", specifier = ">=3.10.7" }, + { name = "orjson", specifier = ">=3.10.11" }, { name = "py-cord", specifier = ">=2.6.1" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "rich", specifier = ">=13.9.3" }, - { name = "tortoise-orm", extras = ["asyncpg"], specifier = ">=0.21.7" }, + { name = "redis", extras = ["hiredis"], specifier = ">=5.2.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "tortoise-orm", extras = ["accel", "asyncpg"], specifier = ">=0.21.7" }, + { name = "typer", specifier = ">=0.13.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "toml", specifier = ">=0.10.2" }] - -[[package]] -name = "pillow" -version = "11.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, - { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, - { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, - { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, - { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, - { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, - { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, - { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, - { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, - { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, - { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, - { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, - { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, - { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, - { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 }, - { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 }, - { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 }, - { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 }, - { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 }, - { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 }, - { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 }, - { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 }, - { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 }, - { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 }, - { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 }, - { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 }, - { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 }, - { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 }, - { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 }, - { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 }, +dev = [ + { name = "python-dotenv", specifier = ">=1.0.1" }, + { name = "toml", specifier = ">=0.10.2" }, ] [[package]] @@ -626,6 +528,20 @@ wheels = [ { 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.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/17/2f4a87ffa4cd93714cf52edfa3ea94589e9de65f71e9f99cbcfa84347a53/redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", size = 4607878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f5/ffa560ecc4bafbf25f7961c3d6f50d627a90186352e27e7d0ba5b1f6d87d/redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897", size = 261428 }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + [[package]] name = "rich" version = "13.9.4" @@ -640,12 +556,12 @@ wheels = [ ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -683,10 +599,30 @@ wheels = [ ] [package.optional-dependencies] +accel = [ + { name = "ciso8601", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "orjson" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, +] asyncpg = [ { name = "asyncpg" }, ] +[[package]] +name = "typer" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/87/9eb07fdfa14e22ec7658b5b1147836d22df3848a22c85a4e18ed272303a5/typer-0.13.0.tar.gz", hash = "sha256:f1c7198347939361eec90139ffa0fd8b3df3a2259d5852a0f7400e476d95985c", size = 97572 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/7e/c8bfa8cbcd3ea1d25d2beb359b5c5a3f4339a7e2e5d9e3ef3e29ba3ab3b9/typer-0.13.0-py3-none-any.whl", hash = "sha256:d85fe0b777b2517cc99c8055ed735452f2659cd45e451507c76f48ce5c1d00e2", size = 44194 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -696,6 +632,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284 }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349 }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089 }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770 }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321 }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + [[package]] name = "yarl" version = "1.17.1"