feat(shard): 🎉 Initialize the rewrite.

This commit is contained in:
Nyeki.py
2024-11-13 02:09:47 -03:00
parent a7f64de5b4
commit d9d752c730
85 changed files with 1912 additions and 7626 deletions
-164
View File
@@ -1,164 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
k8s/
+1 -2
View File
@@ -123,7 +123,6 @@ celerybeat.pid
# Environments
.env
.env.shared
.venv
env/
venv/
@@ -160,4 +159,4 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.idea/
+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"]
}
-17
View File
@@ -1,17 +0,0 @@
FROM python:3.12 AS base
WORKDIR /app
COPY . .
RUN pip install uv
RUN uv sync --no-dev
FROM base AS shard
ENTRYPOINT [ "uv", "run", "--no-dev", "bot.py" ]
FROM base AS tasks
ENTRYPOINT [ "uv", "run", "--no-dev", "tasks.py" ]
-264
View File
@@ -1,264 +0,0 @@
# Payphone
Payphone is deployed using Kubernetes + Docker. To set up a local kubernetes cluster, you can use
`minikube`.
## Starting a Cluster
This section will guide you step by step to create a multi-node cluster or a local development
cluster and start up Payphone. Make sure to have:
1. The Discord bot's token, either for development or production depending on what you're setting
up the cluster for.
2. You'll probably need at least 5 CPU cores (6 recommended) across all of your machines + 1 core
per shard, 8 GB of ram + 512Mb of RAM per shard, and 30Gb of disk space available. It'll still
run with less, but it'll be way slower. In multi-node environment, these specifications are
cluster-wide, meaning that if you have 3 nodes with 3 cores, that makes it 9 cores in total and
therefore suitable for a multi-shard deployment. Do not run nodes with less than 2 CPU cores, 2
Gb (ideally 4 Gb) of RAM, and 15 Gb of disk space available.
### Kubernetes Setup
First of all, you need to install Kubernetes in the machine where you'll set up either a node or a
whole cluster.
Make sure to have Docker installed in your machine before running the following steps. You can
check how to install Docker in the
[Docker documentation](https://docs.docker.com/get-started/get-docker/). If you're setting up a new
node for production, it's better if you only install the Docker Engine without Docker Desktop.
#### For Development
Ideally, use `minikube` for local development. This'll allow you to run a complete cluster locally.
Make sure to have downloaded and installed minikube first. On Windows, it's available with
`chocolatey` running `choco install minikube`. You can read the full set up guide for other
installation methods at the [official minikube documentation](https://minikube.sigs.k8s.io/docs/).
After `minikube` is installed, you need to set up a local cluster. This'll create a virtual machine
in a Docker container to act as nodes in your cluster. Run the following command to start a 3-node
local cluster:
```sh
minikube start --network-plugin=cni --cni=calico --nodes=3
```
This'll create 3 docker containers and start them, and set them up to use kubectl connecting them
automatically. Management `kubectl` commands can only be ran from the master/control-panel node,
but with `kubectl` you don't need to worry about that since running `kubectl` from your machine
will work as if you were in a master node.
`kubectl` comes integrated with `Docker Desktop`. You can enable Kubernetes in Docker Desktop at
Docker Desktop > Settings > Kubernetes > Enable Kubernetes.
#### For Production
These steps are required in order to set up a new `microk8s` node. These installation steps assume
they're being ran in an Ubuntu 22.4+ server with at least 2 CPU cores, 4 GB RAM, and 25 GB disk
space, and `snap` installed. You also need root privileges to follow these steps.
First of all, install `microk8s`.
```sh
sudo snap install microk8s --classic
```
After the installation, check that `microk8s` is up and running using the following command:
```sh
microk8s status --wait-ready
```
> [!INFO] In case the last command fails to run because of missing permissions, you'll be guided
> on how to give your current user the required permissions to run the command. Follow those steps
> and re-run the command afterwards.
Then, you need to enable the required extensions for `microk8s` to work properly. Run the following
commands to enable them:
```sh
microk8s enable dns
microk8s enable ha-cluster
microk8s enable community
sudo systemctl enable iscsid # Needed to run the following command.
microk8s enable openebs
```
The rest of the guide uses `kubectl`, but `microk8s` has `kubectl` as a subcommand, so either:
1. You create an alias in `.bash_aliases`, appending `alias kubectl='microk8s kubectl'` to your
`~/.bash_aliases` file. If you don't have one, create an empty file and add the command in it.
After updating the file, run `source ~/.bash_aliases` to apply the changes.
2. Use `microk8s kubectl` instead of `kubectl` in the following steps. This'll require you to type
`microk8s` at the front of all `kubectl` commands that are run from now on.
To add an alias, run the following command:
```sh
echo "alias kubectl='microk8s kubectl'" >> ~/.bash_aliases && source ~/.bash_aliases
```
##### Add a Node to the Cluster
To add a new node to the cluster, run the following command on a master node:
```sh
microk8s add-node
```
You'll get a command to paste in your new node. Copy the one that ends with `--worker` and paste it
in your new node. Wait a few minutes until it connects, and then you're ready to go! No need to
follow the deployment steps if the cluster is already up and running.
##### SSH Security
To secure the access to the server, you'll need to follow the following steps:
###### Disable Password Authentication
Having SSH password authentication leaves an open door for brute force attacks. Before disabling
it, make sure that the server has your SSH key to prevent from getting locked out after proceeding.
After you have made sure your keys have been properly configured, edit the SSH configuration file:
```sh
nano /etc/ssh/ssh_config
```
In the file, find the following configurations:
1. `ChallengeResponseAuthentication`
2. `PasswordAuthentication`
3. `UsePAM`
If they're set in the file, set their value to `no`. After that, restart the SSH service:
```sh
systemctl restart ssh
```
###### Set Up `fail2ban`
The next step to secure SSH is to set up `fail2ban`, which will rate limit failed attempts to log
in. First, install it in the server:
```sh
apt update
apt install fail2ban
```
Next, make a copy of the configuration file:
```sh
cd /etc/fail2ban
cp jail.conf jail.local
```
You now need to update it to enable SSH protection:
```sh
nano jail.local
```
Find the `[ssh]` section and add the following line (or update it if it already exists):
```toml
enabled = true
```
After updating that line, close the file, enable, and start the service:
```sh
systemctl enable fail2ban
systemctl start fail2ban
# Check the status of the service to make sure it was enabled properly. You can exit the status
# screen pressing "q".
systemctl status fail2ban
```
## Deploying Payphone
The following steps show how to pull and install Payphone on a running cluster of nodes.
### Building and Publishing the Payphone Image
Payphone's container images are hosted here at `git.nyeki.dev`. To login, run the following
command:
```sh
docker login git.nyeki.dev
```
A new browser tab will open and you'll be prompted for log in. Once you accept, you'll be logged in
to the container registry.
When you have made an update and want to update the production deployment's image, you need to
build and push the image to the container registry:
```sh
docker build -t git.nyeki.dev/payphone/payphone:latest .
docker push git.nyeki.dev/payphone/payphone:latest
```
### Starting Payphone in the Cluster
#### Setting Up Config Files
First of all, you'll need to set up the environment variables that Payphone needs. For this,
you'll need:
1. A Discord bot token, base64 encoded. You can encode your token at
[www.base64encode.org](https://www.base64encode.org/).
2. A suggestions channel that the bot can write to (its ID).
3. A non-expiring invite to the official server.
First of all, open `k8s/config.yml`. At the bottom of the file, you'll find a `data` object with
some environment variables definitions. Set `SUGGESTIONS_CHANNEL_ID` and `SERVER_INVITE` and save
the file.
Then, after you've base64-encoded your token, open `k8s/secrets.yml` and in a similar `data` object
at the bottom of the file, set `TOKEN` to the base64-encoded Discord bot token and save the
changes.
After the files are updated with their new values, run the following commands to apply these
changes:
```sh
kubectl apply -f k8s/config.yml
kubectl apply -f k8s/secrets.yml
```
If you see a ConfigMap and a Secret being created, that means you've successfully applied the
changes.
#### Setting Up Redis and PostgreSQL
Now, you can initialize Redis. This step is pretty straightforward and you can forget about it
later.
```sh
kubectl apply -f k8s/redis.yml
kubectl apply -f k8s/psql.yml
```
#### Setting Up Payphone
Finally, you need to start the bot's instance. First, log into your `git.nyeki.dev` account from
`kubectl` with the following command:
```sh
kubectl create secret docker-registry regcred --docker-server=https://git.nyeki.dev/v1/ --docker-username=<your-username> --docker-password=<your-password>
```
After that command is ran, the next step is to update the amount of shards. To set the amount,
open `k8s/bot.yml` and set the total number of shards in both `spec.replicas` and
`spec.template.spec.containers.bot.env.TOTAL_SHARDS.value`.
Once it's done, you can save and close the file and run the final command.
```sh
kubectl apply -f k8s/tasks.yml
kubectl apply -f k8s/bot.yml
```
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
-63
View File
@@ -1,63 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: bot
labels:
app: bot
spec:
serviceName: "bot-headless"
replicas: 1 # Adjust this manually together with env.TOTAL_SHARDS down below.
selector:
matchLabels:
app: bot
template:
metadata:
labels:
app: bot
spec:
containers:
- name: bot
image: git.nyeki.dev/payphone/payphone-shard:latest
imagePullPolicy: "Always"
envFrom:
- configMapRef:
name: bot-config
- secretRef:
name: bot-secrets
env:
- name: SHARD_ID
valueFrom:
fieldRef:
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
- name: SHARD_COUNT
value: "1" # Adjust this manually together with spec.replicas up at the top.
resources:
limits:
cpu: "1000m"
memory: "1Gi"
requests:
cpu: "250m"
memory: "256Mi"
imagePullSecrets:
- name: regcred
volumeClaimTemplates:
- metadata:
name: bot-storage
spec:
accessModes:
- ReadWriteOnce # Ensures only one node can mount the volume at a time
storageClassName: openebs-jiva-csi-default
resources:
requests:
storage: 1Gi # Defines the storage size
---
apiVersion: v1
kind: Service
metadata:
name: bot-headless
labels:
app: bot
spec:
clusterIP: None
selector:
app: bot
-8
View File
@@ -1,8 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bot-config
data:
SUGGESTIONS_CHANNEL_ID: "1287841493343801447"
SERVER_INVITE: "https://discord.gg/Cb3rCrTxmm"
IS_DEBUG: "false"
-64
View File
@@ -1,64 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: postgresql
spec:
type: ClusterIP # Internal access only
selector:
app: postgresql
ports:
- protocol: TCP
port: 5432
targetPort: 5432
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-storage
spec:
accessModes:
- ReadWriteOnce
storageClassName: openebs-hostpath # Your storage class
resources:
requests:
storage: 20Gi # Adjust storage as needed
---
apiVersion: v1
kind: Pod
metadata:
name: postgresql
labels:
app: postgresql
spec:
containers:
- name: postgres
image: postgres:latest # Standard PostgreSQL image
args:
- -c
- max_connections=2000
env:
- name: POSTGRES_DB
value: "payphone"
- name: POSTGRES_USER
value: "payphone"
- name: POSTGRES_PASSWORD
value: "payphone"
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: "1.5"
memory: "3Gi"
limits:
cpu: "2"
memory: "4Gi"
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-storage
restartPolicy: Always
-35
View File
@@ -1,35 +0,0 @@
# Redis Pod
apiVersion: v1
kind: Pod
metadata:
name: redis
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7.4.1-alpine
ports:
- containerPort: 6379
resources:
requests:
memory: "1Gi"
cpu: "1"
limits:
memory: "2Gi"
cpu: "1"
---
# Redis Service
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP
-7
View File
@@ -1,7 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: bot-secrets
type: Opaque
data:
TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz
-42
View File
@@ -1,42 +0,0 @@
apiVersion: v1
kind: Pod
metadata:
name: tasks
spec:
containers:
- name: flush
image: git.nyeki.dev/payphone/payphone-tasks:latest
command: ["uv", "run", "--no-dev", "tasks.py", "flush"]
resources:
requests:
memory: "250Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1"
- name: delete-accounts
image: git.nyeki.dev/payphone/payphone-tasks:latest
command: ["uv", "run", "--no-dev", "tasks.py", "delete-accounts"]
resources:
requests:
memory: "250Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1"
- name: hangup-inactive-calls
image: git.nyeki.dev/payphone/payphone-tasks:latest
command: ["uv", "run", "--no-dev", "tasks.py", "hangup-inactive-calls"]
envFrom:
- secretRef:
name: bot-secrets
resources:
requests:
memory: "250Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1"
restartPolicy: Always
imagePullSecrets:
- name: regcred
+55 -365
View File
@@ -1,54 +1,44 @@
from typing import Literal
from datetime import datetime, timezone, timedelta
import sys
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
import redis.asyncio as redis
from redis import 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
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,
)
for cog in ["calls", "admin"]:
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."""
@@ -102,7 +92,7 @@ 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:
@@ -119,365 +109,65 @@ 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.
def clean_message(self, message: discord.Message, call) -> str:
"""Cleans a message.
Args:
user (discord.User | discord.Member | models.User | int): The user to check or their
ID.
message (discord.Message): The message to clean.
call (Call): The call the message is in.
Returns:
bool: Whether the user is banned or not.
(str): The cleaned message.
"""
user_id = user if isinstance(user, int) else user.id
key = constants.BAN_TEMPLATE % user_id
content = message.content
is_banned = await self.redis.get(key)
content = content.replace(
"@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone"
).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here")
if is_banned is not None:
return bool(int(is_banned))
for mention in message.mentions + message.role_mentions:
content = content.replace(
f"<@{mention.id}>", f"@**{mention.display_name}**"
)
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()
for mention in message.channel_mentions:
content = content.replace(
f"<@{mention.id}>", f"#**{mention.display_name}**"
)
await self.redis.set(key, int(is_banned), ex=300)
return content
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.
async def get_user_flag(self, user_id: int) -> str:
"""Gets the flags of a user.
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.
user_id (int): The ID of the user.
Returns:
models.Ban: The newly created ban.
(str): The flags of the user.
"""
user_id = user if isinstance(user, int) else user.id
key = constants.BAN_TEMPLATE % user_id
flag = await self.redis.hget(f"user:{user_id}", "flags")
mod_id = (
moderator.id
if isinstance(moderator, (discord.User, discord.Member, models.User))
else moderator
)
if flag is None:
user, _ = await models.User.get_or_create(id=user_id)
until = datetime.now(timezone.utc) + duration if duration is not None else None
flag = user.flags
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]:
"""Returns the prefix for the message.
The prefix is case-insensitive, meaning that it'll adapt to the message to be valid not
depending of the case. In case that the prefix is invalid, the default or set prefix in its
original case will be returned.
This method can also be used on a server's raw ID. Either `message` or `server_id` must be
set, but not both.
Args:
message (discord.Message | None): The message to check the prefix for, or None.
server_id (int | None): The server's ID to get the prefix from.
Returns:
str: The prefix for the message.
Raises:
ValueError: Either `message` or `server_id` must be set, but not both.
"""
default_prefixes = ["p.", "P.", self.user.mention]
if message is not None and server_id is not None:
raise ValueError(
"Either `message` or `server_id` must be set, but not both."
)
if message is None and server_id is None:
raise ValueError("Either `message` or `server_id` must be set.")
if message is not None and message.guild is None:
return default_prefixes
if message is not None and message.content is None:
return default_prefixes
if (
message is not None
and message.content is not None
and message.content.startswith(self.user.mention)
):
return default_prefixes
server_id = server_id if server_id is not None else message.guild.id
key = constants.PREFIX_TEMPLATE % server_id
prefix: str | None = await self.redis.get(key)
if prefix is None:
server, _ = await models.Server.update_or_create(
(
{"name": message.guild.name}
if message is not None and message.guild is not None
else None
),
id=server_id,
)
await self.redis.set(key, server.prefix, ex=60 * 60)
prefix = server.prefix
if message is not None and message.content.lower().startswith(prefix.lower()):
return message.content[: len(prefix)]
return prefix
async def set_prefix(
self, server: discord.Guild | models.Server | int, prefix: str = "p."
) -> None:
"""Update a server's prefix.
Args:
server (discord.Guild | models.Server | int): The server to update or it's ID.
prefix (str, optional): The new prefix. It cannot be longer than 5 characters. Defaults
to "p.".
Raises:
ValueError: The prefix provided is invalid.
"""
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."
)
server_id = server if isinstance(server, int) else server.id
await models.Server.filter(id=server_id).update(prefix=prefix)
await self.redis.set(constants.PREFIX_TEMPLATE % server_id, prefix, ex=60 * 60)
async def on_command_error(
self, context: commands.Context, exception: commands.CommandError
) -> None:
"""Handles each of the errors raised by commands.
Args:
context (commands.Context): The context of the command.
exception (commands.CommandError): The exception raised.
"""
if isinstance(exception, exceptions.ChannelDisabledError):
await context.send(
"❌ You cannot run commands in this channel.", delete_after=20.0
)
elif isinstance(exception, exceptions.BannedError):
await context.send("❌ You have been banned from Payphone.")
elif isinstance(exception, exceptions.NotOnCallError):
await context.send("❌ You need to be on a call to run this command.")
elif isinstance(exception, exceptions.NotAModeratorError):
await context.send("❌ You're not a bot moderator.")
elif isinstance(exception, errors.MissingRequiredArgument):
await context.send(
f"❌ `{exception.param.name}` is missing. Use `{context.clean_prefix}help {context.command.name}` to learn how to use it."
)
elif isinstance(exception, errors.BotMissingPermissions):
await context.send(
f"❌ Payphone's missing {formatting.text_join([f"`{perm.replace("_", " ").title()}`" for perm in exception.missing_permissions])} permissions to run this command."
)
elif isinstance(exception, errors.NoPrivateMessage):
await context.send("❌ This command can only be run in a server.")
elif isinstance(exception, errors.MissingPermissions):
await context.send("❌ You don't have permission to run this command.")
elif isinstance(exception, errors.CommandOnCooldown):
await context.send(
f"❌ You're going too fast! Wait `{int(exception.retry_after)}` seconds before trying again."
)
elif isinstance(exception, errors.NotOwner):
await context.send("❌ You're not the owner of this bot.")
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
await self.redis.hset(f"user:{user_id}", "flags", int(flag))
else:
return await super().on_command_error(context, exception)
flag = models.UserFlag(int(flag))
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.
"""
return discord.EmbedAuthor(
name=self.user.display_name, icon_url=self.user.display_avatar.url
)
return flag
bot = Payphone()
def run():
"""Runs the bot."""
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")
bot = Payphone()
bot.loop.run_until_complete(setup.init())
@bot.check
async def is_channel_enabled(ctx: commands.Context) -> Literal[True]:
"""Checks whether the current channel is enabled to run a command or not.
Args:
ctx (commands.Context): The current context.
Raises:
exceptions.ChannelDisabledError: The current channel is disabled.
"""
if ctx.guild is None:
return True
if ctx.command.qualified_name in ["config enable", "config disable"]:
return True
server_id = ctx.guild.id
channel_id = ctx.channel.id
await models.Server.get_or_create(id=server_id)
channel, _ = await models.Channel.get_or_create(id=channel_id, server_id=server_id)
if channel.enabled:
return True
if channel.enabled is False:
raise exceptions.ChannelDisabledError()
if await models.Channel.filter(server_id=server_id, enabled=True).exists():
raise exceptions.ChannelDisabledError()
return True
bot.run(constants.TOKEN)
+43 -187
View File
@@ -1,209 +1,65 @@
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.cogs import base
from payphone.core import utils
from payphone.db import models
from payphone.bot import Payphone
class Admin(base.Cog):
def __init__(self, bot_instance: bot.Payphone):
self.bot = bot_instance
class Admin(commands.Cog):
"""Administration in Payphone."""
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__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
) -> None:
"""Gives the user the Moderator role."""
if vanity is not None:
vanity_code = vanity.code
except errors.DiscordException:
pass
await models.Server.update_or_create(
{"name": after.name, "vanity_code": vanity_code}, id=after.id
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 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=["sup"])
@commands.is_owner()
async def supporter(
self, ctx: commands.Context, user: discord.User, toggle: bool = True
) -> None:
"""Gives the user the Supporter role."""
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 ctx.send(
f"Gave the Supporter role to {ctx.author.mention} until in one month."
)
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))
-7
View File
@@ -1,7 +0,0 @@
from discord.ext import commands
class Cog(commands.Cog):
"""Base cog with Payphone-specific properties."""
show_in_help: bool = True
+283 -1048
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))
-443
View File
@@ -1,443 +0,0 @@
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.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
def __init__(self, bot_instance: bot.Payphone):
self.bot = bot_instance
@commands.command(extras={"reply_argument": "user"})
@checks.is_moderator()
@transactions.atomic()
async def ban(
self,
ctx: commands.Context,
user: converters.DiscordUserID,
duration: converters.Timedelta | None = None,
*,
reason: str | None = "",
) -> None:
"""
Ban a user by their ID, pinging them, or replying to their message.
Options:
- `<user>`: The user to ban or their ID.
- `[<duration>]`: The amount of time to ban the user for. For example, `1d 5h`.
- `[<reason>]`: The reason of their ban.
"""
user_id = user
profile = await models.Profile.filter(user_id=user_id).limit(1).first()
row = await self.bot.ban(
user_id,
duration,
reason,
ctx.author,
)
message = f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was banned"
if row.expires_at:
message += f"{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>)"
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),
)
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."
)
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,
) -> None:
"""
Toggles a user's moderator status. It can only be ran by Payphone's owner.
Options:
- `<user>`: The user to toggle the moderation status for.
- `[<toggle>]`: The moderator status to set to the user.
"""
user_row, profile_row = await utils.get_user(user)
if toggle is not None:
user_row.is_moderator = toggle
else:
user_row.is_moderator = not user_row.is_moderator
await user_row.save()
await ctx.send(
f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`."
)
@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.
Args:
b (bot.Payphone): The bot instance to load the cog into.
"""
b.add_cog(Mods(b))
-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)
View File
-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))
+494
View File
@@ -0,0 +1,494 @@
from dataclasses import dataclass
import orjson
from payphone.bot import Payphone
@dataclass
class Participant:
"""A participant in Payphone."""
_bot: Payphone
id: int
name: str
avatar_url: str
@dataclass
class Call:
"""A call in Payphone."""
_bot: Payphone
id: int
channels: tuple[int, int]
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 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
) -> Participant:
"""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.
Returns:
Participant: The participant that was set.
"""
data = orjson.dumps({"name": user_name, "avatar_url": user_avatar_url})
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)
return Participant(
_bot=self._bot, id=user_id, name=user_name, avatar_url=user_avatar_url
)
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"]
)
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"],
)
)
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(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
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)
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) -> None:
"""Gets a message as part of a call.
Args:
message_id (int): The ID of the message to get.
Returns:
None: The channel and propagation ID of the message.
"""
return await self.bot.redis.hget("calls:messages", 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", id)
return int(author_id)
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
+7 -516
View File
@@ -1,532 +1,23 @@
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) -> str:
"""Returns the first censored word or pattern found.
Args:
-51
View File
@@ -1,51 +0,0 @@
from discord.ext import commands
from payphone.db import utils
from payphone.core import exceptions, constants
def is_moderator():
"""Checks whether the user is a moderator or not."""
async def predicate(ctx: commands.Context) -> bool:
if ctx.author.bot:
return False
user, _ = await utils.get_user(ctx.author)
if user.is_moderator:
return True
raise exceptions.NotAModeratorError()
return commands.check(predicate)
def is_unbanned():
"""Checks that the user is not banned."""
async def predicate(ctx: commands.Context) -> bool:
if await ctx.bot.is_banned(ctx.author):
raise exceptions.BannedError()
return True
return commands.check(predicate)
def is_on_call():
"""Checks that a call is ongoing in the channel."""
async def predicate(ctx: commands.Context) -> bool:
channel_id = ctx.channel.id
is_on_call = await ctx.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if not is_on_call:
raise exceptions.NotOnCallError()
return True
return commands.check(predicate)
-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")
+21 -218
View File
@@ -1,228 +1,31 @@
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
-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()
-21
View File
@@ -1,21 +0,0 @@
from discord.ext.commands import errors
class ChannelDisabledError(errors.CheckFailure):
"""Raised when the channel being run the command in is disabled."""
class NotAModeratorError(errors.CheckFailure):
"""Raised when a non-moderator tries to run a moderator-only command."""
class InvalidTimedeltaError(Exception):
"""Raised when the passed timedelta string cannot be parsed by the converter."""
class BannedError(errors.CheckFailure):
"""Raised when the user running the command is banned."""
class NotOnCallError(errors.CheckFailure):
"""Raised when the command is ran in a channel with no ongoing calls."""
-271
View File
@@ -1,271 +0,0 @@
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.
Args:
text (str): The original text.
length (int, optional): The max length of the string before trimming. Defaults to 100.
Returns:
str: The trimmed (or not) text.
"""
if len(text) > length:
return text[:length - 3] + "..."
return text
def text_join(texts: list[str], join_word: str = "and") -> str:
"""Joins a list with commas and the last item with an "and".
Args:
texts (list[str]): The list of texts to concatenate.
join_word (str): The word to use at the end of the joint.
Returns:
str: The formatted concatenation.
"""
if len(texts) > 1:
return f"{", ".join(texts[:-1])} {join_word} {texts[-1]}"
return texts[0]
def monospace_grid(values: list[str], space: int = 14) -> str:
"""Creates a monospace grid.
Args:
values (list[str]): All the values that the list must contain
space (int, optional): The width of each column. Defaults to 14.
Returns:
str: The formatted grid.
"""
return "```" + "".join([v + " " * (space - len(v)) for v in values]) + "```"
def monospace_table(
rows: list[list[str | tuple[str, str]]],
headers: list | None = None,
column_separator: str = "|",
header_separator: str = "-",
intersection: str = "+",
padding: int = 1,
) -> str:
"""Creates a monospace table.
Creates a monospace table. Row columns can be either a string with the column's content, or a
tuple with two items, being the first one aligned to the left and the second one aligned to the
right. Non-string values will be converted to string using `str(value)`.
Args:
rows (list[list[str | tuple[str, str]]]): The rows.
headers (list | None, optional): The headers.
column_separator (str, optional): The column separator. Defaults to "|".
header_separator (str, optional): The header separator. Defaults to "-".
intersection (str, optional): The intersection character where column and row separators
meet.
padding (int, optional): The padding that each column has at its sides.
Returns:
str: The monospace table.
"""
max_sizes = (
[
max(
*(
[
(
len(str(row[i]))
if not isinstance(row[i], tuple)
else (sum(len(str(item)) for item in row[i]) + 1)
)
for row in rows
]
+ ([len(headers[i])] if headers is not None else [])
)
)
for i in range(len(rows[0]))
]
if len(rows) >= 2
else (
[
(
len(str(col))
if not isinstance(col, tuple)
else (sum(len(str(item)) for item in col) + 1)
)
for col in rows[0]
]
if len(rows) > 0
else []
)
)
def format_row(row: list) -> str:
return column_separator.join(
[
(" " * padding)
+ (
(str(col) + (" " * (max_sizes[i] - len(str(col)))))
if not isinstance(col, tuple)
else (
str(col[0])
+ (" " * (max_sizes[i] - len(str(col[0])) - len(str(col[1]))))
+ str(col[1])
)
)
+ (" " * padding)
for i, col in enumerate(row)
]
)
formatted_rows = []
for row in rows:
formatted_rows.append(format_row(row))
table = "\n".join(formatted_rows)
if headers is not None:
header = format_row(headers)
separator = intersection.join(
[header_separator * (size + padding * 2) for size in max_sizes]
)
table = f"{header}\n{separator}\n{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.
Args:
time_diff (timedelta): The timedelta.
Returns:
str: The string representation.
"""
years = time_diff.days // 365
days = time_diff.days % 365
hours, remainder = divmod(time_diff.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
parts = []
if years > 0:
parts.append(f"{years} year{"s" if years != 1 else ""}")
if days > 0:
parts.append(f"{days} day{"s" if days != 1 else ""}")
if hours > 0:
parts.append(f"{hours} hour{"s" if hours != 1 else ""}")
if minutes > 0:
parts.append(f"{minutes} min{"s" if minutes != 1 else ""}")
if seconds > 0:
parts.append(f"{seconds} sec{"s" if seconds != 1 else ""}")
return " ".join(parts)
def parse_timedelta(text: str) -> timedelta:
"""Parses a timedelta string formatted as 30h 45m to a timedelta object.
Args:
text (str): The formatted string.
Raises:
ValueError: The string is invalid.
Returns:
datetime.timedelta: The timedelta object.
"""
pattern = re.compile(
r"(?:(\d+)y)?\s*" # years
r"(?:(\d+)mo)?\s*" # months
r"(?:(\d+)w)?\s*" # weeks
r"(?:(\d+)d)?\s*" # days
r"(?:(\d+)h)?\s*" # hours
r"(?:(\d+)m)?\s*" # minutes
r"(?:(\d+)s)?" # seconds
)
matches = pattern.match(text.strip())
if not matches:
raise ValueError("Invalid time string format")
years, months, weeks, days, hours, minutes, seconds = matches.groups()
years = int(years) if years else 0
months = int(months) if months else 0
weeks = int(weeks) if weeks else 0
days = int(days) if days else 0
hours = int(hours) if hours else 0
minutes = int(minutes) if minutes else 0
seconds = int(seconds) if seconds else 0
days += weeks * 7
days += months * 30
days += years * 365
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
-150
View File
@@ -1,150 +0,0 @@
from typing import List, Dict
import discord
from discord.ext import commands
from payphone.cogs import base
from payphone.core import formatting, config, constants, typing_utils
class HelpView(discord.ui.View):
"""The view sent with the help command."""
def __init__(self):
super().__init__(
discord.ui.Button(
label="Credits",
style=discord.ButtonStyle.gray,
url="https://payphone.nyeki.dev",
),
discord.ui.Button(
label="Donate",
style=discord.ButtonStyle.gray,
url="https://ko-fi.com/payphonedev",
),
discord.ui.Button(
label="Vote",
style=discord.ButtonStyle.gray,
url="https://top.gg/bot/1212940413795827763/vote",
),
discord.ui.Button(
label="Server",
style=discord.ButtonStyle.gray,
url=config.SERVER_INVITE,
),
timeout=None,
)
class HelpCommand(commands.HelpCommand):
async def send_bot_help(
self, mapping: Dict[base.Cog | None, List[commands.Command]]
) -> None:
bot_commands: List[commands.Command] = []
for cog, cog_commands in mapping.items():
if cog is not None and not cog.show_in_help:
continue
bot_commands += cog_commands
embed = discord.Embed(
author=self.context.bot.embed_author,
title="Payphone, call and connect with other people.",
description=(
"Here is the list of all available commands. You can see more details about a "
f"specific command with `{self.context.clean_prefix}help <command>`."
),
thumbnail=self.context.bot.user.display_avatar.url,
fields=[
discord.EmbedField(
name="Commands",
value=formatting.monospace_grid(
[c.name for c in bot_commands], space=12
),
)
],
color=constants.EMBED_COLOR,
footer=discord.EmbedFooter(
text=f"Use {self.context.clean_prefix}help <command> to get more information about a specific command."
),
)
await self.context.send(embed=embed, view=HelpView())
async def send_command_help(self, command: commands.Command) -> None:
command_name = (
command.name
if isinstance(command, discord.SlashCommand)
else command.qualified_name
)
command_options = "".join(
[f" <{option.name}>" for option in command.options]
if isinstance(command, discord.SlashCommand)
else [
(
f" [<{name}>]"
if typing_utils.is_optional(info.annotation)
else f" <{name}>"
)
for name, info in command.params.items()
if name not in ["self", "ctx"]
]
)
command_description = (
command.description if command.description else command.help
).replace("{prefix}", self.context.clean_prefix)
full_command_usage = (
self.context.clean_prefix + command.qualified_name + command_options
)
reply_argument = (
command.extras.get("reply_argument", None) if command.extras else None
)
embed = discord.Embed(
author=discord.EmbedAuthor(
name="Payphone", icon_url=self.context.bot.user.display_avatar.url
),
title=(
command.extras["help_name"]
if command.extras and "help_name" in command.extras
else command_name.title()
)
+ " Command",
description=command_description,
thumbnail=self.context.bot.user.display_avatar.url,
color=constants.EMBED_COLOR,
fields=[
discord.EmbedField(
name="Usage",
value=f"{f"`┌─ [<{reply_argument}>]`\n" if reply_argument else ""}`{full_command_usage}`",
)
]
+ (
[
discord.EmbedField(
name="Aliases",
value=", ".join(
[
f"`{self.context.clean_prefix}{alias}`"
for alias in command.aliases
]
),
)
]
if len(command.aliases) > 0
else []
),
footer=discord.EmbedFooter(
f"Use {self.context.clean_prefix}help <command> to get more information about a specific command."
),
)
await self.context.send(embed=embed)
async def send_group_help(self, group):
return await self.send_command_help(group)
async def send_error_message(self, error):
await self.context.send(f"❌ {error}")
+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
+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
-57
View File
@@ -1,57 +0,0 @@
from typing import Callable, Iterable, TypeVar, Generator
T = TypeVar("T")
U = TypeVar("U")
def deduplicate(i: Iterable[T], key: Callable[[T], U] = lambda i: i) -> list[T]:
"""Deduplicates an Iterable.
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`.
Returns:
list[T]: A list with the deduplicated items.
"""
found_keys = set()
result = []
for item in i:
k = key(item)
if k not in found_keys:
result.append(item)
found_keys.add(k)
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
@@ -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";"""
@@ -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";"""
@@ -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";"""
@@ -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";"""
+91 -394
View File
@@ -1,406 +1,103 @@
from datetime import datetime
from datetime import datetime, timezone
import enum
from tortoise import models, fields, indexes
import discord
from discord import webhook
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
from tortoise import models, fields
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()
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)
is_masked = fields.BooleanField(default=False)
is_moderator = fields.BooleanField(default=False)
is_supporter_until = fields.DatetimeField(null=True)
@property
def flags(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
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(default=True)
class Meta:
"""
Meta information.
"""
indexes = [
models.Index(fields=["server_id", "is_enabled"]),
]
+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("")
+19 -7
View File
@@ -1,15 +1,27 @@
[project]
name = "payphone"
version = "0.15.0"
name = "payphone-2"
version = "0.1.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",
]
[tool]
[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)
-47
View File
@@ -1,47 +0,0 @@
import os
import sys
import getpass
try:
import requests
except ImportError:
print(
"You need to install `requests` to use this file. Run `pip install requests` and try "
"again."
)
sys.exit()
username = input("Username for git.nyeki.dev: ")
password = getpass.getpass("Password for git.nyeki.dev: ")
tags = requests.get(
"https://git.nyeki.dev/v2/payphone/payphone-shard/tags/list",
auth=(username, password),
).json()["tags"]
latest_version = 1
for tag in tags:
tag: str
if tag[1:].isdecimal():
version = int(tag[1:])
if version > latest_version:
latest_version = version
print(f"Updating to tag v{version}...")
os.system(f"git pull https://{username}:{password}@git.nyeki.dev/payphone/payphone")
os.system(
f"microk8s kubectl set image sts/bot bot=git.nyeki.dev/payphone/payphone-shard:v{version}"
)
os.system(
f"""
microk8s kubectl set image deploy/tasks \
flush=git.nyeki.dev/payphone/payphone-shard:v{version} \
delete-accounts=git.nyeki.dev/payphone/payphone-shard:v{version} \
hangup-inactive-calls=git.nyeki.dev/payphone/payphone-shard:v{version}
"""
)
-33
View File
@@ -1,33 +0,0 @@
import os
import sys
try:
import toml
except ImportError:
print(
"You need to install `toml` to use this file. Run `pip install toml` and try again."
)
sys.exit()
data = toml.load(open("pyproject.toml"))
version = data["project"]["version"].split(".")
version[1] = str(int(version[1]) + 1)
data["project"]["version"] = ".".join(version)
version_num = version[1]
targets = ["tasks", "shard"]
for t in targets:
tag = f"git.nyeki.dev/payphone/payphone-{t}:v{version_num}"
os.system(f"docker build --target {t} --tag {tag} .")
os.system(f"docker push {tag}")
tag = f"git.nyeki.dev/payphone/payphone-{t}:latest"
os.system(f"docker build --target {t} --tag {tag} .")
os.system(f"docker push {tag}")
toml.dump(data, open("pyproject.toml", "w"))
+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
+79 -136
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"
@@ -410,83 +352,39 @@ wheels = [
]
[[package]]
name = "payphone"
version = "0.14.0"
name = "payphone-2"
version = "0.1.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 = "toml" },
{ name = "python-dotenv" },
]
[package.metadata]
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" }]
[[package]]
name = "propcache"
@@ -626,6 +524,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,21 +552,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 },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 },
{ 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 +586,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 +619,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"