Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458f89a3a3 | ||
|
|
4df7c8ce7d | ||
|
|
28f8c8e702 | ||
|
|
79f0ca08b2 | ||
|
|
cd7f4c1cc9 | ||
|
|
84e3b533f0 | ||
|
|
7bb3a3387a | ||
|
|
6555ff672f | ||
|
|
41eac08722 | ||
|
|
689bde0acc | ||
|
|
b96474d0fb | ||
|
|
0f0027da27 | ||
|
|
619223cf5e | ||
|
|
edeaa1335f | ||
|
|
1f002cb7ca | ||
|
|
0ffb62a7dc | ||
|
|
b785eaa6c6 | ||
|
|
5bd9cbd3ee | ||
|
|
ca9c25159e | ||
|
|
6ca2649840 | ||
|
|
d444a4ab89 | ||
|
|
7027d0508c | ||
|
|
293ff8f8de | ||
|
|
a004fe2308 | ||
|
|
f123282699 | ||
|
|
24d2344f37 | ||
|
|
00d61545eb |
-166
@@ -1,166 +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/
|
||||
.vscode/
|
||||
.git/
|
||||
+1
-160
@@ -1,162 +1,3 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
/target
|
||||
|
||||
# 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/
|
||||
@@ -1 +0,0 @@
|
||||
3.12
|
||||
Vendored
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"pylint.args": ["--extension-pkg-allow-list=orjson"],
|
||||
"conventionalCommits.scopes": ["shard", "tasks", "kube", "scripts"]
|
||||
}
|
||||
Generated
+2741
File diff suppressed because it is too large
Load Diff
+33
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "payphone"
|
||||
version = "4.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.88"
|
||||
axum = "0.8.4"
|
||||
clap = { version = "4.5.42", features = ["derive", "env", "string"] }
|
||||
colored = "3.0.0"
|
||||
dotenvy = "0.15.7"
|
||||
include_dir = "0.7.4"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.27", features = ["serde"] }
|
||||
notify = "8.2.0"
|
||||
once_cell = "1.21.3"
|
||||
open = "5.3.2"
|
||||
rand = "0.9.2"
|
||||
rand_chacha = { version = "0.9.0", features = ["os_rng"] }
|
||||
regex = "1.11.1"
|
||||
scc = "2.3.4"
|
||||
scylla = { version = "1.3.1", features = ["time-03"] }
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.6.6", features = ["fs"] }
|
||||
twilight-gateway = "0.16.0"
|
||||
twilight-http = { version = "0.16.0", features = ["simd-json"] }
|
||||
twilight-model = "0.16.0"
|
||||
twilight-util = { version = "0.16.0", features = ["builder"] }
|
||||
twilight-validate = "0.16.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unidecode = "0.3.0"
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
FROM registry.nyeki.dev/docker/python/with-uv:3.12-alpine AS base
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
|
||||
RUN python3 -m uv sync --no-dev
|
||||
|
||||
COPY . /app/
|
||||
|
||||
|
||||
FROM base AS shard
|
||||
|
||||
CMD [ "bot.py" ]
|
||||
|
||||
|
||||
FROM base AS tasks
|
||||
|
||||
CMD [ "tasks.py" ]
|
||||
@@ -1,264 +1,9 @@
|
||||
# Payphone
|
||||
|
||||
Payphone is deployed using Kubernetes + Docker. To set up a local kubernetes cluster, you can use
|
||||
`minikube`.
|
||||
To read the documentation, run
|
||||
|
||||
## 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 `registry.nyeki.dev` account from
|
||||
`kubectl` (using the same credentials that in `git.nyeki.dev`) with the following command:
|
||||
|
||||
```sh
|
||||
kubectl create secret docker-registry regcred --docker-server=https://registry.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
|
||||
```shell
|
||||
$ git clone --branch rewrite https://git.nyeki.dev/payphone/payphone.git
|
||||
$ cd payphone
|
||||
$ cargo run -- docs --open
|
||||
```
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from payphone import bot
|
||||
from payphone.core import logging
|
||||
|
||||
|
||||
logging.setup("Payphone")
|
||||
|
||||
|
||||
bot.run()
|
||||
-469
@@ -1,469 +0,0 @@
|
||||
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
|
||||
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
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
services:
|
||||
scylla-node-1:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-1
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
ports:
|
||||
- "9042:9042" # CQL
|
||||
volumes:
|
||||
- scylla-data-1:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.2
|
||||
|
||||
scylla-node-2:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-2
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
volumes:
|
||||
- scylla-data-2:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.3
|
||||
|
||||
scylla-node-3:
|
||||
image: scylladb/scylla:latest
|
||||
container_name: scylla-node-3
|
||||
command: --smp 1 --memory 750M --overprovisioned 1 --seeds=scylla-node-1
|
||||
volumes:
|
||||
- scylla-data-3:/var/lib/scylla
|
||||
networks:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.4
|
||||
|
||||
volumes:
|
||||
scylla-data-1:
|
||||
scylla-data-2:
|
||||
scylla-data-3:
|
||||
|
||||
networks:
|
||||
scylla-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
@@ -1,16 +0,0 @@
|
||||
name: payphone
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
postgresql:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
environment:
|
||||
- POSTGRES_USER=payphone
|
||||
- POSTGRES_PASSWORD=payphone
|
||||
- POSTGRES_DB=payphone
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: bot
|
||||
labels:
|
||||
app: bot
|
||||
spec:
|
||||
serviceName: "bot-headless"
|
||||
replicas: 30 # Adjust this manually together with env.TOTAL_SHARDS down below.
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bot
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bot
|
||||
spec:
|
||||
containers:
|
||||
- name: bot
|
||||
image: registry.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: "30" # 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:
|
||||
IS_DEBUG: "false"
|
||||
REDIS_HOST: "redis"
|
||||
REDIS_PORT: "6379"
|
||||
@@ -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,37 +0,0 @@
|
||||
# Redis Pod
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: redis
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7.4.1-alpine
|
||||
command: ["redis-server"]
|
||||
args: ["--maxclients", "10000"]
|
||||
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,39 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: tasks
|
||||
spec:
|
||||
containers:
|
||||
- name: connect
|
||||
image: registry.nyeki.dev/payphone/payphone/tasks:latest
|
||||
command: ["uv", "run", "--no-dev", "tasks.py", "connect"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: bot-secrets
|
||||
- configMapRef:
|
||||
name: bot-config
|
||||
resources:
|
||||
requests:
|
||||
memory: "250Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
# - name: expire
|
||||
# image: registry.nyeki.dev/payphone/payphone/tasks:latest
|
||||
# command: ["uv", "run", "--no-dev", "tasks.py", "expire"]
|
||||
# envFrom:
|
||||
# - secretRef:
|
||||
# name: bot-secrets
|
||||
# - configMapRef:
|
||||
# name: bot-config
|
||||
# resources:
|
||||
# requests:
|
||||
# memory: "250Mi"
|
||||
# cpu: "100m"
|
||||
# limits:
|
||||
# memory: "1Gi"
|
||||
# cpu: "1"
|
||||
restartPolicy: Always
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
-499
@@ -1,499 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sys
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import errors
|
||||
|
||||
from tortoise import expressions
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
from payphone.db import setup, models
|
||||
from payphone.core import constants, logging, exceptions, formatting, help_command
|
||||
|
||||
|
||||
class Payphone(commands.AutoShardedBot):
|
||||
log = logging.logger
|
||||
|
||||
redis = redis.Redis(
|
||||
host=constants.REDIS_HOST, port=constants.REDIS_PORT, decode_responses=True
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
intents = discord.Intents.default()
|
||||
intents.guilds = True
|
||||
intents.guild_messages = True
|
||||
intents.guild_reactions = True
|
||||
intents.message_content = True
|
||||
|
||||
super().__init__(
|
||||
intents=intents,
|
||||
command_prefix=self.get_prefix,
|
||||
strip_after_prefix=True,
|
||||
case_insensitive=True,
|
||||
help_command=help_command.HelpCommand(),
|
||||
)
|
||||
|
||||
self.shard_ids = [constants.SHARD_ID]
|
||||
self.shard_count = constants.SHARD_COUNT
|
||||
|
||||
for cog in ["calls", "admin", "mods"]:
|
||||
self.load_extension(f"payphone.cogs.{cog}.cog")
|
||||
self.log.info("Loaded cog %s.", cog)
|
||||
|
||||
from payphone.core import calls
|
||||
|
||||
self.call_handler = calls.CallHandler(self)
|
||||
|
||||
self.owner_ids = [
|
||||
# 657091104877051907, # Anastasia
|
||||
1357199907512914051, # Rafael
|
||||
]
|
||||
|
||||
async def on_ready(self) -> None:
|
||||
"""Runs when the bot has finished initializing."""
|
||||
|
||||
self.log.info("Ready! Logged in as %s.", self.user.display_name)
|
||||
|
||||
async def set_presence(self, shard_id: int) -> None:
|
||||
"""Sets the presence of the bot back to online.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the shard to set the status for.
|
||||
"""
|
||||
|
||||
await self.change_presence(
|
||||
activity=discord.CustomActivity(
|
||||
name=f"Listening to p.help | Shard {shard_id + 1}"
|
||||
),
|
||||
status=discord.Status.online,
|
||||
shard_id=shard_id,
|
||||
)
|
||||
|
||||
async def on_shard_connect(self, _shard_id: int) -> None:
|
||||
"""Runs when the shard connects.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the shard that connected.
|
||||
"""
|
||||
|
||||
self.log.info("Shard has connected.")
|
||||
|
||||
async def on_shard_ready(self, shard_id: int) -> None:
|
||||
"""Sets the activity for the shard.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the initialized shard.
|
||||
"""
|
||||
|
||||
self.log.info("Shard is ready.")
|
||||
|
||||
await self.set_presence(shard_id)
|
||||
|
||||
async def on_shard_resumed(self, shard_id: int) -> None:
|
||||
"""Sets the activity for the shard.
|
||||
|
||||
Args:
|
||||
shard_id (int): The ID of the resumed shard.
|
||||
"""
|
||||
|
||||
self.log.info("Shard has resumed.")
|
||||
|
||||
await self.set_presence(shard_id)
|
||||
|
||||
async def get_or_fetch_channel(
|
||||
self, channel_id: int
|
||||
) -> discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread | None:
|
||||
"""Get's a channel from the bot's cache, or fetches it from Discord if it's not available.
|
||||
|
||||
Args:
|
||||
channel_id (int): The channel's ID.
|
||||
|
||||
Returns:
|
||||
(abc.GuildChannel | abc.PrivateChannel | discord.Thread | None): The channel, or None
|
||||
if not found.
|
||||
"""
|
||||
|
||||
channel = self.get_channel(channel_id)
|
||||
|
||||
if channel is None:
|
||||
try:
|
||||
return await self.fetch_channel(channel_id)
|
||||
|
||||
except (discord.errors.Forbidden, discord.errors.NotFound):
|
||||
return None
|
||||
|
||||
return channel
|
||||
|
||||
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, f"<@!{self.user.id}>"]
|
||||
|
||||
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 = f"prefix:{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
|
||||
|
||||
def clean_message(self, message: discord.Message) -> str:
|
||||
"""Cleans a message.
|
||||
|
||||
Args:
|
||||
message (discord.Message): The message to clean.
|
||||
call (Call): The call the message is in.
|
||||
|
||||
Returns:
|
||||
(str): The cleaned message.
|
||||
"""
|
||||
|
||||
content = message.content
|
||||
|
||||
content = content.replace(
|
||||
"@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone"
|
||||
).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here")
|
||||
|
||||
for mention in message.mentions:
|
||||
content = content.replace(
|
||||
f"<@{mention.id}>", f"@**{mention.display_name}**"
|
||||
)
|
||||
|
||||
for mention in message.role_mentions:
|
||||
content = content.replace(f"<@{mention.id}>", f"@**{mention.name}**")
|
||||
|
||||
for mention in message.channel_mentions:
|
||||
content = content.replace(f"<@{mention.id}>", f"#**{mention.name}**")
|
||||
|
||||
return content
|
||||
|
||||
async def is_moderator(self, user_id: int) -> bool:
|
||||
"""Checks whether a user is a moderator.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to check's ID.
|
||||
|
||||
Returns:
|
||||
(bool): Whether the user is a moderator.
|
||||
"""
|
||||
|
||||
user = await models.User.get_cache_or_db(self.redis, user_id)
|
||||
|
||||
return user.is_moderator
|
||||
|
||||
async def is_banned(self, user_id: int) -> bool:
|
||||
"""Checks whether a user is banned.
|
||||
|
||||
Checks whether a user is banned, and caches the result in Redis. The cache expires in 5
|
||||
minutes.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to check's ID.
|
||||
|
||||
Returns:
|
||||
bool: Whether the user is banned or not.
|
||||
"""
|
||||
|
||||
key = f"banned:{user_id}"
|
||||
|
||||
is_banned = await self.redis.get(key)
|
||||
|
||||
if is_banned is not None:
|
||||
return bool(int(is_banned))
|
||||
|
||||
ban = await models.Ban.filter(
|
||||
expressions.Q(user_id=user_id),
|
||||
expressions.Q(
|
||||
expires_at=None,
|
||||
expires_at__gt=datetime.now(timezone.utc),
|
||||
join_type="OR",
|
||||
),
|
||||
).first()
|
||||
|
||||
await self.redis.set(
|
||||
key, int(ban is not None), exat=ban.expires_at if ban else None
|
||||
)
|
||||
|
||||
return is_banned
|
||||
|
||||
async def ban(
|
||||
self,
|
||||
user_id: int,
|
||||
report: models.Report | None,
|
||||
expires_at: datetime | None,
|
||||
created_by_id: int | None,
|
||||
reason: str | None = None,
|
||||
) -> models.Ban:
|
||||
"""Bans a user.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to ban's ID.
|
||||
report (Report | None): The report that caused the ban.
|
||||
expires_at (datetime | None): When the ban expires.
|
||||
created_by_id (int | None): The ID of the user who created the ban.
|
||||
reason (str | None): The reason for the ban.
|
||||
|
||||
Returns:
|
||||
(Ban): The ban.
|
||||
"""
|
||||
|
||||
ban = models.Ban(
|
||||
user_id=user_id,
|
||||
report=report,
|
||||
expires_at=expires_at,
|
||||
created_by_id=created_by_id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
await ban.save()
|
||||
|
||||
await models.User.filter(id=user_id).update(
|
||||
bans_count=expressions.F("bans_count") + 1
|
||||
)
|
||||
|
||||
await self.redis.set(f"banned:{user_id}", 1, exat=expires_at)
|
||||
|
||||
return ban
|
||||
|
||||
async def unban(
|
||||
self, user_id: int, expired_by: int | None, send_notification: bool = True
|
||||
) -> None:
|
||||
"""Unbans a user.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to unban's ID.
|
||||
expired_by (int | None): The user who expired the ban.
|
||||
send_notification (bool): Whether to send a notification to the user or not.
|
||||
"""
|
||||
|
||||
await models.Ban.filter(
|
||||
(
|
||||
expressions.Q(expires_at__gt=datetime.now(timezone.utc))
|
||||
| expressions.Q(expires_at=None)
|
||||
)
|
||||
& expressions.Q(user_id=user_id)
|
||||
).update(expires_at=datetime.now(timezone.utc), expired_by_id=expired_by)
|
||||
|
||||
await self.redis.delete(f"banned:{user_id}")
|
||||
|
||||
if send_notification:
|
||||
try:
|
||||
await (await self.get_or_fetch_user(user_id)).send(
|
||||
f"🔔 You have been unbanned from Payphone{f" by <@{expired_by}>" if expired_by else ""}."
|
||||
)
|
||||
|
||||
except discord.DiscordException:
|
||||
pass
|
||||
|
||||
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, exceptions.AccountTooYoungError):
|
||||
await context.send(
|
||||
"❌ Your account is too young to use Payphone. It must be at least "
|
||||
f"{exception.days} days old to be able to run commands."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.CommandNotFound):
|
||||
return
|
||||
|
||||
else:
|
||||
sentry_sdk.capture_exception(exception)
|
||||
return await super().on_command_error(context, exception)
|
||||
|
||||
async def on_error(self, *args, **kwargs):
|
||||
sentry_sdk.capture_exception(sys.exc_info())
|
||||
|
||||
@property
|
||||
def embed_author(self) -> discord.EmbedAuthor:
|
||||
"""The author of the embeds."""
|
||||
|
||||
return discord.EmbedAuthor(
|
||||
name=self.user.display_name,
|
||||
icon_url=self.user.display_avatar.url,
|
||||
)
|
||||
|
||||
@commands.check
|
||||
async def is_channel_enabled(self, ctx: commands.Context) -> Literal[True]:
|
||||
"""Checks whether the current channel is enabled to run a command or not.
|
||||
|
||||
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
|
||||
|
||||
channels = await self.redis.hgetall(f"channels:{server_id}")
|
||||
|
||||
if str(channel_id) not in channels:
|
||||
channel, _ = await models.Channel.get_or_create(
|
||||
server_id=server_id, id=channel_id
|
||||
)
|
||||
|
||||
if channel.is_enabled:
|
||||
await self.redis.hset(f"channels:{server_id}", channel_id, 1)
|
||||
return True
|
||||
|
||||
channels = await models.Channel.filter(server_id=server_id, is_enabled=True)
|
||||
|
||||
if not channels:
|
||||
await self.redis.hset(f"channels:{server_id}", channel_id, 1)
|
||||
return True
|
||||
|
||||
await self.redis.hset(f"channels:{server_id}", channel_id, 0)
|
||||
raise exceptions.ChannelDisabledError()
|
||||
|
||||
if channels[str(channel_id)] == "1":
|
||||
return True
|
||||
|
||||
raise exceptions.ChannelDisabledError()
|
||||
|
||||
|
||||
def run():
|
||||
"""Runs the bot."""
|
||||
|
||||
sentry_sdk.init(
|
||||
constants.SENTRY_DSN,
|
||||
environment="development" if constants.IS_DEBUG else "production",
|
||||
server_name=f"Shard #{constants.SHARD_ID + 1}",
|
||||
attach_stacktrace=True,
|
||||
send_default_pii=True,
|
||||
enable_tracing=True,
|
||||
traces_sample_rate=1.0,
|
||||
profiles_sample_rate=1.0,
|
||||
release=constants.VERSION,
|
||||
)
|
||||
|
||||
bot = Payphone()
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
bot.loop.run_until_complete(setup.init())
|
||||
|
||||
bot.run(constants.TOKEN)
|
||||
@@ -1,500 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands, pages
|
||||
|
||||
from tortoise import expressions
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.cogs import base
|
||||
from payphone.cogs.admin import embeds, views
|
||||
from payphone.core import checks, constants, formatting
|
||||
|
||||
|
||||
class Admin(base.Cog):
|
||||
"""Administration in Payphone."""
|
||||
|
||||
show_in_help = False
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
"""Initializes the Calls cog.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
self.bot = bot
|
||||
|
||||
@commands.command(aliases=["mod"], extras={"help_name": "Set Moderator Status"})
|
||||
@commands.is_owner()
|
||||
async def moderator(
|
||||
self, ctx: commands.Context, user: discord.User, toggle: bool | None = None
|
||||
) -> None:
|
||||
"""Gives the user the Moderator role.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user to give the role.
|
||||
- `[toggle]`: Whether to give or remove the role.
|
||||
|
||||
Permissions:
|
||||
- Bot Owner
|
||||
"""
|
||||
|
||||
user: models.User = await models.User.get_or_none(id=user.id)
|
||||
|
||||
if user is None:
|
||||
return await ctx.send("❌ User not found.")
|
||||
|
||||
user.is_moderator = toggle if toggle is not None else not user.is_moderator
|
||||
await user.save()
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
await ctx.send(
|
||||
f"✅ Toggled the moderator role {'on' if user.is_moderator else 'off'} for <@{user.id}>.",
|
||||
allowed_mentions=discord.AllowedMentions(users=False),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["b"])
|
||||
@checks.is_moderator()
|
||||
async def ban(
|
||||
self,
|
||||
ctx: commands.Context,
|
||||
user: discord.User | int | None = None,
|
||||
duration: str | None = None,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Bans the user from the bot.
|
||||
|
||||
Options:
|
||||
- `[user]`: The user to ban or their ID. If none is provided, the author will be banned.
|
||||
- `[duration]`: The duration of the ban (e.g 5d, 4h, 1M). If none is provided, the ban will be permanent.
|
||||
- `[reason]`: The reason for the ban.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id if user else ctx.author.id
|
||||
|
||||
if duration:
|
||||
try:
|
||||
duration = formatting.parse_timedelta(duration)
|
||||
|
||||
except ValueError:
|
||||
reason = f"{duration} {reason}" if reason else duration
|
||||
duration = None
|
||||
|
||||
await self.bot.ban(
|
||||
user_id,
|
||||
None,
|
||||
datetime.now(timezone.utc) + duration if duration else None,
|
||||
ctx.author.id,
|
||||
reason,
|
||||
)
|
||||
|
||||
await ctx.send(f"<@!{user_id}> was banned from Payphone.")
|
||||
|
||||
@commands.command(aliases=["ub"])
|
||||
@checks.is_moderator()
|
||||
async def unban(
|
||||
self, ctx: commands.Context, user: discord.User | None = None
|
||||
) -> None:
|
||||
"""Unbans the user from the bot.
|
||||
|
||||
Options:
|
||||
- `[user]`: The user to unban. If none is provided, the author will be unbanned.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
user = user or ctx.author
|
||||
|
||||
await self.bot.unban(user.id, ctx.author.id)
|
||||
|
||||
await ctx.send(f"{user.mention} was unbanned from Payphone.")
|
||||
|
||||
user: models.User = await models.User.get(id=user.id)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
@commands.command(aliases=["sup"], extras={"help_name": "Supporter Perks"})
|
||||
@commands.is_owner()
|
||||
async def supporter(
|
||||
self, ctx: commands.Context, user: discord.User, months: int = 1
|
||||
) -> None:
|
||||
"""Gives the user the Supporter role.
|
||||
|
||||
Options:
|
||||
- `<user>`: The user to give the role.
|
||||
- `[months]`: The number of months to give the role.
|
||||
|
||||
Permissions:
|
||||
- Bot Owner
|
||||
"""
|
||||
|
||||
instance, _ = await models.User.update_or_create(
|
||||
id=user.id,
|
||||
defaults={
|
||||
"is_supporter_until": (
|
||||
datetime.now(timezone.utc) + timedelta(days=30 * months)
|
||||
if months > 0
|
||||
else None
|
||||
)
|
||||
},
|
||||
)
|
||||
await instance.set_cache(self.bot.redis)
|
||||
|
||||
if months:
|
||||
await ctx.send(
|
||||
f"Gave the Supporter role to <@{user.id}> for {months} months."
|
||||
)
|
||||
|
||||
else:
|
||||
await ctx.send(f"Removed the Supporter role from <@{user.id}>.")
|
||||
|
||||
@commands.command(aliases=["rs"])
|
||||
@checks.is_moderator()
|
||||
async def reports(self, ctx: commands.Context) -> None:
|
||||
"""Shows the reports.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
report = (
|
||||
await models.Report.filter(resolved=False)
|
||||
.select_related("reported_by")
|
||||
.first()
|
||||
)
|
||||
remaining_reports = await models.Report.filter(resolved=False).count()
|
||||
|
||||
if report is None:
|
||||
return await ctx.send("✅ No reports left, have this cookie 🍪 !!")
|
||||
|
||||
await ctx.send(
|
||||
embed=embeds.report_embed(report),
|
||||
view=views.ReportView(self.bot, report, (0, remaining_reports)),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["bs", "ws", "bans", "warns", "warnings"])
|
||||
@checks.is_moderator()
|
||||
async def record(
|
||||
self, ctx: commands.Context, user: discord.User | int | None = None
|
||||
) -> None:
|
||||
"""Shows all bans and warns of a specific user.
|
||||
|
||||
Options:
|
||||
- `[user]`: The user to show the bans of.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id if user else ctx.author.id
|
||||
|
||||
user: models.User | None = await models.User.get_or_none(id=user_id)
|
||||
|
||||
if user is None:
|
||||
return await ctx.send("❌ User not found.")
|
||||
|
||||
bans = (
|
||||
await models.Ban.filter(user_id=user_id)
|
||||
.prefetch_related("report", "created_by", "expired_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
warns = (
|
||||
await models.Warn.filter(user_id=user_id)
|
||||
.prefetch_related("report", "created_by")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
if user.bans_count != len(bans):
|
||||
user.bans_count = len(bans)
|
||||
await user.save()
|
||||
|
||||
if not bans and not warns:
|
||||
if user.bans_count or user.warns_count:
|
||||
await ctx.send(
|
||||
f"❓ <@{user_id}> has no bans nor warns recorded, but they have been banned"
|
||||
f" `{user.bans_count}` times and warned `{user.warns_count}` times.",
|
||||
allowed_mentions=discord.AllowedMentions(users=False),
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.send(f"<@!{user_id}> hasn't been banned nor warned.")
|
||||
return
|
||||
|
||||
ban_embeds = [
|
||||
(
|
||||
discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
title=f"Ban #{ban.id}",
|
||||
description=formatting.trim(ban.report.message_content, 500),
|
||||
author=discord.EmbedAuthor(
|
||||
name=ban.report.user_name,
|
||||
icon_url=ban.report.user_avatar_url,
|
||||
),
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Banned Until",
|
||||
value=(
|
||||
f"<t:{int(ban.expires_at.timestamp())}> (<t:{int(ban.expires_at.timestamp())}:R>)"
|
||||
if ban.expires_at
|
||||
else "Forever"
|
||||
),
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Avatar",
|
||||
value=f"[Click to open avatar URL](<{ban.report.user_avatar_url}>)",
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
discord.EmbedField(
|
||||
name="Replying To",
|
||||
value=f'"{formatting.trim(ban.report.reply_message_content)}" by {ban.report.reply_user_name}',
|
||||
)
|
||||
]
|
||||
if ban.report.reply_message_content
|
||||
else []
|
||||
),
|
||||
thumbnail=ban.report.user_avatar_url,
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"Banned by {(await self.bot.get_or_fetch_user(ban.created_by.id)).display_name}"
|
||||
+ (
|
||||
f" · Unbanned by {(await self.bot.get_or_fetch_user(ban.expired_by.id)).display_name}"
|
||||
if ban.expired_by
|
||||
else ""
|
||||
)
|
||||
+ f" · {user.warns_count}w, {user.bans_count}b"
|
||||
),
|
||||
)
|
||||
if ban.report
|
||||
else discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
title=f"Ban #{ban.id}",
|
||||
description=f"The user was banned manually by <@!{ban.created_by.id}> <t:{int(ban.created_at.timestamp())}:R>. {(f"It expires <t:{int(ban.expires_at.timestamp())}:R>" if not ban.is_expired else f"It expired <t:{int(ban.expires_at.timestamp())}:R>") if ban.expires_at else "It doesn't expire"}.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Reason", value=ban.reason or "No reason provided."
|
||||
)
|
||||
],
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"Banned by {(await self.bot.get_or_fetch_user(ban.created_by.id)).display_name}"
|
||||
+ (
|
||||
f" · Unbanned by {(await self.bot.get_or_fetch_user(ban.expired_by.id)).display_name}"
|
||||
if ban.expired_by
|
||||
else ""
|
||||
)
|
||||
+ f" · {user.warns_count}w, {user.bans_count}b"
|
||||
),
|
||||
)
|
||||
)
|
||||
for ban in bans
|
||||
]
|
||||
|
||||
warn_embeds = [
|
||||
(
|
||||
discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
title=f"Warn #{warn.id}",
|
||||
description=formatting.trim(warn.report.message_content, 500),
|
||||
author=discord.EmbedAuthor(
|
||||
name=warn.report.user_name,
|
||||
icon_url=warn.report.user_avatar_url,
|
||||
),
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="User Avatar",
|
||||
value=f"[Click to open avatar URL](<{warn.report.user_avatar_url}>)",
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
discord.EmbedField(
|
||||
name="Replying To",
|
||||
value=f'"{formatting.trim(warn.report.reply_message_content)}" by {warn.report.reply_user_name}',
|
||||
)
|
||||
]
|
||||
if warn.report.reply_message_content
|
||||
else []
|
||||
),
|
||||
thumbnail=warn.report.user_avatar_url,
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"Warned by {(await self.bot.get_or_fetch_user(warn.created_by.id)).display_name} · {user.warns_count}w, {user.bans_count}b"
|
||||
),
|
||||
)
|
||||
if warn.report
|
||||
else discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
title=f"Warn #{warn.id}",
|
||||
description=f"The user was warned manually by <@!{warn.created_by.id}> <t:{int(warn.created_at.timestamp())}:R>.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Reason", value=warn.reason or "No reason provided."
|
||||
)
|
||||
],
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"Warned by {(await self.bot.get_or_fetch_user(warn.created_by.id)).display_name} · {user.warns_count}w, {user.bans_count}b"
|
||||
),
|
||||
)
|
||||
)
|
||||
for warn in warns
|
||||
]
|
||||
|
||||
await pages.Paginator(
|
||||
ban_embeds + warn_embeds,
|
||||
author_check=True,
|
||||
).send(ctx)
|
||||
|
||||
@commands.command(aliases=["rmw"], extras={"help_name": "Remove Warning"})
|
||||
@checks.is_moderator()
|
||||
async def remove_warning(self, ctx: commands.Context, warn_id: int) -> None:
|
||||
"""Delete a specific warning.
|
||||
|
||||
Options:
|
||||
- `<warn-id>`: The ID of the warning to delete.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
warn = await models.Warn.get_or_none(id=warn_id)
|
||||
|
||||
if warn is None:
|
||||
return await ctx.send("❌ Warning not found.")
|
||||
|
||||
await models.User.filter(id=warn.user_id).update(
|
||||
warns_count=expressions.F("warns_count") - 1
|
||||
)
|
||||
|
||||
await warn.delete()
|
||||
|
||||
user: models.User = await models.User.get_or_none(id=warn.user_id)
|
||||
|
||||
if user:
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
await ctx.send(
|
||||
f"✅ Deleted warning `#{warn.id}` for user <@{warn.user_id}>.",
|
||||
allowed_mentions=discord.AllowedMentions(users=False),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["rmb"], extras={"help_name": "Remove Ban"})
|
||||
@checks.is_moderator()
|
||||
async def remove_ban(self, ctx: commands.Context, ban_id: int) -> None:
|
||||
"""Delete a specific ban.
|
||||
|
||||
Options:
|
||||
- `<ban-id>`: The ID of the ban to delete.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
ban = await models.Ban.get_or_none(id=ban_id)
|
||||
|
||||
if ban is None:
|
||||
return await ctx.send("❌ Ban not found.")
|
||||
|
||||
await models.User.filter(id=ban.user_id).update(
|
||||
bans_count=expressions.F("bans_count") - 1
|
||||
)
|
||||
|
||||
await ban.delete()
|
||||
|
||||
user: models.User = await models.User.get_or_none(id=ban.user_id)
|
||||
|
||||
if user:
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
if (
|
||||
not await models.Ban.filter(
|
||||
expressions.Q(
|
||||
expires_at__gt=datetime.now(timezone.utc),
|
||||
expires_at=None,
|
||||
join_type="OR",
|
||||
),
|
||||
expressions.Q(user_id=ban.user_id),
|
||||
).exists()
|
||||
) and (ban.expires_at is None or ban.expires_at > datetime.now(timezone.utc)):
|
||||
await self.bot.unban(ban.user_id, ctx.author.id)
|
||||
|
||||
await ctx.send(
|
||||
f"✅ Deleted ban `#{ban.id}` for user <@{ban.user_id}>.",
|
||||
allowed_mentions=discord.AllowedMentions(users=False),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["clr"], extras={"help_name": "Clear Criminal Record"})
|
||||
@checks.is_moderator()
|
||||
async def clear_record(
|
||||
self, ctx: commands.Context, user: discord.User | int | None = None
|
||||
) -> None:
|
||||
"""Clears the warns and bans history of a user.
|
||||
|
||||
Options:
|
||||
- `[user]`: The user, their ID, or the author of the command if none is specified.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
user_id = user if isinstance(user, int) else user.id if user else ctx.author.id
|
||||
|
||||
await models.User.filter(id=user_id).update(bans_count=0, warns_count=0)
|
||||
await models.Ban.filter(user_id=user_id).delete()
|
||||
await models.Warn.filter(user_id=user_id).delete()
|
||||
|
||||
await self.bot.unban(user_id, ctx.author.id, False)
|
||||
|
||||
user: models.User = await models.User.get_or_none(id=user_id)
|
||||
|
||||
if user:
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
await ctx.send(
|
||||
f"✅ Cleared the record of <@{user_id}>.",
|
||||
allowed_mentions=discord.AllowedMentions(users=False),
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(user_id)).send(
|
||||
f"✅ Your record has been cleared by {ctx.author.mention}. You are free to use Payphone again."
|
||||
)
|
||||
|
||||
except discord.DiscordException:
|
||||
pass
|
||||
|
||||
@commands.command(aliases=["l"])
|
||||
@checks.is_in_server(constants.PAYPHONE_HUB_ID)
|
||||
@checks.is_moderator()
|
||||
async def lock(self, ctx: commands.Context) -> None:
|
||||
"""Closes and locks a thread.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
- In Payphone Hub
|
||||
"""
|
||||
|
||||
if isinstance(ctx.channel, discord.Thread):
|
||||
# Keep the message sending above because it'll reopen the ticket otherwise.
|
||||
await ctx.send("🎫 This ticket was closed.")
|
||||
|
||||
return await ctx.channel.edit(
|
||||
archived=True,
|
||||
locked=True,
|
||||
)
|
||||
|
||||
await ctx.send("❌ This is not a ticket!")
|
||||
|
||||
|
||||
def setup(bot: Payphone) -> None:
|
||||
"""Loads the Admin cog into the bot.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
bot.add_cog(Admin(bot))
|
||||
@@ -1,35 +0,0 @@
|
||||
import discord
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.core import constants, formatting
|
||||
|
||||
|
||||
def report_embed(report: models.Report) -> discord.Embed:
|
||||
"""Creates an embed for a report.
|
||||
|
||||
Args:
|
||||
report (models.Report): The report to create an embed for.
|
||||
|
||||
Returns:
|
||||
(discord.Embed): The embed.
|
||||
"""
|
||||
|
||||
return discord.Embed(
|
||||
color=constants.PRIMARY_COLOR,
|
||||
title="Message Report",
|
||||
description=(
|
||||
f"**ID:** {report.id}\n"
|
||||
f"**User ID**: {report.user_id}\n"
|
||||
f"**User Name**: {report.user_name}\n"
|
||||
f"**Channel ID**: {report.channel_id}\n"
|
||||
f"**Created At**: <t:{int(report.created_at.timestamp())}>\n"
|
||||
),
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content",
|
||||
value=formatting.trim(report.message_content, 1024),
|
||||
inline=False,
|
||||
)
|
||||
],
|
||||
thumbnail=report.user_avatar_url,
|
||||
)
|
||||
@@ -1,264 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.core import constants, formatting
|
||||
from payphone.cogs.admin import embeds
|
||||
|
||||
|
||||
class ReportView(discord.ui.View):
|
||||
"""The report view."""
|
||||
|
||||
def __init__(
|
||||
self, bot: Payphone, report: models.Report, counter: tuple[int, int]
|
||||
) -> None:
|
||||
"""Initializes the ReportView.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot.
|
||||
report (models.Report): The report.
|
||||
counter (tuple[int, int]): The amount of reports checked during this session, and the
|
||||
total amount of reports assigned to be checked during this session.
|
||||
"""
|
||||
|
||||
super().__init__(timeout=None)
|
||||
|
||||
self.add_item(
|
||||
discord.ui.Button(
|
||||
label=f"{counter[0]}/{counter[1]}",
|
||||
disabled=True,
|
||||
style=discord.ButtonStyle.gray,
|
||||
row=1,
|
||||
)
|
||||
)
|
||||
|
||||
self.bot = bot
|
||||
self.report = report
|
||||
self.counter = counter
|
||||
|
||||
@discord.ui.select(
|
||||
placeholder="Select an action",
|
||||
options=[
|
||||
discord.SelectOption(
|
||||
value="warn",
|
||||
label="Warn",
|
||||
description="Send a warning to the user. This is done automatically.",
|
||||
),
|
||||
discord.SelectOption(
|
||||
value="ban",
|
||||
label="Ban",
|
||||
description="Ban the user. The duration of the ban will be applied automatically.",
|
||||
),
|
||||
discord.SelectOption(
|
||||
value="ban_permanently",
|
||||
label="Permanent Ban",
|
||||
description="Ban the user permanently. Use this when the user must not be allowed back.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def action_callback(
|
||||
self, select: discord.SelectMenu, interaction: discord.Interaction
|
||||
) -> None:
|
||||
"""Handles the action select."""
|
||||
|
||||
interacter = await models.User.get_or_none(id=interaction.user.id)
|
||||
|
||||
if interacter is None or not interacter.is_moderator:
|
||||
return await interaction.response.send_message(
|
||||
"❌ You are not allowed to do this. Only moderators can perform moderative actions.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
self.report.resolved = True
|
||||
await self.report.save()
|
||||
|
||||
report = (
|
||||
await models.Report.filter(resolved=False)
|
||||
.select_related("reported_by")
|
||||
.first()
|
||||
)
|
||||
reports_remaining = await models.Report.filter(resolved=False).count()
|
||||
|
||||
if report is None:
|
||||
self.stop()
|
||||
|
||||
await interaction.response.edit_message(
|
||||
content=f"✅ No reports left, have this cookie 🍪 !!\n-# You handled {reports_remaining + self.counter[0] + 1} reports.",
|
||||
embeds=[],
|
||||
view=None,
|
||||
)
|
||||
|
||||
else:
|
||||
await interaction.response.edit_message(
|
||||
embed=embeds.report_embed(report),
|
||||
view=ReportView(
|
||||
self.bot,
|
||||
report,
|
||||
(self.counter[0] + 1, reports_remaining + self.counter[0] + 1),
|
||||
),
|
||||
)
|
||||
|
||||
user = await models.User.get_cache_or_db(self.bot.redis, self.report.user_id)
|
||||
|
||||
if select.values[0] == "warn" and user.warns_count + user.bans_count < 3:
|
||||
user.warns_count += 1
|
||||
await user.save(force_update=True)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
title="Warned",
|
||||
description=f"You've been warned. You've received `{user.warns_count}` warnings in total.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content",
|
||||
value=f"{formatting.trim(self.report.message_content, 500)}",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Name",
|
||||
value=self.report.user_name,
|
||||
),
|
||||
],
|
||||
thumbnail=self.report.user_avatar_url,
|
||||
footer=discord.EmbedFooter(
|
||||
text="⚠️ Repeating this action will result in a ban."
|
||||
),
|
||||
)
|
||||
|
||||
warn = models.Warn(
|
||||
user_id=self.report.user_id,
|
||||
report=self.report,
|
||||
created_by_id=interaction.user.id,
|
||||
)
|
||||
await warn.save()
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(self.report.user_id)).send(
|
||||
embed=embed
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
try:
|
||||
await (
|
||||
await self.bot.get_or_fetch_user(self.report.reported_by.id)
|
||||
).send(
|
||||
f"🔔 You've helped us warn `{(await self.bot.get_or_fetch_user(self.report.user_id)).display_name}` with your report. Thank you so much!"
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
else:
|
||||
user.bans_count += 1
|
||||
await user.save(force_update=True)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
expires_at = None
|
||||
|
||||
if select.values[0] in ["ban", "warn"]:
|
||||
expiry_times = {
|
||||
1: datetime.now(timezone.utc) + timedelta(days=1),
|
||||
2: datetime.now(timezone.utc) + timedelta(days=7),
|
||||
3: datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
expires_at = expiry_times.get(user.bans_count, None)
|
||||
|
||||
await self.bot.ban(
|
||||
self.report.user_id,
|
||||
self.report,
|
||||
expires_at=expires_at,
|
||||
created_by_id=interaction.user.id,
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
title="Banned",
|
||||
description=f"You've been banned. You've been banned `{user.bans_count}` times.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content",
|
||||
value=f"You sent the following message in a call:\n> {formatting.trim(self.report.message_content, 900)}",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Name",
|
||||
value=self.report.user_name,
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="Expires At",
|
||||
value=(
|
||||
f"<t:{int(expires_at.timestamp())}> <t:{int(expires_at.timestamp())}:R>"
|
||||
if expires_at
|
||||
else "Never"
|
||||
),
|
||||
),
|
||||
],
|
||||
thumbnail=self.report.user_avatar_url,
|
||||
footer=discord.EmbedFooter(
|
||||
text=(
|
||||
"⚠️ In case you get banned again, your ban will last longer. Multiple bans will result in a permanent ban."
|
||||
if expires_at is not None
|
||||
else "⚠️ You've been permanently banned."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(self.report.user_id)).send(
|
||||
embed=embed
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
try:
|
||||
await (
|
||||
await self.bot.get_or_fetch_user(self.report.reported_by.id)
|
||||
).send(
|
||||
f"🔔 You've helped us ban `{(await self.bot.get_or_fetch_user(self.report.user_id)).display_name}` with your report. Thank you so much!"
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
@discord.ui.button(label="Skip", style=discord.ButtonStyle.gray)
|
||||
async def skip_callback(
|
||||
self, _button: discord.ui.Button, interaction: discord.Interaction
|
||||
) -> None:
|
||||
"""Skips the report."""
|
||||
|
||||
interacter = await models.User.get_or_none(id=interaction.user.id)
|
||||
|
||||
if interacter is None or not interacter.is_moderator:
|
||||
return await interaction.response.send_message(
|
||||
"❌ You are not allowed to do this. Only moderators can perform moderative actions.",
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
self.report.resolved = True
|
||||
await self.report.save()
|
||||
|
||||
report = await models.Report.filter(resolved=False).first()
|
||||
reports_remaining = await models.Report.filter(resolved=False).count()
|
||||
|
||||
if report is None:
|
||||
self.stop()
|
||||
|
||||
return await interaction.response.edit_message(
|
||||
content=f"✅ No reports left, have this cookie 🍪 !!\n-# You handled {reports_remaining + self.counter[0] + 1} reports.",
|
||||
embeds=[],
|
||||
view=None,
|
||||
)
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=embeds.report_embed(report),
|
||||
view=ReportView(
|
||||
self.bot,
|
||||
report,
|
||||
(self.counter[0] + 1, reports_remaining + self.counter[0] + 1),
|
||||
),
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class Cog(commands.Cog):
|
||||
"""Base cog with Payphone-specific properties."""
|
||||
|
||||
show_in_help: bool = True
|
||||
@@ -1,685 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import asyncio
|
||||
|
||||
import random
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
import aiohttp
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.cogs import base
|
||||
from payphone.core import constants, censorship, messages, masks, checks, formatting, exceptions
|
||||
|
||||
|
||||
class Calls(base.Cog):
|
||||
"""Calls in Payphone."""
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
"""Initializes the Calls cog.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
self.bot = bot
|
||||
|
||||
@commands.command(aliases=["c"])
|
||||
@commands.bot_has_permissions(
|
||||
send_messages=True,
|
||||
read_messages=True,
|
||||
read_message_history=True,
|
||||
use_external_emojis=True,
|
||||
manage_webhooks=True,
|
||||
manage_messages=True,
|
||||
)
|
||||
@checks.is_not_banned()
|
||||
async def call(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Call and connect with other people.
|
||||
"""
|
||||
|
||||
if await self.bot.call_handler.is_on_call(ctx.channel.id):
|
||||
return await ctx.send(
|
||||
f"☎️ You're already on a call! Use `{ctx.clean_prefix}hangup` to hang up."
|
||||
)
|
||||
|
||||
if await self.bot.call_handler.is_searching(ctx.channel.id):
|
||||
return await ctx.send(
|
||||
f"☎️ You're already searching for a call. Use `{ctx.clean_prefix}hangup` to hang up the phone."
|
||||
)
|
||||
|
||||
await self.bot.call_handler.set_searching(ctx.channel.id, True)
|
||||
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
f"☎️ {random.choice(messages.CALLING)}"
|
||||
)
|
||||
)
|
||||
|
||||
@commands.command(aliases=["s"])
|
||||
@checks.is_not_banned()
|
||||
async def skip(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Skip the current call and search for another one.
|
||||
"""
|
||||
|
||||
if not await self.bot.call_handler.is_on_call(ctx.channel.id):
|
||||
return await ctx.send(
|
||||
f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one."
|
||||
)
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
participant = await call.get_participant(ctx.author.id)
|
||||
|
||||
if participant is None:
|
||||
return await ctx.send(
|
||||
"❌ You cannot skip a call you have not participated in.\n"
|
||||
"-# You didn't send any messages in this call, so you can't skip it."
|
||||
)
|
||||
|
||||
channels = await self.bot.call_handler.skip(ctx.channel.id)
|
||||
|
||||
for channel in channels:
|
||||
if channel == ctx.channel.id:
|
||||
await ctx.send(
|
||||
(
|
||||
f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
|
||||
"☎️ You skipped the call. Searching for another one..."
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
await self.bot.get_partial_messageable(channel).send(
|
||||
f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}"
|
||||
)
|
||||
|
||||
@commands.command(aliases=["h"])
|
||||
@checks.is_not_banned()
|
||||
async def hangup(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Hang up the call.
|
||||
"""
|
||||
|
||||
if not await self.bot.call_handler.is_on_call(ctx.channel.id):
|
||||
if await self.bot.call_handler.is_searching(ctx.channel.id):
|
||||
return await ctx.send(f"☎️ {random.choice(messages.NO_ANSWER)}")
|
||||
|
||||
return await ctx.send(
|
||||
f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one."
|
||||
)
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
participant = await call.get_participant(ctx.author.id)
|
||||
|
||||
if participant is None:
|
||||
return await ctx.send(
|
||||
"❌ You cannot hang up a call you have not participated in.\n"
|
||||
"-# You didn't send any messages in this call, so you can't hang up. Try saying hi!"
|
||||
)
|
||||
|
||||
channels = await self.bot.call_handler.hangup(ctx.channel.id)
|
||||
|
||||
other_channel = channels[0] if channels[0] != ctx.channel.id else channels[1]
|
||||
|
||||
await asyncio.gather(
|
||||
ctx.send(f"☎️ {random.choice(messages.HANGUP)}"),
|
||||
self.bot.get_partial_messageable(other_channel).send(
|
||||
f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}"
|
||||
),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["fr", "friend"])
|
||||
@checks.is_not_banned()
|
||||
async def friendreq(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Send a friend request to the other party.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
if call is None:
|
||||
return await ctx.send(
|
||||
f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one."
|
||||
)
|
||||
|
||||
other_channel = (
|
||||
call.channels[0] if call.channels[0] != ctx.channel.id else call.channels[1]
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
self.bot.get_partial_messageable(other_channel).send(
|
||||
f"📬 **{ctx.author.display_name}** wants you to send them a friend request on Discord! Their username is `{ctx.author.name}`."
|
||||
),
|
||||
ctx.send("📬 Your friend request has been sent!"),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["m"])
|
||||
@commands.bot_has_permissions(
|
||||
send_messages=True,
|
||||
use_external_emojis=True,
|
||||
)
|
||||
@checks.is_not_banned()
|
||||
async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None:
|
||||
"""
|
||||
Toggle your mask.
|
||||
|
||||
Options:
|
||||
- `[toggle]`: Whether to toggle your mask on or off. If no toggle is specified, your mask will be displayed.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
user = await models.User.get_cache_or_db(self.bot.redis, ctx.author.id)
|
||||
|
||||
if toggle is not None:
|
||||
user.is_masked = toggle
|
||||
|
||||
await user.save(force_update=True)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
message = f"🎭 Your mask {"is" if toggle is None else "was set to"} {"`on`" if user.is_masked else "`off`"}."
|
||||
|
||||
if call is not None:
|
||||
participant = await call.get_participant(ctx.author.id)
|
||||
|
||||
if user.is_masked:
|
||||
if participant is not None:
|
||||
message += f" You're currently being displayed as **{masks.get_mask_emoji(participant.mask)} {masks.get_mask_name(participant.mask)}**."
|
||||
|
||||
else:
|
||||
message += " Participate in the call to see your mask."
|
||||
|
||||
message += (
|
||||
" You can toggle your mask on or off using `p.mask on` and `p.mask off`."
|
||||
)
|
||||
|
||||
return await ctx.send(message)
|
||||
|
||||
@commands.command(aliases=["r"])
|
||||
@commands.bot_has_permissions(
|
||||
send_messages=True,
|
||||
)
|
||||
@checks.is_not_banned()
|
||||
async def report(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Report a message in a call.
|
||||
"""
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
title="Reporting has been moved!",
|
||||
description="📝 This command has been replaced! Payphone has switched to per-message reporting due to the high amount of reports.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Reporting on Desktop",
|
||||
value="Right click the message you want to report and, in the opened menu, press `Apps > Report Message`.",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="Reporting on Mobile",
|
||||
value="Press and hold the message you want to report and, in the opened menu, press `Apps > Report Message`.",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
"""
|
||||
Handle messages in calls.
|
||||
"""
|
||||
|
||||
if message.author.created_at > datetime.now(timezone.utc) - timedelta(
|
||||
days=exceptions.AccountTooYoungError.days
|
||||
):
|
||||
return
|
||||
|
||||
if not isinstance(message.channel, (discord.channel.TextChannel)):
|
||||
return
|
||||
|
||||
ctx = await self.bot.get_context(message)
|
||||
|
||||
if message.author.bot or ctx.valid or message.author.system:
|
||||
return
|
||||
|
||||
if message.type not in [discord.MessageType.default, discord.MessageType.reply]:
|
||||
return
|
||||
|
||||
if await self.bot.is_banned(message.author.id):
|
||||
return
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(message.channel.id)
|
||||
|
||||
if call is None:
|
||||
return
|
||||
|
||||
participant, was_mask_toggled = await call.set_participant(
|
||||
message.author.id,
|
||||
message.author.display_name,
|
||||
message.author.display_avatar.url,
|
||||
message.channel.id,
|
||||
)
|
||||
|
||||
if participant.display_name.lower() in [
|
||||
"everyone",
|
||||
"here",
|
||||
"wumpus",
|
||||
"discord",
|
||||
]:
|
||||
await message.reply(
|
||||
f"❌ You can't use {participant.display_name} as your name in calls.\n-# Restriction imposed by Discord."
|
||||
)
|
||||
return
|
||||
|
||||
other_channel = (
|
||||
call.channels[0]
|
||||
if call.channels[0] != message.channel.id
|
||||
else call.channels[1]
|
||||
)
|
||||
|
||||
blocked_section = (
|
||||
censorship.censor(
|
||||
message.content,
|
||||
participant.user.flag & models.UserFlag.SUPPORTER,
|
||||
)
|
||||
if not (participant.user.flag & models.UserFlag.MODERATOR)
|
||||
else None
|
||||
)
|
||||
|
||||
if blocked_section:
|
||||
await message.channel.send(
|
||||
(
|
||||
"❌ Your message wasn't sent because it contained a blocked word or "
|
||||
f"pattern: `{blocked_section}`"
|
||||
),
|
||||
allowed_mentions=discord.AllowedMentions.none(),
|
||||
delete_after=10,
|
||||
)
|
||||
await self.bot.get_partial_messageable(other_channel).send(
|
||||
(
|
||||
f"❌ **{message.author.display_name}** sent a message that was blocked.\n"
|
||||
"-# If you see an inappropiate message, please report it using `Message > "
|
||||
"Apps > Report Message`."
|
||||
),
|
||||
allowed_mentions=discord.AllowedMentions.none(),
|
||||
)
|
||||
return
|
||||
|
||||
message_content = self.bot.clean_message(message)
|
||||
|
||||
notices = []
|
||||
|
||||
if message.stickers:
|
||||
if participant.user.flag & (
|
||||
models.UserFlag.SUPPORTER | models.UserFlag.MODERATOR
|
||||
):
|
||||
sticker = message.stickers[0]
|
||||
|
||||
if sticker.format != discord.StickerFormatType.lottie:
|
||||
message_content = f"[Sticker: {sticker.name}]({sticker.url})"
|
||||
|
||||
else:
|
||||
return await message.channel.send(
|
||||
"💖 Stickers and GIFs are only allowed to donators! You can become one at [our "
|
||||
"Ko-Fi page](<https://ko-fi.com/payphonedev>)."
|
||||
)
|
||||
|
||||
if message.attachments:
|
||||
if participant.user.flag & models.UserFlag.MODERATOR:
|
||||
for attachment in message.attachments:
|
||||
message_content += (
|
||||
f"[{constants.BRAILE_CHARACTER}]({attachment.url})"
|
||||
)
|
||||
|
||||
else:
|
||||
await message.channel.send(
|
||||
f"❌ **{participant.display_name}** You can't send attachments in calls."
|
||||
)
|
||||
|
||||
if not message_content:
|
||||
return
|
||||
|
||||
notices.append("The original message contained attachments.")
|
||||
|
||||
embeds = []
|
||||
|
||||
if message.reference:
|
||||
if await call.is_message_from_call(message.reference.message_id):
|
||||
replied_message = message.reference.cached_message
|
||||
|
||||
if replied_message is None:
|
||||
replied_message = await message.channel.fetch_message(
|
||||
message.reference.message_id
|
||||
)
|
||||
|
||||
original_message_id = (
|
||||
await self.bot.call_handler.get_original_message_id(
|
||||
replied_message.id
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
original_message_id is not None
|
||||
and original_message_id != replied_message.id
|
||||
):
|
||||
try:
|
||||
replied_message = await self.bot.get_partial_messageable(
|
||||
other_channel
|
||||
).fetch_message(original_message_id)
|
||||
|
||||
except discord.errors.NotFound:
|
||||
replied_message = None
|
||||
|
||||
except discord.errors.Forbidden:
|
||||
replied_message = None
|
||||
|
||||
await self.bot.get_partial_messageable(other_channel).send(
|
||||
"❌ **Payphone** is missing permissions to read messages from this "
|
||||
"channel. Make sure it's got `Read Message History` permissions."
|
||||
)
|
||||
|
||||
if replied_message is not None:
|
||||
replied_message_content = self.bot.clean_message(replied_message)
|
||||
replied_participant = await call.get_participant(
|
||||
replied_message.author.id
|
||||
)
|
||||
|
||||
if replied_participant is None:
|
||||
notices.append("In reply to a message from outside this call.")
|
||||
|
||||
else:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.PRIMARY_COLOR,
|
||||
description=replied_message_content,
|
||||
author=discord.EmbedAuthor(
|
||||
name=(
|
||||
replied_participant.display_name
|
||||
if replied_participant.channel_id
|
||||
== message.channel.id
|
||||
else replied_participant.name
|
||||
),
|
||||
icon_url=(
|
||||
replied_participant.display_avatar_url
|
||||
if replied_participant.channel_id
|
||||
== message.channel.id
|
||||
else replied_participant.avatar_url
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
notices.append("In reply to a message from outside this call.")
|
||||
|
||||
else:
|
||||
notices.append("In reply to a message from outside this call.")
|
||||
|
||||
if was_mask_toggled:
|
||||
if participant.is_masked:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
description=f"🎭 **{participant.display_name}** is wearing a mask! Their real identity is hidden.",
|
||||
footer=discord.EmbedFooter(
|
||||
text="You can set your mask using p.mask on."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for _ in range(3):
|
||||
try:
|
||||
webhook = discord.Webhook.from_url(
|
||||
await self.bot.call_handler.get_channel_webhook_url(
|
||||
other_channel
|
||||
),
|
||||
session=session,
|
||||
bot_token=constants.TOKEN,
|
||||
)
|
||||
|
||||
notices_text = (
|
||||
("\n-# ⓘ " + "\n-# ⓘ ".join(notices)) if notices else ""
|
||||
)
|
||||
|
||||
propagated_msg = await webhook.send(
|
||||
content=formatting.trim(
|
||||
message_content, 2000 - len(notices_text)
|
||||
)
|
||||
+ notices_text,
|
||||
username=participant.display_name,
|
||||
avatar_url=participant.display_avatar_url,
|
||||
embeds=embeds,
|
||||
allowed_mentions=discord.AllowedMentions.none(),
|
||||
wait=True,
|
||||
)
|
||||
|
||||
await call.set_message(
|
||||
message.id,
|
||||
message.channel.id,
|
||||
propagated_msg.id,
|
||||
message.author.id,
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
except discord.errors.NotFound:
|
||||
await self.bot.call_handler.refresh_channel_webhook_url(
|
||||
other_channel
|
||||
)
|
||||
|
||||
except discord.errors.Forbidden:
|
||||
await self.bot.get_partial_messageable(other_channel).send(
|
||||
"❌ **Payphone** is missing `Manage Webhooks` permissions. You won't see any messages while that permission is not set."
|
||||
)
|
||||
break
|
||||
|
||||
except discord.errors.DiscordException as exc:
|
||||
sentry_sdk.capture_exception(exc)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_message_delete(
|
||||
self, payload: discord.RawMessageDeleteEvent
|
||||
) -> None:
|
||||
"""
|
||||
Handle deleted messages in calls.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id)
|
||||
|
||||
if call is None:
|
||||
return
|
||||
|
||||
propagation = await call.get_message_propagation(payload.message_id)
|
||||
|
||||
if propagation is None:
|
||||
return
|
||||
|
||||
propagation_channel, propagation_id = propagation
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook = discord.Webhook.from_url(
|
||||
await self.bot.call_handler.get_channel_webhook_url(
|
||||
propagation_channel
|
||||
),
|
||||
session=session,
|
||||
bot_token=constants.TOKEN,
|
||||
)
|
||||
|
||||
try:
|
||||
await webhook.delete_message(propagation_id)
|
||||
|
||||
except discord.errors.NotFound:
|
||||
pass
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
|
||||
"""
|
||||
Handle edited messages in calls.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id)
|
||||
|
||||
if call is None:
|
||||
return
|
||||
|
||||
blocked_section = censorship.censor(payload.data["content"])
|
||||
|
||||
if blocked_section:
|
||||
try:
|
||||
await self.bot.get_partial_messageable(
|
||||
payload.channel_id
|
||||
).get_partial_message(payload.message_id).reply(
|
||||
(
|
||||
"❌ Your message wasn't sent because it contained a blocked word or "
|
||||
f"pattern: `{blocked_section}`"
|
||||
),
|
||||
delete_after=10,
|
||||
)
|
||||
|
||||
except discord.errors.Forbidden:
|
||||
await self.bot.get_partial_messageable(payload.channel_id).send(
|
||||
(
|
||||
f"❌ **{payload.data["member"]["nick"] or payload.data["member"]["user"]["global_name"] or payload.data["member"]["user"]["username"]}** "
|
||||
"your message wasn't sent because it contained a blocked message or "
|
||||
f"pattern: `{blocked_section}`\n-# Payphone doesn't have read message history "
|
||||
"permission? Make sure to keep that one enabled."
|
||||
),
|
||||
delete_after=10,
|
||||
allowed_mentions=discord.AllowedMentions.none(),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
propagation = await call.get_message_propagation(payload.message_id)
|
||||
|
||||
if propagation is None:
|
||||
return
|
||||
|
||||
propagation_channel, propagation_id = propagation
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
webhook = discord.Webhook.from_url(
|
||||
await self.bot.call_handler.get_channel_webhook_url(
|
||||
propagation_channel
|
||||
),
|
||||
session=session,
|
||||
bot_token=constants.TOKEN,
|
||||
)
|
||||
|
||||
await webhook.edit_message(propagation_id, content=payload.data["content"])
|
||||
|
||||
@commands.message_command(
|
||||
name="Report Message" if not constants.IS_DEBUG else "(Dev) Report Message",
|
||||
guild_ids=[constants.PAYPHONE_HUB_ID] if constants.IS_DEBUG else None,
|
||||
)
|
||||
async def report_message(
|
||||
self, ctx: discord.ApplicationContext, message: discord.Message
|
||||
) -> None:
|
||||
"""
|
||||
Report a message in a call.
|
||||
"""
|
||||
|
||||
if await self.bot.is_banned(ctx.author.id):
|
||||
return await ctx.send_response(
|
||||
"❌ You're banned from using Payphone.", ephemeral=True
|
||||
)
|
||||
|
||||
if message.author.bot:
|
||||
message_id = await self.bot.call_handler.get_original_message_id(message.id)
|
||||
author_id = await self.bot.call_handler.get_message_author_id(message_id)
|
||||
found_message = bool(author_id)
|
||||
|
||||
else:
|
||||
message_id = message.id
|
||||
author_id = message.author.id
|
||||
found_message = bool(await self.bot.call_handler.get_message(message.id))
|
||||
|
||||
if not found_message:
|
||||
return await ctx.send_response(
|
||||
(
|
||||
"❌ More than 15 minutes have passed since this message was sent.\n"
|
||||
"-# You'll also see this error if the message wasn't sent during a call."
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
await ctx.send_response("✅ The message has been reported.", ephemeral=True)
|
||||
|
||||
replying_to = (
|
||||
await self.bot.get_partial_messageable(
|
||||
message.reference.channel_id
|
||||
).fetch_message(message.reference.message_id)
|
||||
if message.reference and not message.reference.cached_message
|
||||
else (
|
||||
message.reference.cached_message
|
||||
if message.reference and message.reference.cached_message
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
await models.Report.update_or_create(
|
||||
message_id=message_id,
|
||||
defaults={
|
||||
"channel_id": message.channel.id,
|
||||
"user_id": author_id,
|
||||
"user_name": message.author.display_name,
|
||||
"user_avatar_url": message.author.display_avatar.url,
|
||||
"message_content": message.content,
|
||||
"reply_message_content": (
|
||||
replying_to.clean_content if replying_to else None
|
||||
),
|
||||
"reply_user_id": (replying_to.author.id if replying_to else None),
|
||||
"reply_user_name": (
|
||||
replying_to.author.display_name if replying_to else None
|
||||
),
|
||||
"reply_user_avatar_url": (
|
||||
replying_to.author.display_avatar.url if replying_to else None
|
||||
),
|
||||
"reported_by_id": ctx.author.id,
|
||||
},
|
||||
)
|
||||
|
||||
@commands.command(
|
||||
extras={
|
||||
"show_in_help": False,
|
||||
"help_name": "Cat Mask",
|
||||
"help_error_message": "No.",
|
||||
}
|
||||
)
|
||||
async def catmask(self, ctx: commands.Context) -> None:
|
||||
"""First easter egg. How'd you find it?"""
|
||||
|
||||
await ctx.message.delete()
|
||||
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
if not call:
|
||||
return
|
||||
|
||||
participant, _ = await call.set_participant(
|
||||
ctx.author.id,
|
||||
ctx.author.display_name,
|
||||
ctx.author.display_avatar.url,
|
||||
ctx.channel.id,
|
||||
)
|
||||
|
||||
await participant.set_mask(masks.get_random_mask(call.masks, masks.CAT_MASKS))
|
||||
|
||||
|
||||
def setup(bot: Payphone) -> None:
|
||||
"""Loads the Calls cog into the bot.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
bot.add_cog(Calls(bot))
|
||||
@@ -1,139 +0,0 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.cogs import base
|
||||
|
||||
|
||||
class Mods(base.Cog):
|
||||
"""Administration in Payphone."""
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
"""Initializes the Calls cog.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
self.bot = bot
|
||||
|
||||
@commands.group(invoke_without_command=True)
|
||||
@commands.guild_only()
|
||||
@commands.has_guild_permissions(manage_guild=True)
|
||||
async def config(
|
||||
self, ctx: commands.Context, setting: str = "", value: str | None = None
|
||||
) -> None:
|
||||
"""
|
||||
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)
|
||||
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
|
||||
"""
|
||||
|
||||
if len(prefix) > 10:
|
||||
return await ctx.send(
|
||||
"❌ The prefix must not be longer than 10 characters."
|
||||
)
|
||||
|
||||
server, _ = await models.Server.update_or_create(
|
||||
id=ctx.guild.id, defaults={"name": ctx.guild.name}
|
||||
)
|
||||
|
||||
await server.set_prefix(self.bot.redis, prefix)
|
||||
|
||||
await ctx.send(f"✅ The prefix was set to `{prefix}`!")
|
||||
|
||||
@config.command(extras={"help_name": "Enable Channel"})
|
||||
@commands.guild_only()
|
||||
@commands.has_guild_permissions(manage_channels=True)
|
||||
async def enable(
|
||||
self, ctx: commands.Context, channel: discord.TextChannel | str | None
|
||||
) -> None:
|
||||
"""
|
||||
Enable a channel to run commands in it. Non-enabled channels will pass to be disabled.
|
||||
|
||||
Options:
|
||||
- `[<channel>]`: The channel to enable or "all". If no channel is specified, the current channel will be enabled.
|
||||
|
||||
Permissions:
|
||||
- Manage Channels
|
||||
"""
|
||||
|
||||
server_id = ctx.guild.id
|
||||
channel = channel or ctx.channel
|
||||
|
||||
if isinstance(channel, str) and channel.lower() == "all":
|
||||
await models.Channel.filter(server_id=server_id).update(is_enabled=None)
|
||||
await ctx.bot.redis.delete(f"channels:{server_id}")
|
||||
|
||||
await ctx.send(
|
||||
"✅ All channels were enabled! You can now run commands in any channel."
|
||||
)
|
||||
|
||||
else:
|
||||
await models.Channel.update_or_create(
|
||||
id=channel.id, server_id=server_id, is_enabled=True
|
||||
)
|
||||
await ctx.bot.redis.hset(f"channels:{server_id}", channel.id, "1")
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
await models.Channel.update_or_create(
|
||||
id=channel.id, server_id=ctx.guild.id, is_enabled=False
|
||||
)
|
||||
await ctx.bot.redis.hset(f"channels:{ctx.guild.id}", channel.id, "0")
|
||||
|
||||
await ctx.send(
|
||||
f"✅ {channel.mention} was disabled. Commands cannot be ran in this channel anymore."
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: Payphone) -> None:
|
||||
"""Loads the Mods cog into the bot.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
bot.add_cog(Mods(bot))
|
||||
@@ -1,684 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import orjson
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.core import masks
|
||||
|
||||
|
||||
@dataclass
|
||||
class Participant:
|
||||
"""A participant in Payphone."""
|
||||
|
||||
_bot: Payphone
|
||||
|
||||
id: int
|
||||
name: str
|
||||
avatar_url: str
|
||||
mask: str
|
||||
is_masked: bool
|
||||
channel_id: int
|
||||
call_id: int
|
||||
user: models.User | None = None
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Gets the display name of the participant.
|
||||
|
||||
Returns:
|
||||
str: The display name of the participant.
|
||||
"""
|
||||
|
||||
return self.name if not self.is_masked else masks.get_mask_name(self.mask)
|
||||
|
||||
@property
|
||||
def display_avatar_url(self) -> str:
|
||||
"""Gets the display avatar URL of the participant.
|
||||
|
||||
Returns:
|
||||
str: The display avatar URL of the participant.
|
||||
"""
|
||||
|
||||
return (
|
||||
self.avatar_url
|
||||
if not self.is_masked
|
||||
else masks.get_mask_image_url(self.mask)
|
||||
)
|
||||
|
||||
async def set_mask(self, mask: str) -> None:
|
||||
"""Sets the mask of the participant.
|
||||
|
||||
Args:
|
||||
mask (str): The mask to set.
|
||||
"""
|
||||
|
||||
if mask == self.mask:
|
||||
return
|
||||
|
||||
pipe = self._bot.redis.pipeline()
|
||||
pipe.srem(f"calls:{self.call_id}:masks", self.mask)
|
||||
pipe.sadd(f"calls:{self.call_id}:masks", mask)
|
||||
await pipe.execute()
|
||||
|
||||
self.mask = mask
|
||||
self.is_masked = True
|
||||
|
||||
data = orjson.dumps(
|
||||
{
|
||||
"name": self.name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"mask": mask,
|
||||
"is_masked": True,
|
||||
"channel_id": self.channel_id,
|
||||
}
|
||||
)
|
||||
|
||||
await self._bot.redis.hset(f"calls:{self.call_id}:participants", self.id, data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
id: int
|
||||
call_id: int
|
||||
channel_id: int
|
||||
propagation_id: int
|
||||
propagation_channel_id: int
|
||||
author_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Call:
|
||||
"""A call in Payphone."""
|
||||
|
||||
_bot: Payphone
|
||||
|
||||
id: int
|
||||
channels: tuple[int, int]
|
||||
masks: list[str]
|
||||
|
||||
async def set_message(
|
||||
self, id: int, channel_id: int, propagation_id: int, author_id: int
|
||||
) -> None:
|
||||
"""Sets a message as part of a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to set.
|
||||
channel_id (int): The ID of the channel to set the message in.
|
||||
propagation_id (int): The ID of the propagation message.
|
||||
author_id (int): The ID of the author of the message.
|
||||
"""
|
||||
|
||||
pipe = self._bot.redis.pipeline()
|
||||
|
||||
pipe.hset("calls:messages", id, self.id)
|
||||
pipe.hset("calls:messages:channels", id, channel_id)
|
||||
pipe.hset("calls:messages:propagations", id, propagation_id)
|
||||
pipe.hset("calls:messages:reversed", propagation_id, id)
|
||||
pipe.hset("calls:messages:authors", id, author_id)
|
||||
pipe.hset("calls:last-message", self.id, "")
|
||||
pipe.hexpire("calls:messages", 15 * 60, id)
|
||||
pipe.hexpire("calls:messages:channels", 15 * 60, id)
|
||||
pipe.hexpire("calls:messages:propagations", 15 * 60, id)
|
||||
pipe.hexpire("calls:messages:reversed", 15 * 60, propagation_id)
|
||||
pipe.hexpire("calls:messages:authors", 15 * 60, id)
|
||||
pipe.hexpire("calls:last-message", 15 * 60, self.id)
|
||||
|
||||
await pipe.execute()
|
||||
|
||||
async def get_message(self, id: int) -> Message | None:
|
||||
"""Gets a message as part of a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to get.
|
||||
|
||||
Returns:
|
||||
Message | None: The message that was set.
|
||||
"""
|
||||
|
||||
call_id = await self._bot.redis.hget("calls:messages", id)
|
||||
|
||||
if call_id is None:
|
||||
return
|
||||
|
||||
channel_id = await self._bot.redis.hget("calls:messages:channels", id)
|
||||
propagation_id = await self._bot.redis.hget("calls:messages:propagations", id)
|
||||
author_id = await self._bot.redis.hget("calls:messages:authors", id)
|
||||
|
||||
return Message(
|
||||
id=id,
|
||||
call_id=int(call_id),
|
||||
channel_id=int(channel_id),
|
||||
propagation_id=int(propagation_id),
|
||||
propagation_channel_id=(
|
||||
self.channels[0]
|
||||
if int(channel_id) != self.channels[0]
|
||||
else self.channels[1]
|
||||
),
|
||||
author_id=int(author_id),
|
||||
)
|
||||
|
||||
async def is_message_from_call(self, id: int) -> bool:
|
||||
"""Checks if a message is from a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the message is from the call, False otherwise.
|
||||
"""
|
||||
|
||||
return bool(await self._bot.redis.hexists("calls:messages", id)) or bool(
|
||||
await self._bot.redis.hexists("calls:messages:reversed", id)
|
||||
)
|
||||
|
||||
async def get_message_propagation(self, id: int) -> tuple[int, int] | None:
|
||||
"""Gets a message as part of a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to get.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: The channel and propagation ID of the message.
|
||||
"""
|
||||
|
||||
pipe = self._bot.redis.pipeline()
|
||||
|
||||
pipe.hget("calls:messages:channels", id)
|
||||
pipe.hget("calls:messages:propagations", id)
|
||||
|
||||
channel_id, propagation_id = await pipe.execute()
|
||||
|
||||
if channel_id is None or propagation_id is None:
|
||||
return
|
||||
|
||||
propagation_channel_id = (
|
||||
self.channels[0]
|
||||
if int(channel_id) != self.channels[0]
|
||||
else self.channels[1]
|
||||
)
|
||||
|
||||
return int(propagation_channel_id), int(propagation_id)
|
||||
|
||||
async def set_participant(
|
||||
self, user_id: int, user_name: str, user_avatar_url: str, channel_id: int
|
||||
) -> tuple[Participant, bool]:
|
||||
"""Sets a participant in a call.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user to set.
|
||||
user_name (str): The name of the user to set.
|
||||
user_avatar_url (str): The avatar URL of the user to set.
|
||||
channel_id (int): The ID of the channel the user is in.
|
||||
|
||||
Returns:
|
||||
(Participant, bool): The participant that was set and whether they changed their masked
|
||||
status.
|
||||
"""
|
||||
|
||||
participant = await self.get_participant(user_id)
|
||||
|
||||
user = await models.User.get_cache_or_db(self._bot.redis, user_id)
|
||||
mask = (
|
||||
masks.get_random_mask(self.masks)
|
||||
if participant is None
|
||||
else participant.mask
|
||||
)
|
||||
|
||||
data = orjson.dumps(
|
||||
{
|
||||
"name": user_name,
|
||||
"avatar_url": user_avatar_url,
|
||||
"mask": mask,
|
||||
"is_masked": user.is_masked,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
)
|
||||
|
||||
await self._bot.redis.hset(f"calls:{self.id}:participants", user_id, data)
|
||||
await self._bot.redis.hexpire(f"calls:{self.id}:participants", 3600, user_id)
|
||||
await self._bot.redis.sadd(f"calls:{self.id}:masks", mask)
|
||||
await self._bot.redis.expire(f"calls:{self.id}:masks", 3600)
|
||||
|
||||
return Participant(
|
||||
_bot=self._bot,
|
||||
id=user_id,
|
||||
name=user_name,
|
||||
avatar_url=user_avatar_url,
|
||||
mask=mask,
|
||||
is_masked=user.is_masked,
|
||||
channel_id=channel_id,
|
||||
user=user,
|
||||
call_id=self.id,
|
||||
), (user.is_masked != participant.is_masked if participant else True)
|
||||
|
||||
async def get_participant(self, user_id: int) -> Participant | None:
|
||||
"""Gets a participant in a call.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user to get.
|
||||
|
||||
Returns:
|
||||
Participant | None: The participant that was set.
|
||||
"""
|
||||
|
||||
data = await self._bot.redis.hget(f"calls:{self.id}:participants", user_id)
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
data = orjson.loads(data)
|
||||
|
||||
return Participant(
|
||||
_bot=self._bot,
|
||||
id=user_id,
|
||||
name=data["name"],
|
||||
avatar_url=data["avatar_url"],
|
||||
mask=data["mask"],
|
||||
is_masked=data["is_masked"],
|
||||
channel_id=data["channel_id"],
|
||||
call_id=self.id,
|
||||
)
|
||||
|
||||
async def get_participants(self) -> list["Participant"]:
|
||||
"""Gets all participants in a call.
|
||||
|
||||
Returns:
|
||||
list[Participant]: The participants in the call.
|
||||
"""
|
||||
|
||||
participants = []
|
||||
|
||||
data = await self._bot.redis.hgetall(f"calls:{self.id}:participants")
|
||||
|
||||
for user_id, data in data.items():
|
||||
data = orjson.loads(data)
|
||||
|
||||
participants.append(
|
||||
Participant(
|
||||
_bot=self._bot,
|
||||
id=int(user_id),
|
||||
name=data["name"],
|
||||
avatar_url=data["avatar_url"],
|
||||
mask=data["mask"],
|
||||
is_masked=data["is_masked"],
|
||||
channel_id=data["channel_id"],
|
||||
call_id=self.id,
|
||||
)
|
||||
)
|
||||
|
||||
return participants
|
||||
|
||||
|
||||
class CallHandler:
|
||||
"""Handles calls in Payphone."""
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
"""Initializes the CallHandler.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot instance.
|
||||
"""
|
||||
|
||||
self.bot = bot
|
||||
|
||||
async def set_searching(self, channel_id: int, status: bool) -> None:
|
||||
"""Sets the channel as searching for a call.
|
||||
|
||||
Args:
|
||||
channel_id (int): The ID of the channel to set as searching.
|
||||
status (bool): The status to set the channel to.
|
||||
"""
|
||||
|
||||
if status:
|
||||
await self.bot.redis.sadd("calls:channels:searching", channel_id)
|
||||
|
||||
else:
|
||||
await self.bot.redis.srem("calls:channels:searching", channel_id)
|
||||
|
||||
async def connect(self) -> list[tuple[int, int]]:
|
||||
"""Connects two channels together.
|
||||
|
||||
Returns:
|
||||
list[tuple[int, int]]: A list of channel pairs.
|
||||
"""
|
||||
|
||||
pairs = []
|
||||
|
||||
while True:
|
||||
channels = await self.bot.redis.spop("calls:channels:searching", 2)
|
||||
|
||||
if len(channels) < 2:
|
||||
if len(channels) == 1:
|
||||
await self.set_searching(channels[0], True)
|
||||
|
||||
break
|
||||
|
||||
last_calls = await self.bot.redis.hmget("last-calls", *channels)
|
||||
|
||||
break_outer = False
|
||||
|
||||
for channel in channels:
|
||||
if channel in last_calls:
|
||||
next_channel = await self.bot.redis.spop("calls:channels:searching")
|
||||
|
||||
if next_channel is not None:
|
||||
other_channel_id = channels[abs(channels.index(channel) - 1)]
|
||||
await self.set_searching(other_channel_id, True)
|
||||
|
||||
channels[channels.index(channel)] = next_channel
|
||||
|
||||
else:
|
||||
for channel in channels:
|
||||
await self.set_searching(channel, True)
|
||||
|
||||
break_outer = True
|
||||
break
|
||||
|
||||
if break_outer:
|
||||
break
|
||||
|
||||
pairs.append(tuple(channels))
|
||||
|
||||
mapping = {}
|
||||
reverse_mapping = {}
|
||||
|
||||
for pair in pairs:
|
||||
last_call_id = await self.bot.redis.incr("calls:calls:count")
|
||||
|
||||
mapping[pair[0]] = last_call_id
|
||||
mapping[pair[1]] = last_call_id
|
||||
|
||||
reverse_mapping[last_call_id] = f"{pair[0]}:{pair[1]}"
|
||||
|
||||
pipe = self.bot.redis.pipeline(transaction=True)
|
||||
|
||||
if mapping:
|
||||
pipe.hset("calls:channels:active", mapping=mapping)
|
||||
pipe.hset("calls:calls:active", mapping=reverse_mapping)
|
||||
|
||||
for pair in pairs:
|
||||
pipe.hmset("last-calls", {pair[0]: pair[1], pair[1]: pair[0]})
|
||||
pipe.hset("calls:last-message", mapping[pair[0]], "")
|
||||
pipe.hexpire("calls:last-message", 60, mapping[pair[0]])
|
||||
|
||||
await pipe.execute()
|
||||
|
||||
return pairs
|
||||
|
||||
async def expire(self) -> list[tuple[int, int]]:
|
||||
"""
|
||||
Ends all calls that have been inactive for more than 15 seconds.
|
||||
"""
|
||||
|
||||
active_calls = await self.bot.redis.hgetall("calls:calls:active")
|
||||
|
||||
pairs = []
|
||||
|
||||
pipe = self.bot.redis.pipeline(transaction=True)
|
||||
|
||||
for call_id, channels in active_calls.items():
|
||||
channels = [int(c) for c in channels.split(":")]
|
||||
|
||||
last_message = await self.bot.redis.hget("calls:last-message", call_id)
|
||||
|
||||
if last_message is not None:
|
||||
continue
|
||||
|
||||
pipe.hdel("calls:last-message", call_id)
|
||||
pipe.hdel("calls:channels:active", *channels)
|
||||
pipe.hdel("calls:calls:active", call_id)
|
||||
pipe.srem("calls:channels:searching", *channels)
|
||||
|
||||
pairs.append(tuple(channels))
|
||||
|
||||
await pipe.execute()
|
||||
|
||||
return pairs
|
||||
|
||||
async def is_searching(self, channel_id: int) -> bool:
|
||||
"""Checks if a channel is searching for a call.
|
||||
|
||||
Args:
|
||||
channel_id (int): The ID of the channel to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the channel is searching for a call, False otherwise.
|
||||
"""
|
||||
|
||||
return bool(
|
||||
await self.bot.redis.sismember("calls:channels:searching", channel_id)
|
||||
)
|
||||
|
||||
async def is_on_call(self, channel_id: int) -> bool:
|
||||
"""Checks if a channel is on a call.
|
||||
|
||||
Args:
|
||||
channel_id (int): The ID of the channel to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the channel is on a call, False otherwise.
|
||||
"""
|
||||
|
||||
return await self.bot.redis.hexists("calls:channels:active", channel_id)
|
||||
|
||||
async def get_call_by_channel_id(self, channel_id: int) -> Call | None:
|
||||
"""Gets the call that a channel is in.
|
||||
|
||||
Args:
|
||||
channel_id (int): The ID of the channel to get the call for.
|
||||
|
||||
Returns:
|
||||
Call | None: The pair of channels that are connected.
|
||||
"""
|
||||
|
||||
call_id = await self.bot.redis.hget("calls:channels:active", channel_id)
|
||||
|
||||
if call_id is None:
|
||||
return
|
||||
|
||||
return await self.get_call(int(call_id))
|
||||
|
||||
async def get_call(self, call_id: int) -> Call | None:
|
||||
"""Gets a call by its ID.
|
||||
|
||||
Args:
|
||||
call_id (int): The ID of the call to get.
|
||||
|
||||
Returns:
|
||||
Call | None: The call that was found.
|
||||
"""
|
||||
|
||||
used_masks = await self.bot.redis.smembers(f"calls:{call_id}:masks")
|
||||
|
||||
channels = [
|
||||
int(c)
|
||||
for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split(
|
||||
":"
|
||||
)
|
||||
]
|
||||
|
||||
return Call(_bot=self.bot, id=call_id, channels=channels, masks=used_masks)
|
||||
|
||||
async def hangup(self, channel_id: int) -> tuple[int, int] | tuple[int]:
|
||||
"""Ends a call in a channel.
|
||||
|
||||
Args:
|
||||
channel_id (int): The ID of the channel to end the call in.
|
||||
|
||||
Returns:
|
||||
tuple[int, int] | tuple[int]: The pair of channels that were connected.
|
||||
"""
|
||||
|
||||
await self.set_searching(channel_id, False)
|
||||
|
||||
call_id = await self.bot.redis.hget("calls:channels:active", channel_id)
|
||||
|
||||
if call_id is None:
|
||||
# Channel is searching.
|
||||
return (channel_id,)
|
||||
|
||||
channels = [
|
||||
int(c)
|
||||
for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split(
|
||||
":"
|
||||
)
|
||||
]
|
||||
|
||||
pipe = self.bot.redis.pipeline(transaction=True)
|
||||
|
||||
pipe.srem("calls:channels:searching", *channels)
|
||||
pipe.hdel("calls:channels:active", *channels)
|
||||
pipe.hdel("calls:calls:active", call_id)
|
||||
|
||||
await pipe.execute()
|
||||
|
||||
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.user == self.bot.user 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 refresh_channel_webhook_url(self, channel_id: int) -> str:
|
||||
"""Refreshes a channel's webhook.
|
||||
|
||||
Args:
|
||||
channel_id (int): The channel ID of the webhook to refresh.
|
||||
|
||||
Returns:
|
||||
str: The new webhook's URL.
|
||||
"""
|
||||
|
||||
await self.bot.redis.hdel("calls:channels:webhooks", channel_id)
|
||||
|
||||
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":
|
||||
await webhook.delete()
|
||||
|
||||
return await self.get_channel_webhook_url(channel_id)
|
||||
|
||||
async def get_message(self, message_id: int) -> Message | None:
|
||||
"""Gets a message sent in a call.
|
||||
|
||||
Args:
|
||||
message_id (int): The ID of the message to get.
|
||||
|
||||
Returns:
|
||||
Message | None: The message that was sent.
|
||||
"""
|
||||
|
||||
call_id = await self.bot.redis.hget("calls:messages", message_id)
|
||||
|
||||
if call_id is None:
|
||||
return
|
||||
|
||||
call = await self.get_call(int(call_id))
|
||||
|
||||
return await call.get_message(message_id)
|
||||
|
||||
async def get_message_author_id(self, id: int) -> int | None:
|
||||
"""Gets the author of a message as part of a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to get.
|
||||
|
||||
Returns:
|
||||
int: The author ID of the message.
|
||||
"""
|
||||
|
||||
pipe = self.bot.redis.pipeline()
|
||||
|
||||
pipe.hget("calls:messages", id)
|
||||
pipe.hget("calls:messages:reversed", id)
|
||||
|
||||
call_id, message_id = await pipe.execute()
|
||||
|
||||
if call_id is None and message_id is None:
|
||||
return
|
||||
|
||||
message_id = int(message_id) if message_id else id
|
||||
|
||||
author_id = await self.bot.redis.hget("calls:messages:authors", message_id)
|
||||
|
||||
return int(author_id) if author_id else None
|
||||
|
||||
async def get_original_message_id(self, id: int) -> int | None:
|
||||
"""
|
||||
Get the original message ID of a message in a call.
|
||||
|
||||
Args:
|
||||
id (int): The ID of the message to get.
|
||||
|
||||
Returns:
|
||||
int: The original message ID of the message.
|
||||
"""
|
||||
|
||||
message_id = await self.bot.redis.hget("calls:messages:reversed", id)
|
||||
|
||||
if message_id:
|
||||
return int(message_id)
|
||||
|
||||
return None
|
||||
@@ -1,49 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
words = []
|
||||
|
||||
with open("censored.txt", encoding="UTF-8") as f:
|
||||
for line in f:
|
||||
words.append(line.strip())
|
||||
|
||||
|
||||
WORDS = re.compile(r"\w+")
|
||||
PATTERN = re.compile(
|
||||
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)
|
||||
|
||||
|
||||
def censor(message: str, is_gif_allowed: bool = False) -> str:
|
||||
"""Returns the first censored word or pattern found.
|
||||
|
||||
Args:
|
||||
message (str): The message to moderate.
|
||||
is_gif_allowed (bool, optional): If GIFs are allowed. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str | None: The blocked pattern found as it was found.
|
||||
"""
|
||||
|
||||
for word in WORDS.finditer(message):
|
||||
word = message[word.start() : word.end()]
|
||||
if PATTERN.match(word):
|
||||
return word
|
||||
|
||||
for match in URL_PATTERN.finditer(message):
|
||||
string = message[match.start() : match.end()]
|
||||
|
||||
if is_gif_allowed and string.startswith("https://tenor.com/view/"):
|
||||
continue
|
||||
|
||||
return string
|
||||
|
||||
match = next(INVITES_PATTERN.finditer(message), None)
|
||||
|
||||
if match is not None:
|
||||
return message[match.start() : match.end()]
|
||||
|
||||
return None
|
||||
@@ -1,65 +0,0 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.core import exceptions
|
||||
|
||||
|
||||
# List of check IDs to skip.
|
||||
skip_checks_ids = [1357199907512914051]
|
||||
|
||||
|
||||
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
|
||||
|
||||
if await ctx.bot.is_moderator(ctx.author.id):
|
||||
return True
|
||||
|
||||
raise exceptions.NotAModeratorError()
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
def is_not_banned():
|
||||
"""Checks that the user is not banned."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
if ctx.author.id in skip_checks_ids:
|
||||
return True
|
||||
|
||||
if ctx.author.created_at > datetime.now(timezone.utc) - timedelta(
|
||||
days=exceptions.AccountTooYoungError.days
|
||||
):
|
||||
raise exceptions.AccountTooYoungError()
|
||||
|
||||
if await ctx.bot.is_banned(ctx.author.id):
|
||||
raise exceptions.BannedError()
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
def is_on_call():
|
||||
"""Checks that the user is on a call."""
|
||||
|
||||
async def predicate(ctx: commands.Context) -> bool:
|
||||
call = await ctx.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
if call is None:
|
||||
raise exceptions.NotOnCallError()
|
||||
|
||||
return True
|
||||
|
||||
return commands.check(predicate)
|
||||
|
||||
|
||||
def is_in_server(server_id: int):
|
||||
async def predicate(ctx):
|
||||
return ctx.guild and ctx.guild.id == server_id
|
||||
|
||||
return commands.check(predicate)
|
||||
@@ -1,49 +0,0 @@
|
||||
import os
|
||||
|
||||
import toml
|
||||
|
||||
try:
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
except ImportError:
|
||||
print("dotenv is not installed. No .env file was loaded.")
|
||||
|
||||
IS_DEBUG = os.getenv("IS_DEBUG", "false").lower() == "true"
|
||||
|
||||
TOKEN = os.getenv("TOKEN")
|
||||
|
||||
if TOKEN is None:
|
||||
raise RuntimeError(
|
||||
"No token provided. Make sure to set the TOKEN environment variable."
|
||||
)
|
||||
|
||||
SHARD_ID = int(os.getenv("SHARD_ID", "0"))
|
||||
SHARD_COUNT = int(os.getenv("SHARD_COUNT", "1"))
|
||||
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
INVISIBLE_CHARACTER = "\u200b"
|
||||
BRAILE_CHARACTER = "\u2800"
|
||||
|
||||
PRIMARY_COLOR = 0xE2D9D0
|
||||
SUPPORTER_COLOR = 0xAB9EFF
|
||||
MODERATOR_COLOR = 0xFF8787
|
||||
MASK_COLOR = 0xFFC868
|
||||
APPROVE_COLOR = 0x0FFF95
|
||||
|
||||
DISCORD_SERVER_INVITE = "https://discord.gg/9daSsrfTmE"
|
||||
|
||||
SENTRY_DSN = os.getenv(
|
||||
"SENTRY_DSN", "https://[email protected]/69"
|
||||
)
|
||||
|
||||
VERSION = None
|
||||
|
||||
PAYPHONE_HUB_ID = 1360766092900962414
|
||||
|
||||
with open("pyproject.toml", "r", encoding="UTF-8") as f:
|
||||
data = toml.load(f)
|
||||
VERSION = "v" + data["project"]["version"].split(".")[1]
|
||||
@@ -1,23 +0,0 @@
|
||||
from discord.ext.commands import errors
|
||||
|
||||
|
||||
class NotAModeratorError(errors.CheckFailure):
|
||||
"""Raised when the user is not a moderator."""
|
||||
|
||||
|
||||
class BannedError(errors.CheckFailure):
|
||||
"""Raised when the user is banned."""
|
||||
|
||||
|
||||
class ChannelDisabledError(errors.CheckFailure):
|
||||
"""Raised when the channel is disabled."""
|
||||
|
||||
|
||||
class NotOnCallError(errors.CheckFailure):
|
||||
"""Raised when the user is not on a call."""
|
||||
|
||||
|
||||
class AccountTooYoungError(errors.CheckFailure):
|
||||
"""Raised when the user's account is too young."""
|
||||
|
||||
days: int = 30
|
||||
@@ -1,221 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import re
|
||||
|
||||
|
||||
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 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 matches.span() == (0, 0):
|
||||
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,154 +0,0 @@
|
||||
from typing import List, Dict
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.cogs import base
|
||||
from payphone.core import formatting, constants, 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=constants.DISCORD_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
|
||||
|
||||
for command in cog_commands:
|
||||
if isinstance(command, commands.Command):
|
||||
if command.extras.get("show_in_help", True):
|
||||
bot_commands += [command]
|
||||
|
||||
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.PRIMARY_COLOR,
|
||||
footer=discord.EmbedFooter(
|
||||
text=f"{constants.VERSION} · 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:
|
||||
error_message = command.extras.get("help_error_message")
|
||||
|
||||
if error_message:
|
||||
return await self.context.send(f"❌ {error_message}")
|
||||
|
||||
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 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.PRIMARY_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}")
|
||||
@@ -1,45 +0,0 @@
|
||||
import logging
|
||||
|
||||
from rich import logging as rlogging
|
||||
|
||||
|
||||
logger = logging.getLogger("payphone")
|
||||
|
||||
|
||||
def _get_handler(process_name: str, module: str | None = None) -> None:
|
||||
"""Generates a handler for the given process name and module.
|
||||
|
||||
Args:
|
||||
process_name (str): The process name.
|
||||
module (str | None, optional): The module inside the process. Defaults to None.
|
||||
"""
|
||||
|
||||
fmt = f"[blue][{process_name}{f": [green]{module}[/green]" if module is not None else ""}][/blue] %(message)s"
|
||||
formatter = logging.Formatter(fmt)
|
||||
|
||||
handler = rlogging.RichHandler(
|
||||
level=logging.INFO, markup=True, rich_tracebacks=True
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def setup(process_name: str):
|
||||
"""Sets up pycord logging.
|
||||
|
||||
Args:
|
||||
process_name (str): The name of the process.
|
||||
"""
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG, handlers=[_get_handler(process_name)])
|
||||
|
||||
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
|
||||
@@ -1,118 +0,0 @@
|
||||
import random
|
||||
|
||||
from payphone.core import constants
|
||||
|
||||
|
||||
MASKS = {
|
||||
"ferret": 1278176917123760139 if constants.IS_DEBUG else 1278173356423909448,
|
||||
"octopus": 1278176904410697880 if constants.IS_DEBUG else 1278173331421397072,
|
||||
"cat": 1278176891290783824 if constants.IS_DEBUG else 1278173317034934282,
|
||||
"monkey": 1278176881195094049 if constants.IS_DEBUG else 1278173297456185344,
|
||||
"panda": 1278176868767629455 if constants.IS_DEBUG else 1278173272390762538,
|
||||
"sheep": 1278176856910073946 if constants.IS_DEBUG else 1278173251914432563,
|
||||
"horse": 1278176842116894760 if constants.IS_DEBUG else 1278173237582237708,
|
||||
"bee": 1278176823846506577 if constants.IS_DEBUG else 1278173216652787826,
|
||||
"wild boar": 1278176806498996235 if constants.IS_DEBUG else 1278173196658413649,
|
||||
"duck": 1278176794591232102 if constants.IS_DEBUG else 1278173181483679918,
|
||||
"chicken": 1278176781790216233 if constants.IS_DEBUG else 1278173167218589726,
|
||||
"elephant": 1278176771413512213 if constants.IS_DEBUG else 1278173150063886346,
|
||||
"cow": 1278176761565413483 if constants.IS_DEBUG else 1278173125024153733,
|
||||
"deer": 1278176744691597312 if constants.IS_DEBUG else 1278173110105014406,
|
||||
"turkey": 1278176730619576321 if constants.IS_DEBUG else 1278173091796877363,
|
||||
"dog": 1278176719932751903 if constants.IS_DEBUG else 1278173070846201856,
|
||||
"beaver": 1278176709530882091 if constants.IS_DEBUG else 1278173051485425705,
|
||||
"camel": 1278176699921465394 if constants.IS_DEBUG else 1278173035018584086,
|
||||
"koala": 1278176687493746709 if constants.IS_DEBUG else 1278173021345021952,
|
||||
"hippo": 1278176675846295603 if constants.IS_DEBUG else 1278173007419932804,
|
||||
"ostrich": 1278176661317095494 if constants.IS_DEBUG else 1278172994669248523,
|
||||
"rhino": 1278176650340864000 if constants.IS_DEBUG else 1278172980848885832,
|
||||
"squirrel": 1278176637627924570 if constants.IS_DEBUG else 1278172965590274088,
|
||||
"chamaleon": 1278176625392877648 if constants.IS_DEBUG else 1278172953305022496,
|
||||
"frog": 1278176614512857088 if constants.IS_DEBUG else 1278172937043710094,
|
||||
"skunk": 1278176604509442079 if constants.IS_DEBUG else 1278172923495976981,
|
||||
"walrus": 1278176554723311678 if constants.IS_DEBUG else 1278172912368619603,
|
||||
"hedgehog": 1278176541750067252 if constants.IS_DEBUG else 1278172899911405678,
|
||||
"pig": 1278176531415564332 if constants.IS_DEBUG else 1278172885239861338,
|
||||
"hen": 1278176518643646485 if constants.IS_DEBUG else 1278172871302184994,
|
||||
"turtle": 1278176507428343891 if constants.IS_DEBUG else 1278172858199314455,
|
||||
"mouse": 1278175957047578696 if constants.IS_DEBUG else 1278172846442414081,
|
||||
"rabbit": 1278175944007487489 if constants.IS_DEBUG else 1278172831921999945,
|
||||
"goat": 1278175933915856938 if constants.IS_DEBUG else 1278172815421472808,
|
||||
"lion": 1278175922964664382 if constants.IS_DEBUG else 1278172803534684235,
|
||||
"wolf": 1278175902047535165 if constants.IS_DEBUG else 1278172791052570687,
|
||||
"snake": 1278175869331968061 if constants.IS_DEBUG else 1278172776259260547,
|
||||
"llama": 1278175850008674325 if constants.IS_DEBUG else 1278172764913799168,
|
||||
"crab": 1278175835634925620 if constants.IS_DEBUG else 1278172752322232330,
|
||||
"owl": 1278175822947029152 if constants.IS_DEBUG else 1278172738309193749,
|
||||
"bat": 1278175813572886550 if constants.IS_DEBUG else 1278172726426865724,
|
||||
"zebra": 1278175799962505216 if constants.IS_DEBUG else 1278172715114565753,
|
||||
"fox": 1278175788818104474 if constants.IS_DEBUG else 1278172702955274292,
|
||||
"gorilla": 1278175775891259456 if constants.IS_DEBUG else 1278172687797194824,
|
||||
"sloth": 1278175765661356175 if constants.IS_DEBUG else 1278172676837343242,
|
||||
"puffer fish": 1278175753497743360 if constants.IS_DEBUG else 1278172665093558294,
|
||||
"bear": 1278175741569142867 if constants.IS_DEBUG else 1278172653076746301,
|
||||
"giraffe": 1278175732601983006 if constants.IS_DEBUG else 1278172640296702073,
|
||||
"penguin": 1278175721956573236 if constants.IS_DEBUG else 1278172627629899808,
|
||||
"parrot": 1278175706798489642 if constants.IS_DEBUG else 1278172600702337074,
|
||||
}
|
||||
|
||||
CAT_MASKS = {
|
||||
"dinosaur": 1278255435652010014 if constants.IS_DEBUG else 1278254725833297965,
|
||||
"shleepy cat": 1278255475066146816 if constants.IS_DEBUG else 1278254779767984181,
|
||||
"cozy cat": 1278255420124696663 if constants.IS_DEBUG else 1278254704324771893,
|
||||
"coquette cat": 1278255408254816300 if constants.IS_DEBUG else 1278254684200636428,
|
||||
"panic cat": 1278255451993145345 if constants.IS_DEBUG else 1278254760700674098,
|
||||
}
|
||||
|
||||
|
||||
def get_random_mask(not_int: list[str] = [], masks: dict[str, int] = MASKS) -> str:
|
||||
"""Returns a random mask.
|
||||
|
||||
Args:
|
||||
not_int (list[str], optional): Masks to exclude. Defaults to [].
|
||||
masks (dict[str, int], optional): The masks. Defaults to MASKS.
|
||||
|
||||
Returns:
|
||||
str: The mask.
|
||||
"""
|
||||
|
||||
return random.choice([mask for mask in masks.keys() if mask not in not_int])
|
||||
|
||||
|
||||
def get_mask_name(mask: str) -> str:
|
||||
"""Returns the name of the mask.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The name of the mask.
|
||||
"""
|
||||
|
||||
return mask.title()
|
||||
|
||||
|
||||
def get_mask_image_url(mask: str) -> str:
|
||||
"""Returns the URL of the mask image.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The URL of the mask image.
|
||||
"""
|
||||
|
||||
return f"https://cdn.discordapp.com/emojis/{MASKS[mask] if mask in MASKS else CAT_MASKS[mask]}.png"
|
||||
|
||||
|
||||
def get_mask_emoji(mask: str) -> str:
|
||||
"""Returns the emoji of the mask.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The emoji of the mask.
|
||||
"""
|
||||
|
||||
return f"<:{mask.replace(" ", "")}:{MASKS[mask] if mask in MASKS else CAT_MASKS[mask]}>"
|
||||
@@ -1,96 +0,0 @@
|
||||
from payphone.core import constants
|
||||
|
||||
"""
|
||||
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!",
|
||||
f"Did you join [Payphone's new official server](<{constants.DISCORD_SERVER_INVITE}>) yet? We have a lot of fun stuff there!",
|
||||
# "How high are you on the leaderboard? Use `{prefix}me`, `{prefix}leaderboard`, and `{prefix}leaderboard servers` to check it out!",
|
||||
]
|
||||
"""
|
||||
|
||||
TIPS = [
|
||||
"Thank you anonymous person and Stasi, we got our monthly costs covered!"
|
||||
]
|
||||
|
||||
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,55 +0,0 @@
|
||||
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,25 +0,0 @@
|
||||
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 "ban" ALTER COLUMN "report_id" TYPE BIGINT USING "report_id"::BIGINT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE BIGINT USING "report_id"::BIGINT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE BIGINT USING "report_id"::BIGINT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE BIGINT USING "report_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");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE INT USING "report_id"::INT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE INT USING "report_id"::INT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE INT USING "report_id"::INT;
|
||||
ALTER TABLE "ban" ALTER COLUMN "report_id" TYPE INT USING "report_id"::INT;
|
||||
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,15 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" ADD "reply_user_name" TEXT;
|
||||
ALTER TABLE "report" ADD "reply_message_content" TEXT;
|
||||
ALTER TABLE "report" ADD "reply_user_avatar_url" TEXT;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" DROP COLUMN "reply_user_name";
|
||||
ALTER TABLE "report" DROP COLUMN "reply_message_content";
|
||||
ALTER TABLE "report" DROP COLUMN "reply_user_avatar_url";"""
|
||||
@@ -1,11 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" ADD "reply_user_id" BIGINT;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" DROP COLUMN "reply_user_id";"""
|
||||
@@ -1,11 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "ban" ADD "reason" TEXT;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "ban" DROP COLUMN "reason";"""
|
||||
@@ -1,13 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" ADD "reported_by_id" BIGINT;
|
||||
ALTER TABLE "report" ADD CONSTRAINT "fk_report_user_3a7d2f49" FOREIGN KEY ("reported_by_id") REFERENCES "user" ("id") ON DELETE CASCADE;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "report" DROP CONSTRAINT IF EXISTS "fk_report_user_3a7d2f49";
|
||||
ALTER TABLE "report" DROP COLUMN "reported_by_id";"""
|
||||
@@ -1,19 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "warn" (
|
||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||
"user_id" BIGINT NOT NULL,
|
||||
"reason" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
"report_id" BIGINT REFERENCES "report" ("id") ON DELETE CASCADE
|
||||
);
|
||||
COMMENT ON TABLE "warn" IS 'A warn.';"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP TABLE IF EXISTS "warn";"""
|
||||
@@ -1,17 +0,0 @@
|
||||
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 """
|
||||
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 """
|
||||
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,19 +0,0 @@
|
||||
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,21 +0,0 @@
|
||||
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,21 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "report" ADD "user_avatar_url" TEXT NOT NULL;
|
||||
ALTER TABLE "report" ADD "user_name" TEXT NOT NULL;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "report" DROP COLUMN "user_avatar_url";
|
||||
ALTER TABLE "report" DROP COLUMN "user_name";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -1,21 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "user" ADD "warns_count" INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "user" ADD "bans_count" INT NOT NULL DEFAULT 0;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "user" DROP COLUMN "warns_count";
|
||||
ALTER TABLE "user" DROP COLUMN "bans_count";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -1,24 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
CREATE TABLE IF NOT EXISTS "server" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"language" TEXT NOT NULL
|
||||
);
|
||||
COMMENT ON TABLE "server" IS 'A server.';;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
DROP TABLE IF EXISTS "server";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -1,19 +0,0 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "server" ADD "name" TEXT NOT NULL;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "server" DROP COLUMN "name";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -1,235 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import enum
|
||||
|
||||
from tortoise import models, fields
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
import orjson
|
||||
|
||||
|
||||
class Report(models.Model):
|
||||
"""
|
||||
A report of a message.
|
||||
"""
|
||||
|
||||
id = fields.BigIntField(pk=True)
|
||||
|
||||
message_id = fields.BigIntField(unique=True)
|
||||
message_content = fields.TextField()
|
||||
channel_id = fields.BigIntField()
|
||||
user_id = fields.BigIntField()
|
||||
user_name = fields.TextField()
|
||||
user_avatar_url = fields.TextField()
|
||||
reply_message_content = fields.TextField(null=True, default=None)
|
||||
reply_user_id = fields.BigIntField(null=True)
|
||||
reply_user_name = fields.TextField(null=True, default=None)
|
||||
reply_user_avatar_url = fields.TextField(null=True, default=None)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
resolved = fields.BooleanField(default=False, index=True)
|
||||
reported_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="reports", null=True
|
||||
)
|
||||
resolved_at = fields.DatetimeField(null=True)
|
||||
resolved_by = fields.ForeignKeyField(
|
||||
"models.User", related_name="resolved_reports", null=True
|
||||
)
|
||||
|
||||
|
||||
class UserFlag(enum.IntFlag):
|
||||
"""
|
||||
Flags for a user.
|
||||
"""
|
||||
|
||||
NONE = 0
|
||||
MODERATOR = 1
|
||||
SUPPORTER = 2
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
"""
|
||||
A user.
|
||||
"""
|
||||
|
||||
id = fields.BigIntField(pk=True)
|
||||
|
||||
warns_count: int = fields.IntField(default=0)
|
||||
bans_count: int = fields.IntField(default=0)
|
||||
|
||||
is_masked = fields.BooleanField(default=False)
|
||||
is_moderator = fields.BooleanField(default=False)
|
||||
is_supporter_until = fields.DatetimeField(null=True)
|
||||
|
||||
@property
|
||||
def flag(self) -> UserFlag:
|
||||
"""
|
||||
The flags of the user.
|
||||
"""
|
||||
|
||||
flags = UserFlag.NONE
|
||||
|
||||
if self.is_moderator:
|
||||
flags |= UserFlag.MODERATOR
|
||||
|
||||
if (
|
||||
self.is_supporter_until is not None
|
||||
and self.is_supporter_until > datetime.now(timezone.utc)
|
||||
):
|
||||
flags |= UserFlag.SUPPORTER
|
||||
|
||||
return flags
|
||||
|
||||
@classmethod
|
||||
async def get_cache_or_db(cls, redis_client: redis.Redis, user_id: int) -> "User":
|
||||
"""
|
||||
Returns the user from the cache or the database.
|
||||
|
||||
Args:
|
||||
redis_client (redis.Redis): The Redis client.
|
||||
user_id (int): The user ID.
|
||||
|
||||
Returns:
|
||||
(User): The user.
|
||||
"""
|
||||
|
||||
cache = await redis_client.get(f"user:{user_id}")
|
||||
|
||||
instance = User(id=user_id)
|
||||
|
||||
if cache is not None:
|
||||
cache = orjson.loads(cache)
|
||||
|
||||
instance.warns_count = cache["warns_count"]
|
||||
instance.bans_count = cache["bans_count"]
|
||||
instance.is_masked = cache["is_masked"]
|
||||
instance.is_moderator = cache["is_moderator"]
|
||||
instance.is_supporter_until = (
|
||||
datetime.fromtimestamp(cache["is_supporter_until"], timezone.utc)
|
||||
if cache["is_supporter_until"] is not None
|
||||
else None
|
||||
)
|
||||
|
||||
else:
|
||||
instance, _ = await User.update_or_create(id=user_id)
|
||||
|
||||
await instance.set_cache(redis_client)
|
||||
|
||||
return instance
|
||||
|
||||
async def set_cache(self, redis_client: redis.Redis) -> None:
|
||||
"""
|
||||
Sets the user in the cache.
|
||||
|
||||
Args:
|
||||
redis_client (redis.Redis): The Redis client.
|
||||
"""
|
||||
|
||||
await redis_client.set(
|
||||
f"user:{self.id}",
|
||||
orjson.dumps(
|
||||
{
|
||||
"warns_count": self.warns_count,
|
||||
"bans_count": self.bans_count,
|
||||
"is_masked": self.is_masked,
|
||||
"is_moderator": self.is_moderator,
|
||||
"is_supporter_until": (
|
||||
self.is_supporter_until.timestamp()
|
||||
if self.is_supporter_until
|
||||
else None
|
||||
),
|
||||
}
|
||||
),
|
||||
ex=60 * 15,
|
||||
)
|
||||
|
||||
|
||||
class Warn(models.Model):
|
||||
"""A warn."""
|
||||
|
||||
user_id = fields.BigIntField()
|
||||
report = fields.ForeignKeyField("models.Report", null=True)
|
||||
reason = fields.TextField(null=True, default=None)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
created_by = fields.ForeignKeyField("models.User", related_name="created_warns")
|
||||
|
||||
|
||||
class Ban(models.Model):
|
||||
"""
|
||||
A ban.
|
||||
"""
|
||||
|
||||
user_id = fields.BigIntField()
|
||||
report = fields.ForeignKeyField("models.Report", null=True)
|
||||
reason = fields.TextField(null=True, default=None)
|
||||
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="expired_bans", null=True
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
Whether the ban is expired.
|
||||
"""
|
||||
|
||||
return self.expires_at is not None and self.expires_at < datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Meta information.
|
||||
"""
|
||||
|
||||
indexes = [
|
||||
models.Index(fields=["user_id", "expires_at"]),
|
||||
]
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
"""
|
||||
A channel.
|
||||
"""
|
||||
|
||||
id = fields.BigIntField(pk=True)
|
||||
server_id = fields.BigIntField(index=True)
|
||||
|
||||
is_enabled = fields.BooleanField(null=True)
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
Meta information.
|
||||
"""
|
||||
|
||||
indexes = [
|
||||
models.Index(fields=["server_id", "is_enabled"]),
|
||||
]
|
||||
|
||||
|
||||
class Server(models.Model):
|
||||
"""
|
||||
A server.
|
||||
"""
|
||||
|
||||
id = fields.BigIntField(pk=True)
|
||||
name = fields.TextField()
|
||||
prefix = fields.TextField(default="p.")
|
||||
language = fields.TextField(default="en")
|
||||
|
||||
async def set_prefix(self, redis_client: redis.Redis, prefix: str) -> None:
|
||||
"""
|
||||
Sets the prefix of the server.
|
||||
|
||||
Args:
|
||||
redis_client (redis.Redis): The Redis client.
|
||||
prefix (str): The prefix.
|
||||
"""
|
||||
|
||||
self.prefix = prefix
|
||||
|
||||
await self.save()
|
||||
|
||||
await redis_client.set(f"prefix:{self.id}", prefix, ex=60 * 60 * 24)
|
||||
@@ -1,33 +0,0 @@
|
||||
import tortoise
|
||||
|
||||
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, use_tz=True)
|
||||
@@ -1 +0,0 @@
|
||||
from payphone.tasks import connect, expire
|
||||
@@ -1,32 +0,0 @@
|
||||
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)
|
||||
@@ -1,30 +0,0 @@
|
||||
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,15 +0,0 @@
|
||||
[project]
|
||||
name = "payphone"
|
||||
version = "0.115.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [ "aerich>=0.8.2", "aiohttp>=3.10.10", "orjson>=3.10.11", "py-cord>=2.6.1", "redis[hiredis]>=5.2.0", "rich>=13.9.4", "sentry-sdk>=2.22.0", "toml>=0.10.2", "tortoise-orm[accel,asyncpg]>=0.21.7", "typer>=0.13.0",]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [ "black>=25.1.0", "python-dotenv>=1.0.1", "pyyaml>=6.0.2",]
|
||||
|
||||
[tool.aerich]
|
||||
tortoise_orm = "payphone.db.setup.CONFIG"
|
||||
location = "./payphone/db/migrations"
|
||||
src_folder = "."
|
||||
@@ -1,56 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import toml
|
||||
|
||||
except ImportError:
|
||||
print(
|
||||
"You need to install `toml` to use this file. Run `pip install toml` and try again."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
except ImportError:
|
||||
print(
|
||||
"You need to install `yaml` to use this file. Run `pip install pyyaml` and try again."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
os.system("git pull")
|
||||
|
||||
data = toml.load(open("pyproject.toml", encoding="utf-8"))
|
||||
version = data["project"]["version"].split(".")
|
||||
latest_tag = "v" + version[1]
|
||||
|
||||
os.system(
|
||||
f"microk8s kubectl set image sts/bot bot=registry.nyeki.dev/payphone/payphone/shard:{latest_tag}"
|
||||
)
|
||||
os.system(
|
||||
f"""
|
||||
microk8s kubectl set image pod/tasks \
|
||||
connect=registry.nyeki.dev/payphone/payphone/tasks:{latest_tag}
|
||||
"""
|
||||
)
|
||||
|
||||
print("Waiting 30 seconds to run migrations...")
|
||||
|
||||
time.sleep(30)
|
||||
|
||||
with open("./k8s/bot.yml", encoding="utf-8") as stream:
|
||||
try:
|
||||
docs = list(yaml.load_all(stream, yaml.Loader))
|
||||
data = docs[0]
|
||||
|
||||
os.system(f"microk8s kubectl exec bot-{data["spec"]["replicas"] - 1} -- uv run aerich upgrade")
|
||||
except yaml.YAMLError as exc:
|
||||
print("Could not read the amount of deployed shards.")
|
||||
print(exc)
|
||||
|
||||
|
||||
print("Done!")
|
||||
|
||||
# expire=registry.nyeki.dev/payphone/payphone/shard:v{latest_version}
|
||||
@@ -1,39 +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()
|
||||
|
||||
os.system("git pull")
|
||||
|
||||
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_version = f"registry.nyeki.dev/payphone/payphone/{t}:v{version_num}"
|
||||
|
||||
os.system(f"docker build --target {t} --tag {tag_version} .")
|
||||
os.system(f"docker push {tag_version}")
|
||||
|
||||
tag_latest = f"registry.nyeki.dev/payphone/payphone/{t}:latest"
|
||||
|
||||
os.system(f"docker tag {tag_version} {tag_latest}")
|
||||
os.system(f"docker push {tag_latest}")
|
||||
|
||||
toml.dump(data, open("pyproject.toml", "w"))
|
||||
|
||||
os.system("git add pyproject.toml uv.lock")
|
||||
os.system(f"git commit -m 'chore: 🔖 Bump to version v{version_num}.'")
|
||||
os.system("git push")
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
//! Payphone's main entry point.
|
||||
//!
|
||||
//! This module is responsible for parsing command line arguments, nothing else.
|
||||
//!
|
||||
//! # Command Line Arguments
|
||||
//!
|
||||
//! The command line arguments are parsed using the [`clap`] crate, and can be found in the
|
||||
//! [`Args`] struct. The arguments are:
|
||||
//!
|
||||
//! - `[TOKEN]`: The Discord bot token to run the bot with. This is required.
|
||||
//! - `--logging` or `-l`: The minimum level logs have to be to be outputted into the console, or
|
||||
//! off for no logs. Defaults to `info`.
|
||||
//! - `--database-addr` or `-d`: The address of the database to connect to, a Scylla node.
|
||||
//!
|
||||
//! The CLI also supports setting argument values via environment variables. The environment
|
||||
//! variables are prefixed with `PAYPHONE_`, so for example, the `--token` argument can be set
|
||||
//! using the `PAYPHONE_TOKEN` environment variable. The environment variables are used to
|
||||
//! override the command line arguments. `.env` files are also supported by default, so you can
|
||||
//! create a `.env` file in the root of the project with the following content:
|
||||
//!
|
||||
//! ```env
|
||||
//! PAYPHONE_TOKEN="your_discord_bot_token"
|
||||
//! PAYPHONE_LOGGING="info"
|
||||
//! PAYPHONE_DATABASE_ADDR="your_database_addr"
|
||||
//! ```
|
||||
//!
|
||||
//! # Parsing Arguments
|
||||
//!
|
||||
//! The arguments are parsed using the `Args::parse()` method, which returns an instance of the
|
||||
//! [`Args`] struct. This struct contains the parsed values of the command line arguments, and
|
||||
//! can be used to initialize the bot and start the event loop.
|
||||
//!
|
||||
//! ```
|
||||
//! // Read the environment variables from the `.env` file, if it exists, and load them as proper
|
||||
//! // env variables.
|
||||
//! dotenvy::dotenv().ok();
|
||||
//!
|
||||
//! let args = Args::parse();
|
||||
//! ```
|
||||
//!
|
||||
//! # Running the Bot
|
||||
//!
|
||||
//! To start the bot, run:
|
||||
//!
|
||||
//! ```shell
|
||||
//! $ cargo run -- start [TOKEN]
|
||||
//! ```
|
||||
//!
|
||||
//! # Running the Documentation Server
|
||||
//!
|
||||
//! Payphone has a built-in documentation server that serves the project's documentation and
|
||||
//! refreshes it on changes. To start the documentation server, run the following command:
|
||||
//!
|
||||
//! ```shell
|
||||
//! $ cargo run -- docs --open
|
||||
//! ```
|
||||
//!
|
||||
//! The `--open` flag is optional and will open the documentation in the default browser after
|
||||
//! starting the server. You can also use the `--bind` argument to specify the address to bind the
|
||||
//! server to, which defaults to `0.0.0.0:3000`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Payphone's argument parser.
|
||||
#[derive(clap::Parser)]
|
||||
#[command(name = "Payphone CLI")]
|
||||
#[command(version, about = "Payphone's command line interface.")]
|
||||
pub struct Args {
|
||||
/// The minimum level logs have to be to be outputted into the console, or off for no logs.
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
value_enum,
|
||||
default_value_t = LevelFilter::Info,
|
||||
env = "PAYPHONE_LOGGING",
|
||||
value_name = "LEVEL",
|
||||
long_help = "The minimum level logs have to be to be outputted into the console, or off for no logs."
|
||||
)]
|
||||
pub logging: LevelFilter,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
/// An enum with the different logging levels that can be used in the CLI.
|
||||
///
|
||||
/// This enum is used to parse the `--logging` or `-l` command line argument and then map the value
|
||||
/// to [`log::LevelFilter`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
|
||||
pub enum LevelFilter {
|
||||
/// No logs at all.
|
||||
Off,
|
||||
|
||||
/// The most verbose level, used for debugging purposes.
|
||||
Trace,
|
||||
|
||||
/// Events that are useful to be displayed during development, but not in production.
|
||||
Debug,
|
||||
|
||||
/// Used for general information about the bot's state and milestones.
|
||||
Info,
|
||||
|
||||
/// Used for warnings, when something unexpected happens but the bot can still continue
|
||||
/// running.
|
||||
Warn,
|
||||
|
||||
/// Used for errors, when something goes wrong and the bot cannot continue running.
|
||||
Error,
|
||||
}
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<log::LevelFilter> for LevelFilter {
|
||||
fn into(self) -> log::LevelFilter {
|
||||
match self {
|
||||
Self::Off => log::LevelFilter::Off,
|
||||
Self::Trace => log::LevelFilter::Trace,
|
||||
Self::Debug => log::LevelFilter::Debug,
|
||||
Self::Info => log::LevelFilter::Info,
|
||||
Self::Warn => log::LevelFilter::Warn,
|
||||
Self::Error => log::LevelFilter::Error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand, Clone)]
|
||||
pub enum Commands {
|
||||
/// Starts the bot with the provided token.
|
||||
Start {
|
||||
/// The Discord bot token to run the bot with.
|
||||
#[arg(env = "PAYPHONE_TOKEN")]
|
||||
token: String,
|
||||
|
||||
/// The socket address of the database to connect to, a Scylla node.
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_DATABASE_ADDR",
|
||||
value_name = "ADDR",
|
||||
default_value_t = String::from("127.0.0.1:9042"))
|
||||
]
|
||||
database_addr: String,
|
||||
},
|
||||
|
||||
/// Starts a documentation server for the project which serves `cargo doc` and refreshes on
|
||||
/// changes.
|
||||
Docs {
|
||||
/// The address to run the documentation server on.
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_DOCS_ADDR",
|
||||
value_name = "ADDR",
|
||||
default_value_t = SocketAddr::from(([0, 0, 0, 0], 3000))
|
||||
)]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// When this flag is set, the documentation server will open the documentation in the
|
||||
/// default browser after starting the server.
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_DOCS_OPEN",
|
||||
value_name = "OPEN",
|
||||
default_value_t = false,
|
||||
help = "Open the documentation in the default browser after starting the server."
|
||||
)]
|
||||
open: bool,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Constants used throughout the Payphone project.
|
||||
|
||||
/// The default command prefix used by the bot for new servers.
|
||||
pub const DEFAULT_PREFIX: &str = "p.";
|
||||
|
||||
/// A cream white color extracted from Payphone's avatar picture, used for generic embeds.
|
||||
pub const COLOR_WHITE: u32 = 0xE2D9D0;
|
||||
|
||||
/// A red color used for error messages and moderator embeds.
|
||||
pub const COLOR_RED: u32 = 0xFF8787;
|
||||
@@ -0,0 +1,167 @@
|
||||
CREATE KEYSPACE IF NOT EXISTS payphone
|
||||
WITH replication = {
|
||||
'class': 'NetworkTopologyStrategy',
|
||||
'replication_factor': 3
|
||||
} AND TABLETS = {
|
||||
'enabled': false
|
||||
};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.users (
|
||||
user_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
|
||||
is_masked BOOLEAN,
|
||||
is_moderator BOOLEAN,
|
||||
|
||||
joined_at TIMESTAMP,
|
||||
last_seen_at TIMESTAMP,
|
||||
supporter_since TIMESTAMP,
|
||||
supporter_until TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.calls (
|
||||
call_id BIGINT PRIMARY KEY,
|
||||
|
||||
channel_ids LIST<BIGINT>,
|
||||
|
||||
started_at TIMESTAMP,
|
||||
finished_at TIMESTAMP,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.channels (
|
||||
channel_id BIGINT PRIMARY KEY,
|
||||
server_id BIGINT,
|
||||
|
||||
call_id BIGINT,
|
||||
is_searching BOOLEAN,
|
||||
|
||||
recently_called_server_ids LIST<BIGINT>,
|
||||
|
||||
webhook_id BIGINT,
|
||||
webhook_token TEXT
|
||||
);
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.searching_channels AS
|
||||
SELECT channel_id, server_id, is_searching, recently_called_server_ids FROM payphone.channels
|
||||
WHERE channel_id IS NOT NULL AND is_searching = true
|
||||
PRIMARY KEY (is_searching, channel_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.participants (
|
||||
call_id BIGINT,
|
||||
user_id BIGINT,
|
||||
channel_id BIGINT,
|
||||
|
||||
user_name TEXT,
|
||||
user_avatar_url TEXT,
|
||||
user_mask TEXT,
|
||||
|
||||
PRIMARY KEY (call_id, user_id, channel_id)
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
call_id BIGINT,
|
||||
message_id BIGINT,
|
||||
author_id BIGINT,
|
||||
|
||||
author_name TEXT,
|
||||
author_avatar_url TEXT,
|
||||
author_mask ASCII,
|
||||
|
||||
content TEXT,
|
||||
attachments LIST<TEXT>,
|
||||
sticker BIGINT,
|
||||
|
||||
propagation_message_id BIGINT,
|
||||
propagation_channel_id BIGINT,
|
||||
|
||||
replying_to_id BIGINT,
|
||||
|
||||
is_deleted BOOLEAN,
|
||||
is_edited BOOLEAN,
|
||||
is_censored BOOLEAN,
|
||||
|
||||
PRIMARY KEY (call_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.servers (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
prefix TEXT,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.reports (
|
||||
report_id BIGINT,
|
||||
|
||||
message_author_id BIGINT,
|
||||
message_author_name TEXT,
|
||||
message_author_avatar_url TEXT,
|
||||
message_author_mask ASCII,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
|
||||
created_by_ids SET<BIGINT>,
|
||||
|
||||
reviewed_by_id BIGINT,
|
||||
resolution ASCII,
|
||||
|
||||
PRIMARY KEY (message_author_id, report_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.user_incidents (
|
||||
user_id BIGINT,
|
||||
incident_id BIGINT,
|
||||
|
||||
-- ban, warning
|
||||
type ASCII,
|
||||
|
||||
call_id BIGINT,
|
||||
report_id BIGINT,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
|
||||
reason TEXT,
|
||||
|
||||
created_by_id BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII,
|
||||
|
||||
PRIMARY KEY (user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.server_incidents (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
incident_id BIGINT,
|
||||
|
||||
reason TEXT,
|
||||
|
||||
created_by_id BIGINT,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
pub mod models;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use scylla::{
|
||||
client::{session::Session, session_builder::SessionBuilder},
|
||||
errors::ExecutionError,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("A connection was attempted to be established to the database, but it failed")]
|
||||
ConnectionFailed,
|
||||
|
||||
#[error("The database connection was established but the migrations could not be applied")]
|
||||
MigrationFailed,
|
||||
|
||||
#[error("The migration file was invalidly formatted (not UTF-8)")]
|
||||
MigrationFileInvalid(String),
|
||||
}
|
||||
|
||||
static MIGRATIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/database/migrations");
|
||||
|
||||
pub async fn init(uri: impl ToString) -> Result<Session, DatabaseError> {
|
||||
let session = SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?;
|
||||
|
||||
execute_file(
|
||||
&session,
|
||||
r#"
|
||||
CREATE KEYSPACE IF NOT EXISTS migrations
|
||||
WITH replication = {
|
||||
'class': 'NetworkTopologyStrategy',
|
||||
'replication_factor': 3
|
||||
};
|
||||
|
||||
CREATE TABLE IF NOT EXISTS migrations.applied (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP
|
||||
);
|
||||
"#
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not set up migrations keyspace and table! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
let mut sorted_migration_files = MIGRATIONS.files().collect::<Vec<_>>();
|
||||
sorted_migration_files.sort_by(|a, b| {
|
||||
a.path()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.cmp(&b.path().to_string_lossy().to_string())
|
||||
});
|
||||
|
||||
for file in sorted_migration_files {
|
||||
let migration_name = file.path().to_string_lossy().to_string();
|
||||
|
||||
let row = session
|
||||
.query_unpaged(
|
||||
"SELECT name FROM migrations.applied WHERE name = ? LIMIT 1;",
|
||||
(&migration_name,),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Could not check whether the migration \"{migration_name}\" was applied! {e:?}"
|
||||
)
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?
|
||||
.into_rows_result()
|
||||
.inspect_err(|e| log::error!("The applied migrations' database result were not rows! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?
|
||||
.maybe_first_row::<(String,)>()
|
||||
.inspect_err(|e| log::error!("The applied migrations' row returned by the database could not be parsed! {e:?}"))
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
if row.is_none() {
|
||||
execute_file(
|
||||
&session,
|
||||
file.contents_utf8()
|
||||
.ok_or(DatabaseError::MigrationFileInvalid(migration_name.clone()))?
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
.inspect(|_| log::debug!("Migration {migration_name} applied!"))
|
||||
.inspect_err(|e| {
|
||||
log::error!("Could not migrate {}! {e:?}", file.path().to_str().unwrap())
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
|
||||
session
|
||||
.query_unpaged(
|
||||
"INSERT INTO migrations.applied (name, applied_at) VALUES (?, ?);",
|
||||
(migration_name.clone(), OffsetDateTime::now_utc()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Could not mark migration {migration_name} as applied in DB! {e:?}")
|
||||
})
|
||||
.map_err(|_| DatabaseError::MigrationFailed)?;
|
||||
} else {
|
||||
log::trace!("Skipped migration {migration_name} because it was already applied.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("An error occurred while executing a statement in the file")]
|
||||
struct FileExecutionError {
|
||||
pub statement_index: usize,
|
||||
pub error: ExecutionError,
|
||||
}
|
||||
|
||||
async fn execute_file(session: &Session, file: String) -> Result<(), FileExecutionError> {
|
||||
let statements: Vec<&str> = file.split(";").collect();
|
||||
|
||||
for (statement_index, statement) in statements.iter().enumerate() {
|
||||
if statement.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
session
|
||||
.query_unpaged(format!("{};", statement.trim()), ())
|
||||
.await
|
||||
.map_err(|error| FileExecutionError {
|
||||
error,
|
||||
statement_index: statement_index + 1,
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
use scylla::{
|
||||
client::session::Session,
|
||||
deserialize::row::DeserializeRow,
|
||||
errors::{ExecutionError, SingleRowError},
|
||||
statement::{
|
||||
Statement,
|
||||
batch::{Batch, BatchType},
|
||||
},
|
||||
};
|
||||
use thiserror::Error;
|
||||
use time::{OffsetDateTime, UtcDateTime};
|
||||
use twilight_http::Client;
|
||||
use twilight_model::{
|
||||
guild::{Guild as TwilightGuild, GuildInfo as TwilightGuildInfo},
|
||||
id::Id,
|
||||
user::User as TwilightUser,
|
||||
};
|
||||
|
||||
use crate::{constants::DEFAULT_PREFIX, utils::get_random_u64};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseOrDiscordError {
|
||||
#[error("An error occurred when querying the database")]
|
||||
Database(#[from] DatabaseError),
|
||||
|
||||
#[error("An error occurred when querying the Discord API")]
|
||||
Discord(#[from] twilight_http::Error),
|
||||
|
||||
#[error("The Discord API returned a response that could not be deserialized")]
|
||||
DiscordDeserializationFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DatabaseError {
|
||||
#[error("An error occurred when querying the database")]
|
||||
QueryError(ExecutionError),
|
||||
|
||||
#[error("The database responded but the result of the query weren't rows")]
|
||||
ResultNotRows,
|
||||
|
||||
#[error("The resulting row could not be deserialized into an object")]
|
||||
DeserializationFailed,
|
||||
}
|
||||
|
||||
/// Copies the bytes from [`i64`] to [`u64`].
|
||||
///
|
||||
/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact,
|
||||
/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `i` - The [`i64`] to convert.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`u64`] - The resulting [`u64`].
|
||||
fn i64_to_u64(i: i64) -> u64 {
|
||||
u64::from_ne_bytes(i.to_ne_bytes())
|
||||
}
|
||||
|
||||
/// Copies the bytes from [`u64`] to [`i64`].
|
||||
///
|
||||
/// WARNING: This does NOT keep the same "represented" value. This only keeps the bytes intact,
|
||||
/// e.g. 0b0101 will still be 0b0101, but the value represented will change since the type changes.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `u` - The [`u64`] to convert.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`i64`] - The resulting [`i64`].
|
||||
fn u64_to_i64(u: u64) -> i64 {
|
||||
i64::from_ne_bytes(u.to_ne_bytes())
|
||||
}
|
||||
|
||||
trait Object<'a> {
|
||||
type Row: DeserializeRow<'a, 'a>;
|
||||
|
||||
fn from_row(row: Self::Row) -> Self;
|
||||
fn to_row(value: Self) -> Self::Row;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub is_masked: bool,
|
||||
pub is_moderator: bool,
|
||||
pub joined_at: UtcDateTime,
|
||||
pub last_seen_at: UtcDateTime,
|
||||
pub supporter_since: Option<UtcDateTime>,
|
||||
pub supporter_until: Option<UtcDateTime>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for User {
|
||||
type Row = (
|
||||
i64,
|
||||
String,
|
||||
Option<String>,
|
||||
bool,
|
||||
bool,
|
||||
OffsetDateTime,
|
||||
OffsetDateTime,
|
||||
Option<OffsetDateTime>,
|
||||
Option<OffsetDateTime>,
|
||||
);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
name: row.1,
|
||||
email: row.2,
|
||||
is_masked: row.3,
|
||||
is_moderator: row.4,
|
||||
joined_at: row.5.into(),
|
||||
last_seen_at: row.6.into(),
|
||||
supporter_since: row.7.map(|i| i.into()),
|
||||
supporter_until: row.8.map(|i| i.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
value.name,
|
||||
value.email,
|
||||
value.is_masked,
|
||||
value.is_moderator,
|
||||
value.joined_at.into(),
|
||||
value.last_seen_at.into(),
|
||||
value.supporter_since.map(|i| i.into()),
|
||||
value.supporter_until.map(|i| i.into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
user_id,
|
||||
name,
|
||||
email,
|
||||
is_masked,
|
||||
is_moderator,
|
||||
joined_at,
|
||||
last_seen_at,
|
||||
supporter_since,
|
||||
supporter_until
|
||||
FROM
|
||||
payphone.users
|
||||
WHERE
|
||||
user_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<User as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightUser> for User {
|
||||
fn from(value: TwilightUser) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
email: value.email,
|
||||
is_masked: false,
|
||||
is_moderator: false,
|
||||
joined_at: UtcDateTime::now(),
|
||||
last_seen_at: UtcDateTime::now(),
|
||||
supporter_since: None,
|
||||
supporter_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Server {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Server {
|
||||
type Row = (i64, String, String);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
name: row.1,
|
||||
prefix: row.2,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(u64_to_i64(value.id), value.name, value.prefix)
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
FROM
|
||||
payphone.servers
|
||||
WHERE
|
||||
server_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Server as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.servers (
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
) VALUES (?, ?, ?) IF NOT EXISTS;
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("{e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.servers (
|
||||
server_id,
|
||||
name,
|
||||
prefix
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?
|
||||
);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_by_id(id: u64, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
"DELETE FROM payphone.servers WHERE server_id = ?;",
|
||||
(u64_to_i64(id),),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightGuild> for Server {
|
||||
fn from(value: TwilightGuild) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
prefix: DEFAULT_PREFIX.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TwilightGuildInfo> for Server {
|
||||
fn from(value: TwilightGuildInfo) -> Self {
|
||||
Self {
|
||||
id: value.id.get(),
|
||||
name: value.name,
|
||||
prefix: DEFAULT_PREFIX.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Channel {
|
||||
pub id: u64,
|
||||
pub server_id: u64,
|
||||
pub call_id: Option<u64>,
|
||||
pub is_searching: bool,
|
||||
pub recently_called_server_ids: Vec<u64>,
|
||||
pub webhook_id: Option<u64>,
|
||||
pub webhook_token: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Channel {
|
||||
type Row = (
|
||||
i64,
|
||||
i64,
|
||||
Option<i64>,
|
||||
bool,
|
||||
Vec<i64>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
server_id: i64_to_u64(row.1),
|
||||
call_id: row.2.map(i64_to_u64),
|
||||
is_searching: row.3,
|
||||
recently_called_server_ids: row.4.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
webhook_id: row.5.map(i64_to_u64),
|
||||
webhook_token: row.6,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
u64_to_i64(value.server_id),
|
||||
value.call_id.map(u64_to_i64),
|
||||
value.is_searching,
|
||||
value
|
||||
.recently_called_server_ids
|
||||
.iter()
|
||||
.map(|&u| u64_to_i64(u))
|
||||
.collect(),
|
||||
value.webhook_id.map(u64_to_i64),
|
||||
value.webhook_token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
channel_id,
|
||||
server_id,
|
||||
call_id,
|
||||
is_searching,
|
||||
recently_called_server_ids,
|
||||
webhook_id,
|
||||
webhook_token
|
||||
FROM
|
||||
payphone.channels
|
||||
WHERE
|
||||
channel_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Channel as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create_by_id(
|
||||
id: u64,
|
||||
session: &Session,
|
||||
http: &Client,
|
||||
) -> Result<Self, DatabaseOrDiscordError> {
|
||||
if let Some(channel) = Channel::get_by_id(id, session)
|
||||
.await
|
||||
.map_err(DatabaseOrDiscordError::Database)?
|
||||
{
|
||||
Ok(channel)
|
||||
} else {
|
||||
let webhook = http
|
||||
.create_webhook(Id::new(id), "Payphone Messages")
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to create webhook for channel {id}: {e:?}"))
|
||||
.map_err(DatabaseOrDiscordError::Discord)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get model for webhook {id}: {e:?}"))
|
||||
.map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?;
|
||||
|
||||
let response = http
|
||||
.channel(Id::new(id))
|
||||
.await
|
||||
.map_err(DatabaseOrDiscordError::Discord)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("The discord API didn't return a parseable channel! {e:?}")
|
||||
})
|
||||
.map_err(|_| DatabaseOrDiscordError::DiscordDeserializationFailed)?;
|
||||
|
||||
let channel = Channel {
|
||||
id: response.id.get(),
|
||||
server_id: response.guild_id.map(|i| i.get()).unwrap_or(0),
|
||||
call_id: None,
|
||||
is_searching: false,
|
||||
recently_called_server_ids: vec![],
|
||||
webhook_id: Some(webhook.id.get()),
|
||||
webhook_token: webhook.token,
|
||||
};
|
||||
channel
|
||||
.upsert(session)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not upsert channel in database! {e:?}"))
|
||||
.map_err(DatabaseOrDiscordError::Database)?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upsert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.channels (
|
||||
channel_id,
|
||||
server_id,
|
||||
call_id,
|
||||
is_searching,
|
||||
recently_called_server_ids,
|
||||
webhook_id,
|
||||
webhook_token
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_search(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
"UPDATE payphone.channels SET is_searching = false WHERE channel_id = ?;",
|
||||
(u64_to_i64(self.id),),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchingChannel {
|
||||
pub id: u64,
|
||||
pub server_id: u64,
|
||||
pub recently_called_server_ids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for SearchingChannel {
|
||||
type Row = (i64, i64, Vec<i64>);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
server_id: i64_to_u64(row.1),
|
||||
recently_called_server_ids: row.2.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
u64_to_i64(value.server_id),
|
||||
value
|
||||
.recently_called_server_ids
|
||||
.iter()
|
||||
.map(|&u| u64_to_i64(u))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchingChannel {
|
||||
pub async fn get_all(session: &Session) -> Result<Vec<Self>, DatabaseError> {
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
channel_id,
|
||||
server_id,
|
||||
recently_called_server_ids
|
||||
FROM
|
||||
payphone.searching_channels;
|
||||
"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
Ok(result
|
||||
.rows::<<SearchingChannel as Object>::Row>()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| DatabaseError::DeserializationFailed)?
|
||||
.into_iter()
|
||||
.map(Self::from_row)
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Call {
|
||||
pub id: u64,
|
||||
pub channel_ids: Vec<u64>,
|
||||
pub started_at: UtcDateTime,
|
||||
pub finished_at: Option<UtcDateTime>,
|
||||
}
|
||||
|
||||
impl<'a> Object<'a> for Call {
|
||||
type Row = (i64, Vec<i64>, OffsetDateTime, Option<OffsetDateTime>);
|
||||
|
||||
fn from_row(row: Self::Row) -> Self {
|
||||
Self {
|
||||
id: i64_to_u64(row.0),
|
||||
channel_ids: row.1.iter().map(|&i| i64_to_u64(i)).collect(),
|
||||
started_at: row.2.into(),
|
||||
finished_at: row.3.map(|i| i.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_row(value: Self) -> Self::Row {
|
||||
(
|
||||
u64_to_i64(value.id),
|
||||
value.channel_ids.iter().map(|&u| u64_to_i64(u)).collect(),
|
||||
value.started_at.into(),
|
||||
value.finished_at.map(|i| i.into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn push_and_cap<T>(vec: &mut Vec<T>, value: T, max_size: usize) {
|
||||
if vec.len() >= max_size {
|
||||
vec.remove(0);
|
||||
}
|
||||
|
||||
vec.push(value);
|
||||
}
|
||||
|
||||
impl Call {
|
||||
pub async fn get_by_id(id: u64, session: &Session) -> Result<Option<Self>, DatabaseError> {
|
||||
let id = u64_to_i64(id);
|
||||
|
||||
let result = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
SELECT
|
||||
call_id,
|
||||
channel_ids,
|
||||
started_at,
|
||||
finished_at
|
||||
FROM
|
||||
payphone.calls
|
||||
WHERE
|
||||
call_id = ?
|
||||
LIMIT 1;
|
||||
"#,
|
||||
(id,),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?;
|
||||
|
||||
match result.single_row::<<Call as Object>::Row>() {
|
||||
Ok(row) => Ok(Some(Self::from_row(row))),
|
||||
Err(error) => match error {
|
||||
SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => {
|
||||
Err(DatabaseError::DeserializationFailed)
|
||||
}
|
||||
SingleRowError::UnexpectedRowCount(_) => Ok(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the opposite party's channel ID.
|
||||
pub fn other_party_channel_id(&self, channel_id: u64) -> u64 {
|
||||
if self.channel_ids[0] == channel_id {
|
||||
self.channel_ids[1]
|
||||
} else {
|
||||
self.channel_ids[0]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.finished_at.is_none()
|
||||
}
|
||||
|
||||
/// Starts a call, or marks the channel as searching if the call is not started.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `channel` - The channel to start the call in.
|
||||
/// * `session` - The Scylla session to use for database operations.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Result<Option<Self>, DatabaseError>`] - `Ok(Some(Call))` if the call was started
|
||||
/// successfully, or `Ok(None)` if the channel was marked as searching. If an error occurs, it
|
||||
/// returns `Err(DatabaseError)`.
|
||||
pub async fn start(
|
||||
channel: &Channel,
|
||||
session: &Session,
|
||||
) -> Result<Option<Self>, DatabaseError> {
|
||||
let searching_channels = SearchingChannel::get_all(session).await?;
|
||||
|
||||
let call_id = get_random_u64();
|
||||
|
||||
log::trace!("Call ID is {call_id}");
|
||||
|
||||
for searching_channel in searching_channels
|
||||
.iter()
|
||||
.filter(|sc| !sc.recently_called_server_ids.contains(&channel.server_id))
|
||||
{
|
||||
if searching_channel.server_id == channel.server_id {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if channel
|
||||
.recently_called_server_ids
|
||||
.contains(&searching_channel.server_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
log::trace!("Found a searching channel: {searching_channel:?}");
|
||||
|
||||
let mut searching_channel_recently_called_server_ids = searching_channel
|
||||
.recently_called_server_ids
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(u64_to_i64)
|
||||
.collect::<Vec<_>>();
|
||||
push_and_cap(
|
||||
&mut searching_channel_recently_called_server_ids,
|
||||
u64_to_i64(channel.server_id),
|
||||
5,
|
||||
);
|
||||
|
||||
let (is_applied, ..) = session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
UPDATE payphone.channels
|
||||
SET
|
||||
is_searching = false,
|
||||
call_id = ?,
|
||||
recently_called_server_ids = ?
|
||||
WHERE
|
||||
channel_id = ?
|
||||
IF call_id = NULL;
|
||||
"#,
|
||||
(
|
||||
u64_to_i64(call_id),
|
||||
searching_channel_recently_called_server_ids,
|
||||
u64_to_i64(searching_channel.id),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to add channel to the call! {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?
|
||||
.into_rows_result()
|
||||
.inspect_err(|e| log::error!("Failed to get result of the query! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ResultNotRows)?
|
||||
.first_row::<(bool, Option<i64>)>()
|
||||
.inspect_err(|e| log::error!("Failed to get first row of the result! {e:?}"))
|
||||
.map_err(|_| DatabaseError::DeserializationFailed)?;
|
||||
|
||||
log::trace!("Is applied: {is_applied:?}");
|
||||
|
||||
if is_applied {
|
||||
let mut channel_recently_called_server_ids = channel
|
||||
.recently_called_server_ids
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(u64_to_i64)
|
||||
.collect::<Vec<_>>();
|
||||
push_and_cap(
|
||||
&mut channel_recently_called_server_ids,
|
||||
u64_to_i64(searching_channel.server_id),
|
||||
5,
|
||||
);
|
||||
|
||||
session
|
||||
.query_unpaged(
|
||||
"UPDATE payphone.channels SET call_id = ?, recently_called_server_ids = ? WHERE channel_id = ?",
|
||||
(u64_to_i64(call_id), channel_recently_called_server_ids, u64_to_i64(channel.id)),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to update channel {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
let call = Call {
|
||||
id: call_id,
|
||||
channel_ids: vec![searching_channel.id, channel.id].into_iter().collect(),
|
||||
started_at: UtcDateTime::now(),
|
||||
finished_at: None,
|
||||
};
|
||||
call.insert(session)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to insert call {e:?}"))?;
|
||||
|
||||
return Ok(Some(call));
|
||||
} else {
|
||||
// This channel already got a call, so we skip it, preventing race conditions.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
session.query_unpaged(
|
||||
"UPDATE payphone.channels SET is_searching = true WHERE channel_id = ? IF call_id = NULL;",
|
||||
(u64_to_i64(channel.id),),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to mark channel as searching! {e:?}"))
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
session
|
||||
.query_unpaged(
|
||||
r#"
|
||||
INSERT INTO payphone.calls (
|
||||
call_id,
|
||||
channel_ids,
|
||||
started_at,
|
||||
finished_at
|
||||
) VALUES (?, ?, ?, ?);
|
||||
"#,
|
||||
Self::to_row(self.clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hangup(&self, session: &Session) -> Result<(), DatabaseError> {
|
||||
let mut batch = Batch::new(BatchType::Unlogged);
|
||||
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.calls SET finished_at = ? WHERE call_id = ?;",
|
||||
));
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;",
|
||||
));
|
||||
batch.append_statement(Statement::new(
|
||||
"UPDATE payphone.channels SET call_id = NULL WHERE channel_id = ?;",
|
||||
));
|
||||
|
||||
let call_id = u64_to_i64(self.id);
|
||||
|
||||
session
|
||||
.batch(
|
||||
&batch,
|
||||
(
|
||||
(OffsetDateTime::now_utc(), call_id),
|
||||
(u64_to_i64(self.channel_ids[0]),),
|
||||
(u64_to_i64(self.channel_ids[1]),),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(DatabaseError::QueryError)?;
|
||||
|
||||
log::trace!("Hung up call {call_id}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use twilight_model::util::ImageHash;
|
||||
|
||||
pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64) -> String {
|
||||
if let Some(hash) = hash {
|
||||
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
|
||||
} else {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/embed/avatars/{}.png?size=256",
|
||||
match discriminator.to_string().as_str() {
|
||||
"0" => (user_id >> 22) % 6,
|
||||
_ => discriminator % 6,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod images;
|
||||
pub mod sanitizing;
|
||||
@@ -0,0 +1,38 @@
|
||||
pub trait SanitizeDiscordMarkdown {
|
||||
fn sanitize(&self, hard: bool) -> String;
|
||||
}
|
||||
|
||||
impl<T> SanitizeDiscordMarkdown for T
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
fn sanitize(&self, hard: bool) -> String {
|
||||
let input = self.to_string();
|
||||
|
||||
let escaped = if hard {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' | '*' | '_' | '~' | '`' | '>' | '<' | '|' | '#' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
escaped
|
||||
} else {
|
||||
input
|
||||
};
|
||||
|
||||
// Now block ping attempts — break mentions
|
||||
escaped
|
||||
.replace("@everyone", "@\u{200B}everyone")
|
||||
.replace("@here", "@\u{200B}here")
|
||||
.replace("<@", "<@\u{200B}") // user mentions
|
||||
.replace("<@&", "<@&\u{200B}") // role mentions
|
||||
.replace("<#", "<#\u{200B}") // channel mentions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
|
||||
use twilight_http::Client;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
commands::{Command, runner::CommandRunner},
|
||||
events::traits::{
|
||||
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
|
||||
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
|
||||
OnServerUpdateHandler,
|
||||
},
|
||||
interactions::{components::Component, registry::ComponentRegistry},
|
||||
runtime::Runtime,
|
||||
},
|
||||
utils::Bye,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("The application's details could not be fetched")]
|
||||
ApplicationFetchingFailed,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IntoPrefixes<T> {
|
||||
async fn get(
|
||||
&self,
|
||||
runtime: &Runtime<T>,
|
||||
server_id: Option<u64>,
|
||||
user_id: Option<u64>,
|
||||
) -> Vec<String>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for String {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for &str {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static, const N: usize, S: Send + Sync + 'static> IntoPrefixes<S> for [T; N]
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<S>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
self.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<F, Fut, T> IntoPrefixes<T> for F
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Vec<String>> + Send,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
(self)().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bot<T: Send + Sync = ()> {
|
||||
state: Arc<T>,
|
||||
prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync>,
|
||||
intents: Intents,
|
||||
commands: Vec<Arc<dyn Command<T>>>,
|
||||
components: Vec<Arc<dyn Component<T>>>,
|
||||
on_ready_handlers: Vec<Arc<dyn OnReadyHandler<T>>>,
|
||||
on_reconnect_handlers: Vec<Arc<dyn OnReconnectHandler<T>>>,
|
||||
on_interaction_handlers: Vec<Arc<dyn OnInteractionHandler<T>>>,
|
||||
on_message_handlers: Vec<Arc<dyn OnMessageHandler<T>>>,
|
||||
on_message_edit_handlers: Vec<Arc<dyn OnMessageEditHandler<T>>>,
|
||||
on_message_delete_handlers: Vec<Arc<dyn OnMessageDeleteHandler<T>>>,
|
||||
on_server_create_handlers: Vec<Arc<dyn OnServerCreateHandler<T>>>,
|
||||
on_server_update_handlers: Vec<Arc<dyn OnServerUpdateHandler<T>>>,
|
||||
on_server_delete_handlers: Vec<Arc<dyn OnServerDeleteHandler<T>>>,
|
||||
}
|
||||
|
||||
impl<T: 'static + Send + Sync> Bot<T> {
|
||||
pub fn new(state: T, prefixes: impl IntoPrefixes<T> + Send + Sync + 'static) -> Self {
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
prefixes: Arc::new(prefixes),
|
||||
intents: Intents::empty(),
|
||||
commands: Vec::new(),
|
||||
components: Vec::new(),
|
||||
on_ready_handlers: Vec::new(),
|
||||
on_reconnect_handlers: Vec::new(),
|
||||
on_interaction_handlers: Vec::new(),
|
||||
on_message_handlers: vec![Arc::new(CommandRunner)],
|
||||
on_message_edit_handlers: Vec::new(),
|
||||
on_message_delete_handlers: Vec::new(),
|
||||
on_server_create_handlers: Vec::new(),
|
||||
on_server_update_handlers: Vec::new(),
|
||||
on_server_delete_handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intent(mut self, intent: Intents) -> Self {
|
||||
self.intents.insert(intent);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command(mut self, command: impl Command<T> + 'static) -> Self {
|
||||
self.commands.push(Arc::new(command));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static) -> Self {
|
||||
self.components.push(Arc::new(component));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ready(mut self, handler: impl OnReadyHandler<T> + 'static) -> Self {
|
||||
self.on_ready_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_reconnect(mut self, handler: impl OnReconnectHandler<T> + 'static) -> Self {
|
||||
self.on_reconnect_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_interaction(mut self, handler: impl OnInteractionHandler<T> + 'static) -> Self {
|
||||
self.on_interaction_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message(mut self, handler: impl OnMessageHandler<T> + 'static) -> Self {
|
||||
self.on_message_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler<T> + 'static) -> Self {
|
||||
self.on_message_edit_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_delete(mut self, handler: impl OnMessageDeleteHandler<T> + 'static) -> Self {
|
||||
self.on_message_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_create(mut self, handler: impl OnServerCreateHandler<T> + 'static) -> Self {
|
||||
self.on_server_create_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler<T> + 'static) -> Self {
|
||||
self.on_server_update_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler<T> + 'static) -> Self {
|
||||
self.on_server_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(self, token: &str) -> Result<(), RuntimeError> {
|
||||
let http = Client::new(token.to_string());
|
||||
let app = http
|
||||
.current_user_application()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not fetch application data! {e:?}"))
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?
|
||||
.model()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not parse application data! {e:?}"))
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?;
|
||||
let registry = ComponentRegistry::new();
|
||||
|
||||
for component in &self.components {
|
||||
let component_id = component.custom_id();
|
||||
|
||||
registry.register(
|
||||
component_id.expect("The component registered by default must have a custom ID"),
|
||||
component.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
let bot = Arc::new(self);
|
||||
|
||||
let runtime = Arc::new(Runtime {
|
||||
http,
|
||||
app,
|
||||
registry,
|
||||
state: bot.state.clone(),
|
||||
prefixes: bot.prefixes.clone(),
|
||||
commands: bot.commands.clone(),
|
||||
});
|
||||
|
||||
let mut gateway = Shard::new(ShardId::new(0, 1), token.to_string(), bot.intents);
|
||||
|
||||
loop {
|
||||
if let Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()).await {
|
||||
let task_rt = runtime.clone();
|
||||
let task_bot = bot.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match event {
|
||||
Event::Ready(event) => {
|
||||
for handler in &task_bot.on_ready_handlers {
|
||||
handler
|
||||
.on_ready(&event, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on ready for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GatewayReconnect => {
|
||||
for handler in &task_bot.on_reconnect_handlers {
|
||||
handler
|
||||
.on_reconnect(task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on reconnect for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::InteractionCreate(interaction) => {
|
||||
for handler in &task_bot.on_interaction_handlers {
|
||||
handler
|
||||
.on_interaction(&interaction, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageCreate(message) => {
|
||||
for handler in &task_bot.on_message_handlers {
|
||||
log::trace!("Running {handler:?}");
|
||||
|
||||
handler
|
||||
.on_message(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageUpdate(message) => {
|
||||
for handler in &task_bot.on_message_edit_handlers {
|
||||
handler
|
||||
.on_message_edit(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message edit for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageDelete(message) => {
|
||||
for handler in &task_bot.on_message_delete_handlers {
|
||||
handler
|
||||
.on_message_delete(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildCreate(guild) => {
|
||||
for handler in &task_bot.on_server_create_handlers {
|
||||
handler
|
||||
.on_server_create(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server create for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildDelete(guild) => {
|
||||
for handler in &task_bot.on_server_delete_handlers {
|
||||
handler
|
||||
.on_server_delete(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::trace!("Unhandled event {:?} received", event.kind());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::{
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArgumentParsingError {
|
||||
#[error("This argument was missing in the arguments array")]
|
||||
MissingArgument,
|
||||
|
||||
#[error("An argument was there but it could not be parsed into the specified value")]
|
||||
InvalidArgument,
|
||||
}
|
||||
|
||||
pub trait FromArguments: Sized {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl FromArguments for String {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((arg.to_string(), rest)),
|
||||
None => Ok((args.to_string(), "")),
|
||||
}
|
||||
} else {
|
||||
Ok((args.to_string(), ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GreedyString(pub String);
|
||||
|
||||
impl FromArguments for GreedyString {
|
||||
fn from_args<'a>(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
let value = GreedyString(args.to_string());
|
||||
|
||||
Ok((value, ""))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for GreedyString {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for GreedyString {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GreedyString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NeedyString(pub String);
|
||||
|
||||
impl Deref for NeedyString {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for NeedyString {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NeedyString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for NeedyString {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((Self(arg.to_string()), rest)),
|
||||
None => Ok((Self(args.to_string()), "")),
|
||||
}
|
||||
} else {
|
||||
Err(ArgumentParsingError::MissingArgument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NeedyGreedyString(pub String);
|
||||
|
||||
impl Deref for NeedyGreedyString {
|
||||
type Target = String;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for NeedyGreedyString {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NeedyGreedyString {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for NeedyGreedyString {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
Ok((Self(args.to_string()), ""))
|
||||
} else {
|
||||
Err(ArgumentParsingError::MissingArgument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for bool {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
let arg: &str;
|
||||
let rest: &str;
|
||||
|
||||
match args.split_once(" ") {
|
||||
Some((a, b)) => {
|
||||
arg = a;
|
||||
rest = b;
|
||||
}
|
||||
None => {
|
||||
arg = args;
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
|
||||
match arg {
|
||||
"y" | "yes" | "on" | "true" | "t" | "1" => Ok((true, rest)),
|
||||
"n" | "no" | "off" | "false" | "f" | "0" => Ok((false, rest)),
|
||||
_ => Err(ArgumentParsingError::InvalidArgument),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromArguments for Option<T>
|
||||
where
|
||||
T: FromArguments,
|
||||
{
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
match T::from_args(args) {
|
||||
Ok((result, rest)) => Ok((Some(result), rest)),
|
||||
Err(_) => Ok((None, args)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ParseArguments: Sized {
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl ParseArguments for () {
|
||||
fn parse(_: &str) -> Result<Self, ArgumentParsingError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for A
|
||||
where
|
||||
A: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for (A,)
|
||||
where
|
||||
A: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
|
||||
Ok((a,))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B> ParseArguments for (A, B)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, _) = B::from_args(args)?;
|
||||
|
||||
Ok((a, b))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B, C> ParseArguments for (A, B, C)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
C: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, args) = B::from_args(args)?;
|
||||
let (c, _) = C::from_args(args)?;
|
||||
|
||||
Ok((a, b, c))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Argument {
|
||||
pub name: &'static str,
|
||||
pub is_optional: bool,
|
||||
pub is_greedy: bool,
|
||||
}
|
||||
|
||||
impl fmt::Display for Argument {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut argument = String::new();
|
||||
|
||||
if self.is_optional {
|
||||
argument.push('[');
|
||||
} else {
|
||||
argument.push('<');
|
||||
}
|
||||
|
||||
argument.push_str(self.name);
|
||||
|
||||
if self.is_greedy {
|
||||
argument.push_str("...");
|
||||
}
|
||||
|
||||
if self.is_optional {
|
||||
argument.push(']');
|
||||
} else {
|
||||
argument.push('>');
|
||||
}
|
||||
|
||||
write!(f, "{argument}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{commands::messages::MessageSender, runtime::Runtime};
|
||||
|
||||
pub struct CommandContext<'a, T> {
|
||||
pub event: &'a MessageCreate,
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub prefix: &'a str,
|
||||
}
|
||||
|
||||
impl<'a, T> CommandContext<'a, T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(event: &'a MessageCreate, rt: Arc<Runtime<T>>, prefix: &'a str) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
}
|
||||
|
||||
pub fn send(&self) -> MessageSender<T> {
|
||||
MessageSender::new(self.event.channel_id, self.rt.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
use std::{
|
||||
future::{Future, IntoFuture},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use twilight_http::{Error, Response, response::marker::EmptyBody};
|
||||
use twilight_model::{
|
||||
channel::{
|
||||
Message,
|
||||
message::{Component as TwilightComponent, Embed},
|
||||
},
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{
|
||||
Id,
|
||||
marker::{ChannelMarker, InteractionMarker},
|
||||
},
|
||||
};
|
||||
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||
|
||||
use crate::framework::{
|
||||
interactions::components::{ActionRow, Component},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
pub struct MessageSender<T> {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
|
||||
rt: Arc<Runtime<T>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
}
|
||||
|
||||
impl<T> MessageSender<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(channel_id: Id<ChannelMarker>, rt: Arc<Runtime<T>>) -> Self {
|
||||
Self {
|
||||
channel_id,
|
||||
rt,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl ToString) -> Self {
|
||||
self.content = Some(content.to_string());
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
self.embeds.push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<Response<Message>, Error> {
|
||||
let mut builder = self.rt.http.create_message(self.channel_id);
|
||||
|
||||
if let Some(content) = &self.content {
|
||||
builder = builder.content(content);
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut rows: Vec<TwilightComponent> = vec![];
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
builder = builder.components(&rows);
|
||||
builder = builder.embeds(&self.embeds);
|
||||
|
||||
let result = builder.await;
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> IntoFuture for MessageSender<T> {
|
||||
type Output = Result<Response<Message>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(self.send())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessageUpdater<T> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
|
||||
rt: Arc<Runtime<T>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
}
|
||||
|
||||
impl<T> OriginalMessageUpdater<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
rt: Arc<Runtime<T>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interaction_id,
|
||||
interaction_token,
|
||||
rt,
|
||||
content: None,
|
||||
components: vec![],
|
||||
embeds: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl ToString) -> Self {
|
||||
self.content = Some(content.to_string());
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
self.embeds.push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<Response<EmptyBody>, Error> {
|
||||
let mut builder = InteractionResponseDataBuilder::new();
|
||||
|
||||
if let Some(content) = &self.content {
|
||||
builder = builder.content(content);
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut rows: Vec<TwilightComponent> = vec![];
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
builder = builder.components(rows);
|
||||
builder = builder.embeds(self.embeds);
|
||||
|
||||
let result = self
|
||||
.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.interaction_id,
|
||||
&self.interaction_token,
|
||||
&InteractionResponse {
|
||||
data: Some(builder.build()),
|
||||
kind: InteractionResponseType::UpdateMessage,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> IntoFuture for OriginalMessageUpdater<T> {
|
||||
type Output = Result<Response<EmptyBody>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
fn into_future(self) -> Self::IntoFuture {
|
||||
Box::pin(self.send())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::framework::commands::{arguments::Argument, context::CommandContext};
|
||||
|
||||
pub mod arguments;
|
||||
pub mod context;
|
||||
pub mod messages;
|
||||
pub mod runner;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CommandError {
|
||||
#[error("A message was attempted to be sent but it failed")]
|
||||
MessageSendingFailed,
|
||||
|
||||
#[error("A database query failed")]
|
||||
DatabaseQueryFailed,
|
||||
|
||||
#[error("A database operation failed due to an inconsistent state")]
|
||||
DatabaseInconsistentState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Command<T>: Send + Sync + Debug {
|
||||
fn name(&self) -> &'static str;
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
fn description(&self) -> &'static str {
|
||||
"A Payphone command."
|
||||
}
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Returns the command name + all its aliases.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Vec<&'static str>`] - A vector with the command's name and its aliases.
|
||||
fn identifiers(&self) -> Vec<&'static str> {
|
||||
let mut all = vec![self.name()];
|
||||
all.extend_from_slice(self.aliases());
|
||||
all
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: CommandContext<'_, T>, args: &str) -> Result<(), CommandError>;
|
||||
}
|
||||
|
||||
pub struct CommandParts<'a> {
|
||||
pub name: &'a str,
|
||||
pub args: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> CommandParts<'a> {
|
||||
fn new(name: &'a str, args: &'a str) -> Self {
|
||||
Self { name, args }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_command_parts<'a>(prefix: &str, message: &'a str) -> Option<CommandParts<'a>> {
|
||||
let message_trimmed_start = message.trim_ascii_start();
|
||||
|
||||
if message_trimmed_start
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&prefix.to_ascii_lowercase())
|
||||
{
|
||||
let message = message_trimmed_start.split_at(prefix.len()).1;
|
||||
|
||||
match message.split_once(" ") {
|
||||
Some((name, args)) => Some(CommandParts::new(name, args)),
|
||||
None => Some(CommandParts::new(message, "")),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{
|
||||
commands::{context::CommandContext, get_command_parts},
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommandRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static> OnMessageHandler<T> for CommandRunner {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
|
||||
for prefix in prefixes {
|
||||
if let Some(parts) = get_command_parts(&prefix, &event.content) {
|
||||
let command = rt.commands.iter().find(|cmd| {
|
||||
cmd.identifiers()
|
||||
.contains(&parts.name.to_ascii_lowercase().as_str())
|
||||
});
|
||||
|
||||
if let Some(command) = command {
|
||||
command
|
||||
.execute(CommandContext::new(event, rt.clone(), &prefix), parts.args)
|
||||
.await?;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod traits;
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{error::Error, fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::{
|
||||
GuildCreate, GuildDelete, GuildUpdate, InteractionCreate, MessageCreate, MessageDelete,
|
||||
MessageUpdate, Ready,
|
||||
};
|
||||
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub type EventResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReadyHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_ready(&self, event: &Ready, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReconnectHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_reconnect(&self, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnInteractionHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_interaction(&self, event: &InteractionCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageEditHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageDeleteHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_message_delete(&self, event: &MessageDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerCreateHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_create(&self, event: &GuildCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerUpdateHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_update(&self, event: &GuildUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerDeleteHandler<T>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
{
|
||||
async fn on_server_delete(&self, event: &GuildDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::channel::message::{
|
||||
Component as TwilightComponent,
|
||||
component::{ActionRow as TwilightActionRow, Button as TwilightButton, ButtonStyle},
|
||||
};
|
||||
|
||||
use crate::framework::interactions::{InteractionError, context::InteractionContext};
|
||||
|
||||
type ComponentWithID<T> = (Option<String>, Arc<dyn Component<T>>);
|
||||
|
||||
pub struct ActionRow<T> {
|
||||
components: Vec<ComponentWithID<T>>,
|
||||
}
|
||||
|
||||
impl<T> ActionRow<T> {
|
||||
pub fn new(components: &[ComponentWithID<T>]) -> Self {
|
||||
Self {
|
||||
components: components.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component<T>>) {
|
||||
self.components.push((id, component));
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.components.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.components.is_empty()
|
||||
}
|
||||
|
||||
pub fn build(self) -> TwilightComponent {
|
||||
TwilightComponent::ActionRow(TwilightActionRow {
|
||||
components: self
|
||||
.components
|
||||
.into_iter()
|
||||
.map(|(id, c)| c.build(id))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Component<T>: Send + Sync + Debug {
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Some(String::from("component"))
|
||||
}
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent;
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext<T>) -> Result<(), InteractionError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, S: Send + Sync + 'static> Component<S> for T
|
||||
where
|
||||
T: Button<S>,
|
||||
{
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Button::id(self)
|
||||
}
|
||||
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent {
|
||||
TwilightComponent::Button(TwilightButton {
|
||||
custom_id: id,
|
||||
disabled: false,
|
||||
emoji: None,
|
||||
label: self.label().map(|i| i.to_string()),
|
||||
style: if self.url().is_none() {
|
||||
self.style()
|
||||
} else {
|
||||
ButtonStyle::Link
|
||||
},
|
||||
url: self.url().map(|i| i.to_string()),
|
||||
sku_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
self.on_press(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Button<S: Send + Sync + 'static>: Send + Sync + Debug {
|
||||
fn id(&self) -> Option<String> {
|
||||
if self.url().is_none() {
|
||||
Some(String::from("Button"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn style(&self) -> ButtonStyle {
|
||||
ButtonStyle::Secondary
|
||||
}
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
ctx.defer().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::{
|
||||
gateway::payload::incoming::InteractionCreate,
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{Id, marker::InteractionMarker},
|
||||
};
|
||||
|
||||
use crate::framework::{
|
||||
commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime,
|
||||
};
|
||||
|
||||
pub struct InteractionContext<T> {
|
||||
pub event: InteractionCreate,
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
impl<T> InteractionContext<T> {
|
||||
pub fn new(event: InteractionCreate, rt: Arc<Runtime<T>>, prefix: String) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
}
|
||||
|
||||
pub async fn defer(&self) -> Result<(), InteractionError> {
|
||||
self.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
&InteractionResponse {
|
||||
data: None,
|
||||
kind: InteractionResponseType::DeferredUpdateMessage,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn think(&self) -> Result<(), InteractionError> {
|
||||
self.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
&InteractionResponse {
|
||||
data: None,
|
||||
kind: InteractionResponseType::DeferredChannelMessageWithSource,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn original_message(&self) -> Option<OriginalMessage<T>> {
|
||||
self.event.message.as_ref().map(|_| OriginalMessage {
|
||||
interaction_id: self.event.id,
|
||||
interaction_token: self.event.token.clone(),
|
||||
rt: self.rt.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessage<T> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
rt: Arc<Runtime<T>>,
|
||||
}
|
||||
|
||||
impl<T> OriginalMessage<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn edit(&self) -> OriginalMessageUpdater<T> {
|
||||
OriginalMessageUpdater::new(
|
||||
self.interaction_id,
|
||||
self.interaction_token.clone(),
|
||||
self.rt.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod components;
|
||||
pub mod context;
|
||||
pub mod registry;
|
||||
pub mod runner;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum InteractionError {
|
||||
#[error(
|
||||
"The ID for this interaction did not point to any existing interaction in the registry"
|
||||
)]
|
||||
InteractionNotFound,
|
||||
|
||||
#[error("The interaction was attempted to be deferred, but it failed to be done")]
|
||||
DeferringFailed,
|
||||
|
||||
#[error("Discord responded with an interaction type Payphone does not use")]
|
||||
InteractionTypeUnsent,
|
||||
|
||||
#[error("The original message was attempted to be edited, but it failed")]
|
||||
OriginalMessageEditingFailed,
|
||||
|
||||
#[error("No prefixes were returned for the event")]
|
||||
PrefixesVectorEmpty,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use scc::HashMap;
|
||||
|
||||
use twilight_model::{
|
||||
application::interaction::InteractionData, gateway::payload::incoming::InteractionCreate,
|
||||
};
|
||||
|
||||
use crate::framework::{
|
||||
interactions::{InteractionError, context::InteractionContext},
|
||||
runtime::Runtime,
|
||||
};
|
||||
|
||||
use super::components::Component;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ComponentRegistry<T> {
|
||||
inner: HashMap<String, Arc<dyn Component<T>>>,
|
||||
}
|
||||
|
||||
impl<T> ComponentRegistry<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component<T>>) {
|
||||
self.inner.upsert(id.to_string(), component);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, id: impl ToString) {
|
||||
self.inner.remove(&id.to_string());
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<T>>,
|
||||
) -> Result<(), InteractionError> {
|
||||
if let Some(data) = &event.data {
|
||||
match data {
|
||||
InteractionData::MessageComponent(data) => {
|
||||
if let Some(component) = self.inner.get_async(&data.custom_id).await {
|
||||
let prefixes = rt
|
||||
.get_prefixes(
|
||||
event.guild_id.map(|id| id.get()),
|
||||
event.author_id().map(|id| id.get()),
|
||||
)
|
||||
.await;
|
||||
let prefix = prefixes
|
||||
.first()
|
||||
.ok_or(InteractionError::PrefixesVectorEmpty)?;
|
||||
|
||||
component
|
||||
.execute(InteractionContext::new(
|
||||
event.clone(),
|
||||
rt.clone(),
|
||||
prefix.to_string(),
|
||||
))
|
||||
.await
|
||||
} else {
|
||||
Err(InteractionError::InteractionNotFound)
|
||||
}
|
||||
}
|
||||
_ => Err(InteractionError::InteractionTypeUnsent),
|
||||
}
|
||||
} else {
|
||||
Err(InteractionError::InteractionTypeUnsent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
events::traits::{EventResult, OnInteractionHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InteractionRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl OnInteractionHandler<State> for InteractionRunner {
|
||||
async fn on_interaction(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<State>>,
|
||||
) -> EventResult {
|
||||
Ok(rt.registry.execute(event, rt.clone()).await?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! A framework for building Discord bots, built with Twilight.
|
||||
//!
|
||||
//! Since Twilight is a low-level library, it can be hard to build a bot with it. Payphone's
|
||||
//! framework provides a high-level interface for building Discord bots, with a focus on modularity
|
||||
//! and ease of use. The framework is designed to be extensible, allowing you to add new features
|
||||
//! and commands without modifying the core codebase. It also provides a set of utilities for
|
||||
//! common tasks, such as sending messages, handling events, and managing state.
|
||||
//!
|
||||
//! # Creating a Bot
|
||||
//!
|
||||
//! Creating a bot with Payphone is as simple as creating a new instance of the `Bot` struct and
|
||||
//! running it. The `Bot` struct is the main entry point for the framework and provides methods for
|
||||
//! running the bot, handling events, and managing state.
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::bot::Bot;
|
||||
//! use crate::state::State;
|
||||
//!
|
||||
//! // Your app's state.
|
||||
//! let state = State::new();
|
||||
//!
|
||||
//! // Create a new bot instance with the state and a list of prefixes.
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p."]);
|
||||
//!
|
||||
//! bot.run(&token).await.unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! ### Prefixes
|
||||
//!
|
||||
//! The framework supports multiple prefixes per command and allows you to set them dynamically
|
||||
//! based on server and user IDs.
|
||||
//!
|
||||
//! ```
|
||||
//! async fn get_prefixes(
|
||||
//! rt: &Runtime<State>,
|
||||
//! server_id: Option<u64>,
|
||||
//! user_id: Option<u64>
|
||||
//! ) -> Vec<String> {
|
||||
//! // The runtime contains your state at [`Runtime::state`].
|
||||
//!
|
||||
//! vec!["p.".to_string()]
|
||||
//! }
|
||||
//!
|
||||
//! let bot: Bot<State> = Bot::new(state, get_prefixes);
|
||||
//! ```
|
||||
//!
|
||||
//! Otherwise, you can just pass a static list of prefixes as shown in the previous example.
|
||||
//!
|
||||
//! ```
|
||||
//! // All of these are valid ways to create a bot with a prefix.
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p."]);
|
||||
//! let bot: Bot<State> = Bot::new(state, "p.");
|
||||
//! let bot: Bot<State> = Bot::new(state, "p.".to_string());
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p.".to_string()]);
|
||||
//! ```
|
||||
//!
|
||||
//! Setting no prefix will make the bot raise errors when receiving messages.
|
||||
//!
|
||||
//! ## Adding Commands
|
||||
//!
|
||||
//! Creating a command is quite simple. You just need to implement the `Command` trait and register
|
||||
//! it with the bot.
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::commands::{Command, CommandError, context::CommandContext};
|
||||
//!
|
||||
//! pub struct MyCommand;
|
||||
//!
|
||||
//! #[async_trait::async_trait]
|
||||
//! impl Command<State> for MyCommand {
|
||||
//! fn name(&self) -> &'static str {
|
||||
//! "my_command"
|
||||
//! }
|
||||
//!
|
||||
//! async fn execute(&self, ctx: CommandContext<'_, T>, _args: &str) -> Result<(), CommandError> {
|
||||
//! // Your command logic here.
|
||||
//! ctx.send()
|
||||
//! .content("Hello, world!")
|
||||
//! .await
|
||||
//! .map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Then you just need to register the command with the bot:
|
||||
//!
|
||||
//! ```
|
||||
//! let bot = Bot::new(state, ["p."]).command(MyCommand);
|
||||
//!
|
||||
//! bot.run(&token).await.unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! Log into your Discord account and invite the bot to your server. Then you can test the command
|
||||
//! by sending `p.my_command` in a channel the bot has access to.
|
||||
//!
|
||||
//! ### Configuring Commands Further
|
||||
//!
|
||||
//! Commands can be configured with additional metadata, such as aliases, descriptions, and
|
||||
//! arguments. The following is a full example of a command with all these features:
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::commands::{
|
||||
//! Command, CommandError, context::CommandContext, arguments::NeedyGreedyString
|
||||
//! };
|
||||
//! use crate::state::State;
|
||||
//!
|
||||
//! pub struct HelloCommand;
|
||||
//!
|
||||
//! #[async_trait::async_trait]
|
||||
//! impl Command<State> for HelloCommand {
|
||||
//! fn name(&self) -> &'static str {
|
||||
//! "hello"
|
||||
//! }
|
||||
//!
|
||||
//! /// A list of alternative names for the command.
|
||||
//! fn aliases(&self) -> &'static [&'static str] {
|
||||
//! &["hola", "hey", "hi", "konnichiwa"]
|
||||
//! }
|
||||
//!
|
||||
//! /// A description of the command and its functionality.
|
||||
//! fn description(&self) -> &'static str {
|
||||
//! "This command responds with a greeting."
|
||||
//! }
|
||||
//!
|
||||
//! /// A list of arguments the command accepts. These are solely for documentation purposes,
|
||||
//! /// they won't affect the parsing you do in the command's `execute` method.
|
||||
//! fn arguments(&self) -> &'static [Argument] {
|
||||
//! &[
|
||||
//! Argument {
|
||||
//! name: "name",
|
||||
//! is_optional: false,
|
||||
//! is_greedy: true,
|
||||
//! },
|
||||
//! ]
|
||||
//! }
|
||||
//!
|
||||
//! async fn execute(
|
||||
//! &self,
|
||||
//! ctx: CommandContext<'_, T>,
|
||||
//! args: &str,
|
||||
//! ) -> Result<(), CommandError> {
|
||||
//! // Parse the arguments using the `ParseArguments` trait.
|
||||
//! let name: NeedyGreedyString = ParseArguments::parse(
|
||||
//! args
|
||||
//! )
|
||||
//! .unwrap_or(NeedyGreedyString("???".to_string()));
|
||||
//!
|
||||
//! ctx.send()
|
||||
//! .content(format!("Hello, {}!", name.0))
|
||||
//! .await
|
||||
//! .map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod bot;
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod interactions;
|
||||
pub mod runtime;
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::Client;
|
||||
use twilight_model::oauth::Application;
|
||||
|
||||
use crate::framework::{
|
||||
bot::IntoPrefixes, commands::Command, interactions::registry::ComponentRegistry,
|
||||
};
|
||||
|
||||
pub struct Runtime<T> {
|
||||
pub http: Client,
|
||||
pub app: Application,
|
||||
pub registry: ComponentRegistry<T>,
|
||||
pub prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync + 'static>,
|
||||
pub commands: Vec<Arc<dyn Command<T>>>,
|
||||
pub state: Arc<T>,
|
||||
}
|
||||
|
||||
impl<T> Runtime<T> {
|
||||
pub async fn get_prefixes(&self, server_id: Option<u64>, user_id: Option<u64>) -> Vec<String> {
|
||||
self.prefixes.clone().get(self, server_id, user_id).await
|
||||
}
|
||||
|
||||
pub fn get_command(&self, name: &str) -> Option<Arc<dyn Command<T>>> {
|
||||
self.commands
|
||||
.iter()
|
||||
.find(|cmd| {
|
||||
cmd.identifiers()
|
||||
.contains(&name.to_ascii_lowercase().as_str())
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::commands::{
|
||||
Command, CommandError,
|
||||
arguments::{Argument, NeedyString, ParseArguments},
|
||||
context::CommandContext,
|
||||
},
|
||||
groups::about::{
|
||||
components::{
|
||||
HelpDonateLinkButton, HelpGoBackButton, HelpSupportLinkButton, HelpWebsiteLinkButton,
|
||||
},
|
||||
embeds::{get_all_command_help_embed, get_command_help_embed, get_command_not_found_embed},
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HelpCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"help"
|
||||
}
|
||||
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[Argument {
|
||||
name: "command",
|
||||
is_optional: true,
|
||||
is_greedy: false,
|
||||
}]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Lists all commands and shows information about them individually."
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let command_name: Option<NeedyString> = ParseArguments::parse(args).unwrap();
|
||||
|
||||
if let Some(command_name) = command_name {
|
||||
if let Some(command) = ctx.rt.get_command(&command_name) {
|
||||
ctx.send()
|
||||
.embed(get_command_help_embed(&command, &ctx))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to send message! {e}"))
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_command_not_found_embed(ctx.rt, command_name.0))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_all_command_help_embed(ctx.prefix, ctx.rt))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::interactions::{InteractionError, components::Button, context::InteractionContext},
|
||||
groups::about::embeds::get_all_command_help_embed,
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpGoBackButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpGoBackButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("← Go back to commands list")
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<State>) -> Result<(), InteractionError> {
|
||||
ctx
|
||||
.original_message()
|
||||
.expect(
|
||||
"Buttons always have the original message in the interaction context, this one didn't (got None)"
|
||||
)
|
||||
.edit()
|
||||
.embed(get_all_command_help_embed(&ctx.prefix, ctx.rt))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}"))
|
||||
.map_err(
|
||||
|_| InteractionError::OriginalMessageEditingFailed
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpSupportLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpSupportLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Get help")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://discord.gg/9daSsrfTmE")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpWebsiteLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpWebsiteLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Website")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://payphone.lat/")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpDonateLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpDonateLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Donate")
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://ko-fi.com/payphonedev")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::channel::message::Embed;
|
||||
use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
constants::{COLOR_RED, COLOR_WHITE},
|
||||
discord::{images, sanitizing::SanitizeDiscordMarkdown},
|
||||
framework::{
|
||||
commands::{Command, context::CommandContext},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
utils::ToTitleCase,
|
||||
};
|
||||
|
||||
pub(super) fn get_command_help_embed(
|
||||
command: &Arc<dyn Command<State>>,
|
||||
ctx: &CommandContext<'_, State>,
|
||||
) -> Embed {
|
||||
let name = format!("{}{}", ctx.prefix, command.name());
|
||||
let arguments = command
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let usage = if !arguments.is_empty() {
|
||||
format!("{name} {arguments}")
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(ctx.rt.app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(images::get_avatar_url(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.thumbnail(
|
||||
ImageSource::url(images::get_avatar_url(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.color(COLOR_WHITE)
|
||||
.title(format!("{} Command", command.name().to_title_case()))
|
||||
.description(command.description())
|
||||
.field(EmbedFieldBuilder::new("Usage", format!("`{usage}`")).build())
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"Aliases",
|
||||
if !command.aliases().is_empty() {
|
||||
command
|
||||
.aliases()
|
||||
.iter()
|
||||
.map(|alias| format!("`{}{alias}`", ctx.prefix))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
} else {
|
||||
String::from("*This command doesn't have aliases.*")
|
||||
},
|
||||
))
|
||||
.footer(EmbedFooterBuilder::new(format!(
|
||||
"Use {}help <command> to get help about a specific command.",
|
||||
ctx.prefix
|
||||
)))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_all_command_help_embed<T>(prefix: &str, rt: Arc<Runtime<T>>) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap()))
|
||||
.title("Pick up the phone and start making friends!")
|
||||
.description(format!("Here is the list of all available commands. You can see more details about a specific command with `{prefix}help <command>`."))
|
||||
.thumbnail(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap())
|
||||
.color(COLOR_WHITE)
|
||||
.field(EmbedFieldBuilder::new("Commands", get_commands_table(rt)).build())
|
||||
.footer(EmbedFooterBuilder::new(format!("v{} - Use {}help <command> to get more information about a specific command.", env!("CARGO_PKG_VERSION"), prefix)))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_commands_table<T>(rt: Arc<Runtime<T>>) -> String {
|
||||
let mut table = String::new();
|
||||
|
||||
for command in rt.commands.iter() {
|
||||
table.push_str(&format!("{:<12}", command.name()));
|
||||
}
|
||||
|
||||
format!("```\n{table}\n```")
|
||||
}
|
||||
|
||||
pub(super) fn get_command_not_found_embed<T>(rt: Arc<Runtime<T>>, name: String) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.color(COLOR_RED)
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap()))
|
||||
.title("That's not a command")
|
||||
.description(format!("`{}` is not a valid command name (nor alias). If you put it with the prefix on, take the prefix off before trying again. For example, if you did `p.help p.call`, do `p.help call` instead.\n\nIf you still can't figure it out, press the button below to go back to the full list. Everything's there!", name.sanitize(true)))
|
||||
.thumbnail(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
))
|
||||
.unwrap())
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
pub mod embeds;
|
||||
@@ -0,0 +1,204 @@
|
||||
use async_trait::async_trait;
|
||||
use scc::HashSet;
|
||||
use twilight_model::id::Id;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel},
|
||||
framework::commands::{Command, CommandError, context::CommandContext},
|
||||
state::State,
|
||||
utils::Bye,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct CallCommand {
|
||||
/// A set of channel IDs that are currently searching for a call. This works as a lock for two
|
||||
/// users not to use p.call at the same time in the same channel, as it could create a race
|
||||
/// condition. IDs are removed from this set once the DB is properly updated with the channel's
|
||||
/// state, meaning that not all searching channels will be in this set at all times.
|
||||
channel_lock: HashSet<u64>,
|
||||
}
|
||||
|
||||
impl CallCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channel_lock: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for CallCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"call"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["c"]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let channel = Channel::get_or_create_by_id(
|
||||
ctx.event.channel_id.get(),
|
||||
&ctx.rt.state.db,
|
||||
&ctx.rt.http,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to get or create channel: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
if channel.is_searching || self.channel_lock.contains(&ctx.event.channel_id.get()) {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"☎️ You are already searching for a call! Use `{}hangup` to hang up the phone.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if channel.call_id.is_some() {
|
||||
ctx.send()
|
||||
.content(format!("☎️ You are already on a call! Use `{}hangup` to hang up the phone, or `{}skip` to skip to the next call.", ctx.prefix, ctx.prefix))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.channel_lock
|
||||
.insert_async(ctx.event.channel_id.get())
|
||||
.await
|
||||
.bye();
|
||||
|
||||
let call = Call::start(&channel, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to start call: {e:?}");
|
||||
self.channel_lock.remove(&ctx.event.channel_id.get());
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
self.channel_lock
|
||||
.remove_async(&ctx.event.channel_id.get())
|
||||
.await;
|
||||
|
||||
if let Some(call) = call {
|
||||
ctx.send().content("☎️ Call connected. Say hi!").await.bye();
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ Call connected. Say hi!")
|
||||
.await
|
||||
.bye();
|
||||
} else {
|
||||
ctx.send()
|
||||
.content("☎️ Searching for a call...")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HangupCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HangupCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"hangup"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["h", "hang"]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let Some(channel) = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get channel: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if channel.is_searching {
|
||||
channel
|
||||
.cancel_search(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to cancel search: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
ctx.send()
|
||||
.content("☎️ The phone was hung up.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(call_id) = channel.call_id {
|
||||
let Some(call) = Call::get_by_id(call_id, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
else {
|
||||
ctx.send()
|
||||
.content("❌ Call not found.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Err(CommandError::DatabaseInconsistentState);
|
||||
};
|
||||
|
||||
call.hangup(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
|
||||
ctx.send().content("☎️ You hung up the phone.").await.bye();
|
||||
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ The other party hung up the phone.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id};
|
||||
use twilight_validate::request::webhook_username;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel},
|
||||
discord::images,
|
||||
framework::{
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
groups::calls::moderation::escape_message,
|
||||
state::State,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessageRedirector;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMessageHandler<State> for MessageRedirector {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
|
||||
if prefixes.iter().any(|prefix| {
|
||||
event
|
||||
.content
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&prefix.to_ascii_lowercase())
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get channel from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call = if let Some(call_id) = channel.call_id {
|
||||
if let Some(call) = Call::get_by_id(call_id, &rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get call from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
{
|
||||
if !call.is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call
|
||||
} else {
|
||||
log::error!(
|
||||
"Call ID {} in channel {} does not correspond to an active call.",
|
||||
call_id,
|
||||
channel.id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(opposite_channel) = Channel::get_by_id(
|
||||
call.other_party_channel_id(event.channel_id.get()),
|
||||
&rt.state.db,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to get opposite channel from DB when processing message create event! {e:?}"
|
||||
);
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some((webhook_id, webhook_token)) = opposite_channel
|
||||
.webhook_id
|
||||
.zip(opposite_channel.webhook_token)
|
||||
else {
|
||||
// Handle DM calls in the future.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let profile = get_display_profile(event);
|
||||
|
||||
rt.http
|
||||
.execute_webhook(Id::new(webhook_id), &webhook_token)
|
||||
.allowed_mentions(None)
|
||||
.username(&profile.name)
|
||||
.avatar_url(&profile.avatar_url)
|
||||
.content(&escape_message(event))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to send message to webhook for call {} in channel {}: {e:?}",
|
||||
call.id,
|
||||
opposite_channel.id
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayProfile {
|
||||
pub name: String,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
fn get_display_profile(event: &MessageCreate) -> DisplayProfile {
|
||||
let member = event.member.clone();
|
||||
|
||||
let name = if let Some(nick) = &member.clone().and_then(|member| member.nick) {
|
||||
if webhook_username(nick).is_err() {
|
||||
"Invalid Nickname".to_string()
|
||||
} else {
|
||||
nick.clone()
|
||||
}
|
||||
} else if let Some(name) = &event.author.global_name {
|
||||
if webhook_username(name).is_err() {
|
||||
"Invalid Global Name".to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
}
|
||||
} else if webhook_username(&event.author.name).is_err() {
|
||||
"Invalid Username".to_string()
|
||||
} else {
|
||||
event.author.name.clone()
|
||||
};
|
||||
|
||||
let avatar_hash = member
|
||||
.and_then(|inner| inner.avatar)
|
||||
.or(event.author.avatar);
|
||||
let avatar_url = images::get_avatar_url(
|
||||
avatar_hash,
|
||||
event.author.id.get(),
|
||||
event.author.discriminator as u64,
|
||||
);
|
||||
|
||||
DisplayProfile { name, avatar_url }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
join my server
|
||||
join my srv
|
||||
join my sv
|
||||
join svr
|
||||
join my serv
|
||||
join our server
|
||||
join our sv
|
||||
join our serv
|
||||
join our svr
|
||||
can you join
|
||||
can u join
|
||||
join please
|
||||
join pls
|
||||
@@ -0,0 +1,6 @@
|
||||
scum
|
||||
idiot
|
||||
stupid
|
||||
dumb
|
||||
moron
|
||||
fool
|
||||
@@ -0,0 +1,88 @@
|
||||
sex
|
||||
esex
|
||||
give head
|
||||
gave head
|
||||
give me head
|
||||
giv me head
|
||||
giv head
|
||||
gv head
|
||||
gv me head
|
||||
suck me of
|
||||
suck him of
|
||||
suck your mom
|
||||
suck your mum
|
||||
suck yr mom
|
||||
suck ur mom
|
||||
suck ur mum
|
||||
suck yr mum
|
||||
suck your dad
|
||||
suck your daddy
|
||||
suck yr dad
|
||||
suck ur daddy
|
||||
suck ur dad
|
||||
suck yr daddy
|
||||
suck my dick
|
||||
suck my dih
|
||||
stick it in your dad
|
||||
stick it in ur dad
|
||||
stick it in yr dad
|
||||
stick it in your mom
|
||||
stick it in ur mom
|
||||
stick it in yr mom
|
||||
stick it in your pussy
|
||||
stick it in ur pussy
|
||||
stick it in yr pussy
|
||||
cock
|
||||
dick
|
||||
dih
|
||||
pussy
|
||||
dck
|
||||
psy
|
||||
pu-c
|
||||
eyp
|
||||
emp
|
||||
smd
|
||||
cum
|
||||
cums
|
||||
cuming
|
||||
cumin
|
||||
gang bang
|
||||
squirt
|
||||
squirting
|
||||
squirts
|
||||
bitch
|
||||
bih
|
||||
bich
|
||||
ho
|
||||
hoe
|
||||
huz
|
||||
slut
|
||||
dldo
|
||||
dildo
|
||||
goon
|
||||
gooning
|
||||
i will fuh you
|
||||
i will fuck you
|
||||
i will fuh u
|
||||
i will fuck u
|
||||
nut
|
||||
nuting
|
||||
goonbeam
|
||||
goonbeaming
|
||||
gooncream
|
||||
gooncreaming
|
||||
boty hole
|
||||
suck my pp
|
||||
ass cracker
|
||||
ass crack
|
||||
fuck yourself
|
||||
fuck urself
|
||||
bbc
|
||||
bwc
|
||||
cumy
|
||||
fap
|
||||
faping
|
||||
masturbate
|
||||
masturbating
|
||||
masterbating
|
||||
masterbaiting
|
||||
@@ -0,0 +1,47 @@
|
||||
niga
|
||||
niger
|
||||
nig
|
||||
nga
|
||||
ngr
|
||||
nger
|
||||
qiga
|
||||
niqa
|
||||
nqa
|
||||
nlger
|
||||
digga
|
||||
fag
|
||||
fagot
|
||||
fgot
|
||||
fgt
|
||||
paki
|
||||
retard
|
||||
rtrd
|
||||
tard
|
||||
dumbfuck
|
||||
dumbfck
|
||||
dumbfuk
|
||||
dumbfuh
|
||||
dumbfuhk
|
||||
dmbfuck
|
||||
dmbfck
|
||||
dmbfuk
|
||||
dmbfuh
|
||||
dmbfuhk
|
||||
dumb fuck
|
||||
dumb fck
|
||||
dumb fuk
|
||||
dumb fuh
|
||||
dumb fuhk
|
||||
stfu
|
||||
shut the fuck up
|
||||
syfm
|
||||
shut your fucking mouth
|
||||
sybau
|
||||
shut your bitch as up
|
||||
shut your bih as up
|
||||
gtfo
|
||||
get the fuck out
|
||||
pedo
|
||||
pdf
|
||||
pdfile
|
||||
blackie
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user