feat(shard): 🎉 Initialize the rewrite.
This commit is contained in:
-164
@@ -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
@@ -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/
|
||||
Vendored
+2
-4
@@ -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
@@ -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" ]
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 MiB |
@@ -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
@@ -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
|
||||
-63
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bot-secrets
|
||||
type: Opaque
|
||||
data:
|
||||
TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
from payphone.tasks import connect, expire
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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",]
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
"""
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user