This commit is contained in:
Nyeki.py
2024-11-16 00:04:46 -03:00
82 changed files with 3007 additions and 6273 deletions
+2
View File
@@ -162,3 +162,5 @@ cython_debug/
#.idea/
k8s/
.vscode/
.git/
-1
View File
@@ -123,7 +123,6 @@ celerybeat.pid
# Environments
.env
.env.shared
.venv
env/
venv/
+2 -4
View File
@@ -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"]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

+2 -23
View File
@@ -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()
+471
View File
@@ -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
View File
-2
View File
@@ -3,6 +3,4 @@ kind: ConfigMap
metadata:
name: bot-config
data:
SUGGESTIONS_CHANNEL_ID: "1287841493343801447"
SERVER_INVITE: "https://discord.gg/Cb3rCrTxmm"
IS_DEBUG: "false"
-6
View File
@@ -7,9 +7,6 @@ spec:
- name: connect
image: git.nyeki.dev/payphone/payphone-tasks:latest
command: ["uv", "run", "--no-dev", "tasks.py", "connect"]
envFrom:
- secretRef:
name: bot-secrets
resources:
requests:
memory: "250Mi"
@@ -20,9 +17,6 @@ spec:
- name: expire
image: git.nyeki.dev/payphone/payphone-tasks:latest
command: ["uv", "run", "--no-dev", "tasks.py", "expire"]
envFrom:
- secretRef:
name: bot-secrets
resources:
requests:
memory: "250Mi"
+190 -236
View File
@@ -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)
+105 -182
View File
@@ -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.
if vanity is not None:
vanity_code = vanity.code
Options:
- `<user>`: The user to give the role.
- `[toggle]`: Whether to give or remove the role.
except errors.DiscordException:
pass
Permissions:
- Bot Owner
"""
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 <setting>` to get help about a specific configuration.
"""
await ctx.send_help("config")
@config.command(extras={"help_name": "Prefix"})
@commands.guild_only()
@commands.has_guild_permissions(manage_guild=True)
@transactions.atomic()
async def prefix(self, ctx: commands.Context, prefix: str = "p.") -> None:
"""
Set a custom bot prefix.
Options:
- `<prefix>`: The new custom prefix.
Permissions:
- Manage Server
"""
try:
await self.bot.set_prefix(ctx.guild, prefix)
except ValueError:
await ctx.send("❌ The prefix must not be longer than 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:
- `[<channel>]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled.
Permissions:
- Manage Channels
"""
server_id = ctx.guild.id
channel = channel or ctx.channel
if isinstance(channel, str) and channel.lower() == "all":
await models.Channel.filter(server_id=server_id).update(enabled=None)
await ctx.send(
"✅ All channels were enabled! You can now run commands in any channel."
)
else:
row = await 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:
- `[<channel>]`: The channel to disable. If no channel is specified, the current channel will be disabled.
Permissions:
- Manage Channels
"""
channel = channel or ctx.channel
row = await 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:
- `<user>`: 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))
+33
View File
@@ -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**: <t:{int(report.created_at.timestamp())}>\n"
),
fields=[
discord.EmbedField(
name="Message Content", value=report.message_content, inline=False
)
],
thumbnail=report.user_avatar_url,
)
+197
View File
@@ -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" <t:{int(expires_at.timestamp())}:R>"
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)
)
+396 -1046
View File
File diff suppressed because it is too large Load Diff
-104
View File
@@ -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
-10
View File
@@ -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
-35
View File
@@ -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)
-66
View File
@@ -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))
+97 -401
View File
@@ -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:
- `<user>`: The user to ban or their ID.
- `[<duration>]`: The amount of time to ban the user for. For example, `1d 5h`.
- `[<reason>]`: 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 <setting>` 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:
- `<prefix>`: 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 <t:{int(row.expires_at.timestamp())}:F> (<t:{int(row.expires_at.timestamp())}:R>)"
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:
- `[<channel>]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled.
Permissions:
- Manage Channels
"""
server_id = ctx.guild.id
channel = channel or ctx.channel
if isinstance(channel, str) and channel.lower() == "all":
await models.Channel.filter(server_id=server_id).update(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:
- `<user>`: 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:
- `<user>`: The user's mention or their ID.
- `<reason>`: 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:
- `<user>`: The user to toggle the moderation status for.
- `[<toggle>]`: The moderator status to set to the user.
- `[<channel>]`: 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:
- `<iterations>`: The amount of iterations (to display progress). Defaults to 5.
- `<wait>`: 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:
- `[<user>]`: 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 <t:{int(ban.expires_at.timestamp())}:R>" 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}>"} <t:{int(ban.expires_at.timestamp())}:R>."
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))
-84
View File
@@ -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"<t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
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 <t:{int(report.call.started_at.timestamp())}:F> to <t:{int(report.call.ended_at.timestamp())}:F>.",
),
],
author=bot.embed_author,
footer=discord.EmbedFooter(
text=f"Call ID: {report.call.id} | Report ID: {report.id}"
),
thumbnail=bot.user.display_avatar.url,
)
-90
View File
@@ -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 <t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
else:
message += " permanently"
message += "."
await interaction.channel.send(message)
-233
View File
@@ -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),
)
-592
View File
@@ -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:
- `[<toggle>]`: 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"<t:{int(user.joined_at.timestamp())}:d> <t:{int(user.joined_at.timestamp())}:T>",
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 <t:{int(ban.expires_at.timestamp())}:F> (<t:{int(ban.expires_at.timestamp())}:R>)"
)
),
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:
- `[<board>]`: 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))
-69
View File
@@ -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)
-49
View File
@@ -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))
+625
View File
@@ -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
+14 -519
View File
@@ -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)
+7 -14
View File
@@ -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
-40
View File
@@ -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")
+22 -218
View File
@@ -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](<https://top.gg/bot/1212940413795827763/vote>) to help us reach more users!",
"[Write a review](<https://top.gg/bot/1212940413795827763#reviews>) to tell everyone your opinion about Payphone!",
"Calls will be automatically hung up after 15 minutes if nobody talks.",
"You can change Payphone's prefix with `{prefix}config prefix`.",
"Do you have any suggestions? Use `{prefix}suggest`!",
"You can use `{prefix}skip` to directly search for a new call.",
"The links from `{prefix}history` will take you to that call.",
"Want to stay hidden? Use `{prefix}mask on`!",
"Did you know you can check your mask with `{prefix}mask`?",
"Do you like Payphone? Consider [making a donation](<https://ko-fi.com/payphonedev>) to help us grow Payphone (and keep it running)!",
"Using `{prefix}friendreq` is the best way to ask the other party to add you.",
"Enable and disable channels using `{prefix}config enable` and `{prefix}config disable`!",
"There's a secret easter egg command. Try to find it! (Hint: cat)",
"Make sure to read Payphone's [Code of Conduct](<https://payphone.nyeki.dev/code-of-conduct>). Let's keep Payphone safe and fun for everyone!",
f"Join [Payphone's official server](<{config.SERVER_INVITE}>) to participate in polls for new features!",
"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
-112
View File
@@ -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()
+7 -11
View File
@@ -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."""
-50
View File
@@ -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.
+9 -7
View File
@@ -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 <command> 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",
+9 -2
View File
@@ -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
+109
View File
@@ -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]}>"
+88
View File
@@ -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](<https://top.gg/bot/1212940413795827763/vote>) to help us reach more users!",
"[Write a review](<https://top.gg/bot/1212940413795827763#reviews>) to tell everyone your opinion about Payphone!",
"Calls will be automatically hung up after 15 minutes if nobody talks.",
"You can change Payphone's prefix with `{prefix}config prefix`.",
"Do you have any suggestions? Use `{prefix}suggest`!",
"You can use `{prefix}skip` to directly search for a new call.",
# "The links from `{prefix}history` will take you to that call.",
"Want to stay hidden? Use `{prefix}mask on`!",
"Did you know you can check your mask with `{prefix}mask`?",
"Do you like Payphone? Consider [making a donation](<https://ko-fi.com/payphonedev>) to help us grow Payphone (and keep it running)!",
"Using `{prefix}friendreq` is the best way to ask the other party to add you.",
"Enable and disable channels using `{prefix}config enable` and `{prefix}config disable`!",
"There's a secret easter egg command. Try to find it! (Hint: cat)",
"Make sure to read Payphone's [Code of Conduct](<https://payphone.nyeki.dev/code-of-conduct>). Let's keep Payphone safe and fun for everyone!",
"Join [Payphone's official server](<https://discord.gg/payphone>) 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.",
]
-25
View File
@@ -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
+14 -46
View File
@@ -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
@@ -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 """
"""
@@ -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 """
"""
@@ -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";"""
@@ -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";"""
@@ -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;"""
@@ -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";"""
@@ -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;"""
@@ -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");"""
@@ -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");;"""
@@ -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");;"""
@@ -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;"""
@@ -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");;"""
@@ -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;"""
@@ -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");;"""
@@ -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);"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
@@ -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";"""
@@ -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");;"""
+187 -392
View File
@@ -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)
+26 -2
View File
@@ -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)
-85
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
from payphone.tasks import connect, expire
+32
View File
@@ -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)
+30
View File
@@ -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)
View File
-134
View File
@@ -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("")
+6 -6
View File
@@ -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",]
-826
View File
@@ -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)
+12 -93
View File
@@ -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()
Generated
+80 -124
View File
@@ -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"