Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6843865da3 | ||
|
|
f882ed7fed | ||
|
|
1149dd5bb4 | ||
|
|
54217f1d69 | ||
|
|
8ff7e3af02 | ||
|
|
040b720755 | ||
|
|
91ae88d040 | ||
|
|
31bb63a77a | ||
|
|
2f99b32aea | ||
|
|
d3e192e3c3 | ||
|
|
f98fc37191 | ||
|
|
fcb1a786be | ||
|
|
5fe3878471 | ||
|
|
d53a161716 | ||
|
|
c0b919d33c | ||
|
|
a852cd9844 | ||
|
|
36f15615b6 | ||
|
|
f909a300ad | ||
|
|
da1971060a | ||
|
|
e1c8957fc8 | ||
|
|
43ab107102 | ||
|
|
14f8df2aba | ||
|
|
a67b4f6a3d | ||
|
|
455dc27743 | ||
|
|
d39b06eb12 | ||
|
|
8eaa3f6195 | ||
|
|
f6866e1a11 | ||
|
|
700b770248 | ||
|
|
ac81e3b286 | ||
|
|
323cf7f748 | ||
|
|
281542a5c2 | ||
|
|
77182d9633 | ||
|
|
e472936c51 | ||
|
|
12948dcc15 | ||
|
|
b9410bedd7 | ||
|
|
7eb2a99b69 | ||
|
|
fb7eba11bb | ||
|
|
208e0ddbf5 | ||
|
|
deb76941ea | ||
|
|
25b96cdc2a | ||
|
|
f4f842e3c8 | ||
|
|
6dde25aa72 | ||
|
|
8e0c591b21 | ||
|
|
fe53f2e5c2 | ||
|
|
3c87fdfa69 | ||
|
|
da10b7f194 | ||
|
|
53d61cfbbb | ||
|
|
eb6dcb4604 | ||
|
|
8432cb78fa | ||
|
|
faf9d9d580 | ||
|
|
f4a282447a | ||
|
|
2b4c3d0b8b | ||
|
|
1a446ae3bf | ||
|
|
55431d580e | ||
|
|
09ed3b870e | ||
|
|
634c8eff8c | ||
|
|
8173a508ad | ||
|
|
c284ede5b4 | ||
|
|
7b3fa9905a | ||
|
|
a14ae96a47 | ||
|
|
e35b200d03 | ||
|
|
285f1d4516 | ||
|
|
2030812742 | ||
|
|
e407068a9c | ||
|
|
7270c90b62 | ||
|
|
7119730668 | ||
|
|
fb660c162b | ||
|
|
6ba65eef71 | ||
|
|
d7b8138606 | ||
|
|
66584a8239 | ||
|
|
80ed00f796 | ||
|
|
fd71345cd0 | ||
|
|
7ed2c10448 | ||
|
|
717de3eec5 | ||
|
|
09b875da97 | ||
|
|
0cdbe63c55 | ||
|
|
ed717ef94b | ||
|
|
5806493ca8 | ||
|
|
39c455f617 | ||
|
|
d04af236d2 | ||
|
|
e8a76d5e77 | ||
|
|
91d5bf73c5 | ||
|
|
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 |
+15
-162
@@ -1,166 +1,19 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
/target
|
||||
/.venv
|
||||
/.ansible
|
||||
/.vscode
|
||||
/k8s
|
||||
/playbooks
|
||||
/backup
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.python-version
|
||||
|
||||
# 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
|
||||
ansible.cfg
|
||||
compose.yml
|
||||
inventory.ini
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
# 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/
|
||||
+6
-158
@@ -1,162 +1,10 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
/target
|
||||
/.venv
|
||||
/.ansible
|
||||
/backup
|
||||
|
||||
# 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
|
||||
00-vault-pwd.txt
|
||||
|
||||
# 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/
|
||||
reset-ssh.sh
|
||||
@@ -0,0 +1,38 @@
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- test
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
image: rust:latest
|
||||
script:
|
||||
- rustup component add rustfmt clippy
|
||||
- cargo fmt --check
|
||||
- cargo clippy -- -D warnings
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: rust:latest
|
||||
script:
|
||||
- cargo build --release
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: rust:latest
|
||||
script:
|
||||
- cargo test --release
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"pylint.args": ["--extension-pkg-allow-list=orjson"],
|
||||
"conventionalCommits.scopes": ["shard", "tasks", "kube", "scripts"]
|
||||
"editor.formatOnSave": true,
|
||||
"ansible.python.interpreterPath": "/home/nyeki/Documents/Repositories/payphone/payphone/.venv/bin/python"
|
||||
}
|
||||
|
||||
Generated
+3055
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "payphone"
|
||||
version = "4.0.8"
|
||||
edition = "2024"
|
||||
default-run = "payphone"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.88"
|
||||
axum = "0.8.4"
|
||||
clap = { version = "4.5.42", features = ["derive", "env", "string"] }
|
||||
colored = "3.0.0"
|
||||
csv = "1.3.1"
|
||||
deadpool-redis = "0.22.0"
|
||||
dotenvy = "0.15.7"
|
||||
include_dir = "0.7.4"
|
||||
itertools = "0.14.0"
|
||||
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"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
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"
|
||||
+11
-10
@@ -1,19 +1,20 @@
|
||||
FROM registry.nyeki.dev/docker/python/with-uv:3.12-alpine AS base
|
||||
|
||||
FROM rust:latest AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
|
||||
RUN python3 -m uv sync --no-dev
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
COPY . /app/
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM base AS shard
|
||||
FROM debian:bookworm
|
||||
|
||||
CMD [ "bot.py" ]
|
||||
COPY --from=builder /app/target/release/payphone /usr/local/bin/payphone
|
||||
COPY --from=builder /app/target/release/migrate /usr/local/bin/migrate
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
RUN update-ca-certificates
|
||||
|
||||
FROM base AS tasks
|
||||
|
||||
CMD [ "tasks.py" ]
|
||||
ENTRYPOINT [ "/usr/local/bin/payphone" ]
|
||||
CMD [ "start" ]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[ssh_connection]
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
||||
|
||||
[defaults]
|
||||
timeout = 30
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
[init]
|
||||
p1 ansible_host=p1.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack1
|
||||
|
||||
[master]
|
||||
p2 ansible_host=p2.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack2
|
||||
p3 ansible_host=p3.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack3
|
||||
|
||||
[worker]
|
||||
p4 ansible_host=p4.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack1
|
||||
p5 ansible_host=p5.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack2
|
||||
p6 ansible_host=p6.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack3
|
||||
|
||||
[scylla]
|
||||
p4
|
||||
p5
|
||||
p6
|
||||
-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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
apiVersion: scylla.scylladb.com/v1alpha1
|
||||
kind: NodeConfig
|
||||
metadata:
|
||||
name: scylladb-nodepool-1
|
||||
spec:
|
||||
localDiskSetup:
|
||||
loopDevices:
|
||||
- name: persistent-volumes
|
||||
imagePath: /var/lib/persistent-volumes.img
|
||||
size: 60Gi
|
||||
filesystems:
|
||||
- device: /dev/loops/persistent-volumes
|
||||
type: xfs
|
||||
mounts:
|
||||
- device: /dev/loops/persistent-volumes
|
||||
mountPoint: /var/lib/persistent-volumes
|
||||
unsupportedOptions:
|
||||
- prjquota
|
||||
placement:
|
||||
nodeSelector:
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: scylladb:csi-external-provisioner
|
||||
aggregationRule:
|
||||
clusterRoleSelectors:
|
||||
- matchLabels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
@@ -0,0 +1,102 @@
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:aggregate-to-csi-external-provisioner
|
||||
labels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "persistentvolumes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "delete"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "persistentvolumeclaims"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "update"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "storageclasses"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "events"
|
||||
verbs:
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "update"
|
||||
- "patch"
|
||||
- apiGroups:
|
||||
- "snapshot.storage.k8s.io"
|
||||
resources:
|
||||
- "volumesnapshots"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- apiGroups:
|
||||
- "snapshot.storage.k8s.io"
|
||||
resources:
|
||||
- "volumesnapshotcontents"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "csinodes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "nodes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "csistoragecapacities"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "update"
|
||||
- "patch"
|
||||
- "delete"
|
||||
# The GET permissions below are needed for walking up the ownership chain
|
||||
# for CSIStorageCapacity. They are sufficient for deployment via
|
||||
# StatefulSet (only needs to get Pod) and Deployment (needs to get
|
||||
# Pod and then ReplicaSet to find the Deployment).
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "pods"
|
||||
verbs:
|
||||
- "get"
|
||||
- apiGroups:
|
||||
- "apps"
|
||||
resources:
|
||||
- "replicasets"
|
||||
verbs:
|
||||
- "get"
|
||||
@@ -0,0 +1,15 @@
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:aggregate-to-csi-external-provisioner-openshift
|
||||
labels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
rules:
|
||||
- apiGroups:
|
||||
- security.openshift.io
|
||||
resourceNames:
|
||||
- privileged
|
||||
resources:
|
||||
- securitycontextconstraints
|
||||
verbs:
|
||||
- use
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: local-csi-driver
|
||||
@@ -0,0 +1,8 @@
|
||||
kind: StorageClass
|
||||
apiVersion: storage.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb-local-xfs
|
||||
provisioner: local.csi.scylladb.com
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
parameters:
|
||||
csi.storage.k8s.io/fstype: xfs
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: CSIDriver
|
||||
metadata:
|
||||
name: local.csi.scylladb.com
|
||||
spec:
|
||||
attachRequired: false
|
||||
storageCapacity: true
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
namespace: local-csi-driver
|
||||
name: local-csi-driver
|
||||
@@ -0,0 +1,12 @@
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:csi-external-provisioner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: local-csi-driver
|
||||
namespace: local-csi-driver
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: scylladb:csi-external-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -0,0 +1,125 @@
|
||||
kind: DaemonSet
|
||||
apiVersion: apps/v1
|
||||
metadata:
|
||||
namespace: local-csi-driver
|
||||
name: local-csi-driver
|
||||
labels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
serviceAccountName: local-csi-driver
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
containers:
|
||||
- name: local-csi-driver
|
||||
securityContext:
|
||||
privileged: true
|
||||
image: docker.io/scylladb/local-csi-driver:0.5.0@sha256:46c1d2e9be8edf076d8dea7e82a3bb763d4bdda5adf43c794f617e9c94251079
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- --listen=/csi/csi.sock
|
||||
- --node-name=$(NODE_NAME)
|
||||
- --volumes-dir=/var/lib/persistent-volumes
|
||||
- --v=2
|
||||
env:
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
volumeMounts:
|
||||
- name: kubelet-dir
|
||||
mountPath: /var/lib/kubelet
|
||||
mountPropagation: "Bidirectional"
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: volumes-dir
|
||||
mountPath: /var/lib/persistent-volumes
|
||||
ports:
|
||||
- name: healthz
|
||||
containerPort: 9809
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: healthz
|
||||
initialDelaySeconds: 10
|
||||
timeoutSeconds: 3
|
||||
periodSeconds: 2
|
||||
failureThreshold: 5
|
||||
- name: csi-driver-registrar
|
||||
image: registry.k8s.io/sig-storage/csi-node-driver-registrar@sha256:fdff3ee285341bc58033b6b2458a5d45fd90ec6922a8ba6ebdd49b0c41e2cd34
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --kubelet-registration-path=/var/lib/kubelet/plugins/local.csi.scylladb.com/csi.sock
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: registration-dir
|
||||
mountPath: /registration
|
||||
- name: liveness-probe
|
||||
image: registry.k8s.io/sig-storage/livenessprobe@sha256:cacee2b5c36dd59d4c7e8469c05c9e4ef53ecb2df9025fa8c10cdaf61bce62f0
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --health-port=9809
|
||||
- --v=2
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: csi-provisioner
|
||||
image: registry.k8s.io/sig-storage/csi-provisioner@sha256:ee3b525d5b89db99da3b8eb521d9cd90cb6e9ef0fbb651e98bb37be78d36b5b8
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --v=2
|
||||
- --node-deployment
|
||||
- --feature-gates=Topology=true
|
||||
- --immediate-topology=false
|
||||
- --enable-capacity
|
||||
- --capacity-ownerref-level=0
|
||||
- --capacity-poll-interval=30s
|
||||
- --default-fstype=xfs
|
||||
env:
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
volumes:
|
||||
- name: kubelet-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet
|
||||
type: Directory
|
||||
- name: plugin-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins/local.csi.scylladb.com/
|
||||
type: DirectoryOrCreate
|
||||
- name: registration-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins_registry/
|
||||
type: Directory
|
||||
- name: volumes-dir
|
||||
hostPath:
|
||||
path: /var/lib/persistent-volumes
|
||||
type: Directory
|
||||
@@ -0,0 +1,117 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: scylladb-config
|
||||
data:
|
||||
scylla.yaml: |
|
||||
authenticator: PasswordAuthenticator
|
||||
authorizer: CassandraAuthorizer
|
||||
---
|
||||
apiVersion: scylla.scylladb.com/v1
|
||||
kind: ScyllaCluster
|
||||
metadata:
|
||||
name: scylladb
|
||||
spec:
|
||||
repository: docker.io/scylladb/scylla
|
||||
version: 2025.1.5
|
||||
agentVersion: 3.5.1
|
||||
developerMode: false
|
||||
automaticOrphanedNodeCleanup: true
|
||||
sysctls:
|
||||
- fs.aio-max-nr=30000000
|
||||
datacenter:
|
||||
name: ge-dc1
|
||||
racks:
|
||||
- name: ge-dc1-rack1
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack1
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
- name: ge-dc1-rack2
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack2
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
- name: ge-dc1-rack3
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack3
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
@@ -0,0 +1,167 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: scylla-manager-config
|
||||
namespace: scylla-manager
|
||||
data:
|
||||
scylla-manager.yaml: |-
|
||||
http: :5080
|
||||
logger:
|
||||
level: info
|
||||
database:
|
||||
hosts:
|
||||
- scylla-manager-cluster-manager-dc-manager-rack-0
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
namespace: scylla-manager
|
||||
name: scylla-manager-to-scylla-pod
|
||||
spec:
|
||||
policyTypes:
|
||||
- Ingress
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/managed-by: scylla-operator
|
||||
app.kubernetes.io/name: scylla
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
spec:
|
||||
ports:
|
||||
- name: api
|
||||
port: 80
|
||||
protocol: TCP
|
||||
targetPort: 5080
|
||||
- name: metrics
|
||||
port: 5090
|
||||
protocol: TCP
|
||||
targetPort: 5090
|
||||
selector:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
spec:
|
||||
serviceAccountName: scylla-manager
|
||||
containers:
|
||||
- name: scylla-manager
|
||||
image: docker.io/scylladb/scylla-manager:3.5.1@sha256:6986ecfc8c925c3d59b65bbcb9763d62f7591a00bb30242842aada115929e816
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /usr/bin/scylla-manager
|
||||
args:
|
||||
- --config-file=/mnt/etc/scylla-manager/scylla-manager.yaml
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 20Mi
|
||||
volumeMounts:
|
||||
- mountPath: /mnt/etc/scylla-manager
|
||||
name: scylla-manager-config
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/clusters
|
||||
port: 5080
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
volumes:
|
||||
- configMap:
|
||||
name: scylla-manager-config
|
||||
name: scylla-manager-config
|
||||
|
||||
---
|
||||
apiVersion: scylla.scylladb.com/v1
|
||||
kind: ScyllaCluster
|
||||
metadata:
|
||||
name: scylla-manager-cluster
|
||||
namespace: scylla-manager
|
||||
spec:
|
||||
version: 2025.1.5
|
||||
agentVersion: 3.5.1@sha256:d1b57d08b9949c8faad2048fdf4dc7c502dae81da856c3c6b3a77dd347d5c7fc
|
||||
repository: docker.io/scylladb/scylla
|
||||
agentRepository: docker.io/scylladb/scylla-manager-agent
|
||||
developerMode: true
|
||||
cpuset: true
|
||||
sysctls:
|
||||
- fs.aio-max-nr=30000000
|
||||
datacenter:
|
||||
name: manager-dc
|
||||
racks:
|
||||
- members: 1
|
||||
name: manager-rack
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 200Mi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 200Mi
|
||||
storage:
|
||||
capacity: 5Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
---
|
||||
@@ -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)
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
uv run ansible-playbook -i inventory.ini playbooks/01-setup.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/02-copy-k8s-files.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/03-cert-manager.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/04-prometheus-operator.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/05-scylla-operator.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/06-scylla-cluster.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/07-scylla-manager.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/08-payphone.yml --vault-password-file playbooks/00-vault-pwd.txt
|
||||
@@ -0,0 +1,14 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
31353838363564633532353539313763643561396661313030346466653839663931323961663362
|
||||
6333376338336336346562643136316439393962356236640a653836643066346564663363633364
|
||||
30616634363430646335663339613333616334393037333065336664396338346135353239346261
|
||||
3366303231616166300a613566643231303066373266646665343637353364303939346464323633
|
||||
37623565643063353435376238326564353563653735393164616136373531356135666337353765
|
||||
39326463633965383463353335306432326433313132373461633666343934616638346666306339
|
||||
36313561363965663662313163663362333466663135363731643737386466643134396564353439
|
||||
32313936316432313433336235643337386430633434363764666439386234323933313465306638
|
||||
65396532396533303662326338376661343962613432393735356130613165626465656464343434
|
||||
63646265333064623735646161656236623366623265626664313564316665396331616631613536
|
||||
38646264656563353934383237626534663563666335666430326562303862616461343935393737
|
||||
62343637336563383864383732303861633634303061376666353231323331643538333633643762
|
||||
35623539303562643763336232353633666661626539356465383631336261363532
|
||||
@@ -0,0 +1,186 @@
|
||||
- name: Install K3s and Apply Security Configurations on All Nodes
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Install curl
|
||||
ansible.builtin.apt:
|
||||
name: curl
|
||||
state: present
|
||||
when: ansible_os_family == "Debian"
|
||||
|
||||
- name: Download k3s install script
|
||||
ansible.builtin.get_url:
|
||||
url: https://get.k3s.io
|
||||
dest: /tmp/k3s_install.sh
|
||||
mode: '0755'
|
||||
|
||||
- name: Install Python Kubernetes client
|
||||
ansible.builtin.apt:
|
||||
name: python3-kubernetes
|
||||
state: present
|
||||
|
||||
- name: Disable UFW
|
||||
ansible.builtin.command: ufw disable
|
||||
changed_when: false
|
||||
|
||||
- name: Disable password authentication for SSH
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^PasswordAuthentication'
|
||||
line: 'PasswordAuthentication no'
|
||||
state: present
|
||||
backup: true
|
||||
|
||||
- name: Disable root password login
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^PermitRootLogin'
|
||||
line: 'PermitRootLogin prohibit-password'
|
||||
state: present
|
||||
backup: true
|
||||
|
||||
- name: Restart SSH to apply config
|
||||
ansible.builtin.service:
|
||||
name: ssh
|
||||
state: restarted
|
||||
|
||||
- name: Install fail2ban
|
||||
ansible.builtin.apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Configure fail2ban for SSH
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/fail2ban/jail.local
|
||||
create: true
|
||||
block: |
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
mode: '0644'
|
||||
|
||||
- name: Restart fail2ban
|
||||
ansible.builtin.service:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
|
||||
- name: Set up K3s on Init Node
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="server --tls-san {{ ansible_host }} --flannel-backend=wireguard-native --cluster-init" \
|
||||
sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure K3s Service is Started and Enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Get Joining Token
|
||||
ansible.builtin.command: cat /var/lib/rancher/k3s/server/node-token
|
||||
register: k3s_token
|
||||
run_once: true
|
||||
tags: skip_ansible_lint
|
||||
changed_when: false
|
||||
|
||||
- name: Set up K3s on Master Nodes
|
||||
hosts: master
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="server --flannel-backend=wireguard-native --tls-san {{ ansible_host }}" \
|
||||
K3S_URL="https://{{ hostvars[groups['init'][0]].ansible_host }}:6443" \
|
||||
K3S_TOKEN="{{ hostvars[groups['init'][0]].k3s_token.stdout }}" sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure k3s service is started and enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Set up K3s on Worker Nodes
|
||||
hosts: worker
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="agent" K3S_URL="https://{{ hostvars[groups['init'][0]].ansible_host }}:6443" \
|
||||
K3S_TOKEN="{{ hostvars[groups['init'][0]].k3s_token.stdout }}" sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure k3s service is started and enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s-agent
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Wait for All K3s Nodes to Become Ready
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Wait until all nodes report Ready
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for=condition=Ready node --all --timeout=10m
|
||||
register: wait_nodes
|
||||
retries: 5
|
||||
delay: 30
|
||||
until: wait_nodes.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: Set Up Node Labels and Taints
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Set Rack Tags to All Nodes
|
||||
loop: "{{ groups['all'] }}"
|
||||
kubernetes.core.k8s:
|
||||
api_version: v1
|
||||
kind: Node
|
||||
name: "{{ item }}"
|
||||
definition:
|
||||
metadata:
|
||||
labels:
|
||||
topology.kubernetes.io/zone: "{{ hostvars[item].rack }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
state: present
|
||||
|
||||
- name: Set Scylla Tags to Scylla Nodes
|
||||
loop: "{{ groups['scylla'] }}"
|
||||
kubernetes.core.k8s:
|
||||
api_version: v1
|
||||
kind: Node
|
||||
name: "{{ item }}"
|
||||
definition:
|
||||
metadata:
|
||||
labels:
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
spec:
|
||||
taints:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
state: present
|
||||
|
||||
- name: Create Containerd Socket Symlink
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Create Containerd Symlink
|
||||
ansible.builtin.command: |
|
||||
ln -s /run/k3s/containerd/containerd.sock /run/containerd/containerd.sock
|
||||
args:
|
||||
creates: /run/containerd/containerd.sock
|
||||
@@ -0,0 +1,19 @@
|
||||
- name: Copy K8s files to node
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars:
|
||||
k8s_files_src: "{{ playbook_dir }}/../k8s"
|
||||
k8s_files_dest: /tmp/k8s
|
||||
tasks:
|
||||
- name: Create destination directory for K8s files
|
||||
ansible.builtin.file:
|
||||
path: "{{ k8s_files_dest }}"
|
||||
state: directory
|
||||
mode: '0644'
|
||||
|
||||
- name: Copy K8s files to destination
|
||||
ansible.posix.synchronize:
|
||||
src: "{{ k8s_files_src }}/"
|
||||
dest: "{{ k8s_files_dest }}/"
|
||||
recursive: true
|
||||
@@ -0,0 +1,46 @@
|
||||
- name: Set up Cert-Manager
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
become: true
|
||||
vars:
|
||||
cert_manager_ns: cert-manager
|
||||
cert_manager_crds:
|
||||
- certificates.cert-manager.io
|
||||
- issuers.cert-manager.io
|
||||
cert_manager_deployments:
|
||||
- cert-manager
|
||||
- cert-manager-cainjector
|
||||
- cert-manager-webhook
|
||||
tasks:
|
||||
- name: Apply Cert-Manager YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/01-cert-manager.yml"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Cert-Manager CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for condition=established --timeout=60s crd/{{ cert_manager_crds | join(' crd/') }}
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Cert-Manager deployments
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n {{ cert_manager_ns }} rollout status --timeout=10m deployment.apps/{{ item }}
|
||||
loop: "{{ cert_manager_deployments }}"
|
||||
loop_control:
|
||||
loop_var: item
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Cert-Manager webhook CA secret
|
||||
ansible.builtin.shell: |
|
||||
for i in {1..30}; do
|
||||
kubectl -n {{ cert_manager_ns }} get secret/cert-manager-webhook-ca && break
|
||||
sleep 1
|
||||
done
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
@@ -0,0 +1,26 @@
|
||||
- name: Set up Prometheus operator
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
become: true
|
||||
vars:
|
||||
prometheus_crds:
|
||||
- prometheuses.monitoring.coreos.com
|
||||
- prometheusrules.monitoring.coreos.com
|
||||
- servicemonitors.monitoring.coreos.com
|
||||
tasks:
|
||||
- name: Apply Prometheus operator server-side
|
||||
ansible.builtin.command: |
|
||||
kubectl apply --server-side -f /tmp/k8s/scylla/02-prometheus-operator.yml -n prometheus-operator
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Prometheus operator CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for='condition=established' crd/{{ prometheus_crds | join(' crd/') }}
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Prometheus operator deployment
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n=prometheus-operator rollout status --timeout=10m deployment.apps/prometheus-operator
|
||||
changed_when: false
|
||||
@@ -0,0 +1,67 @@
|
||||
- name: Set up Scylla Operator, NodeConfig, and the Local CSI Driver
|
||||
hosts: init
|
||||
become: true
|
||||
vars:
|
||||
scylla_crds:
|
||||
- scyllaclusters.scylla.scylladb.com
|
||||
- nodeconfigs.scylla.scylladb.com
|
||||
- scyllaoperatorconfigs.scylla.scylladb.com
|
||||
- scylladbmonitorings.scylla.scylladb.com
|
||||
scylla_deployments:
|
||||
- scylla-operator
|
||||
- webhook-server
|
||||
tasks:
|
||||
- name: Apply Scylla Operator YAML
|
||||
ansible.builtin.command: |
|
||||
kubectl apply --server-side -f /tmp/k8s/scylla/03-scylla-operator.yml -n scylla-operator
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Scylla Operator CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for='condition=established' crd/{{ scylla_crds | join(' crd/') }}
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Scylla Operator Deployments
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n scylla-operator rollout status --timeout=10m deployment.apps/{{ item }}
|
||||
loop: "{{ scylla_deployments }}"
|
||||
loop_control:
|
||||
loop_var: item
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Apply Scylla NodeConfig YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/04-scylla-node-config.yml"
|
||||
namespace: scylla-operator
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla NodeConfig to be applied across all nodes
|
||||
ansible.builtin.command: |
|
||||
kubectl wait --for='condition=Reconciled' --timeout=10m nodeconfigs.scylla.scylladb.com/scylladb-nodepool-1
|
||||
changed_when: false
|
||||
|
||||
- name: Find Scylla CSI Driver K8s YAMLs on Remote Host
|
||||
ansible.builtin.find:
|
||||
paths: /tmp/k8s/scylla
|
||||
patterns: "05-*.yml"
|
||||
file_type: file
|
||||
register: scylla_csi_driver_files
|
||||
|
||||
- name: Apply all Scylla CSI Driver K8s YAMLs
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "{{ item.path }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
loop: "{{ scylla_csi_driver_files.files | sort(attribute='path') }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
- name: Wait for Local CSI Driver DaemonSet
|
||||
ansible.builtin.command: |
|
||||
kubectl -n=local-csi-driver rollout status --timeout=10m daemonset.apps/local-csi-driver
|
||||
changed_when: false
|
||||
@@ -0,0 +1,35 @@
|
||||
- name: Set Up Scylla Cluster
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Create Scylla Namespace
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: scylla
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Apply Scylla Cluster Config
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: /tmp/k8s/scylla/06-scylla-cluster.yml
|
||||
namespace: scylla
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla Cluster Application
|
||||
ansible.builtin.command: |
|
||||
kubectl -n scylla rollout status statefulset/{{ item }} --timeout=10m
|
||||
loop:
|
||||
- scylladb-ge-dc1-ge-dc1-rack1
|
||||
- scylladb-ge-dc1-ge-dc1-rack2
|
||||
- scylladb-ge-dc1-ge-dc1-rack3
|
||||
register: rollout
|
||||
retries: 30
|
||||
delay: 10
|
||||
until: rollout.rc == 0
|
||||
ignore_errors: true
|
||||
changed_when: false
|
||||
@@ -0,0 +1,14 @@
|
||||
- name: Set Up Scylla Manager
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Apply Scylla Manager YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/07-scylla-manager.yml"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla Manager Deployment
|
||||
ansible.builtin.command: |
|
||||
kubectl -n=scylla-manager rollout status --timeout=10m deployment.apps/scylla-manager
|
||||
changed_when: false
|
||||
@@ -0,0 +1,125 @@
|
||||
- name: Build and Push Payphone
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Get Cargo Project Metadata
|
||||
ansible.builtin.command: cargo metadata --no-deps --format-version 1
|
||||
register: cargo_metadata
|
||||
changed_when: false
|
||||
|
||||
- name: Parse version
|
||||
ansible.builtin.set_fact:
|
||||
app_version: "v{{ (cargo_metadata.stdout | from_json).packages[0].version }}"
|
||||
|
||||
- name: Build and Push the Docker image
|
||||
community.docker.docker_image:
|
||||
source: build
|
||||
build:
|
||||
path: "{{ playbook_dir }}/../"
|
||||
name: registry.nyeki.dev/payphone/payphone
|
||||
tag: "{{ app_version }}"
|
||||
push: true
|
||||
|
||||
- name: Deploy Payphone
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- 00-vault.yml
|
||||
tasks:
|
||||
- name: Create Payphone Namespace
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: payphone
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Create Bot Token Secret
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
namespace: payphone
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: payphone-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
PAYPHONE_TOKEN: "{{ bot_token }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Set Git credentials
|
||||
ansible.builtin.shell: >
|
||||
kubectl create secret docker-registry payphone-git-credentials \
|
||||
--docker-server=registry.nyeki.dev \
|
||||
--docker-username={{ gitlab_username }} \
|
||||
--docker-password={{ gitlab_pat }} \
|
||||
--docker-email={{ gitlab_email }} \
|
||||
-n payphone \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Apply Payphone Deployment
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
namespace: payphone
|
||||
definition:
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: payphone
|
||||
labels:
|
||||
app: payphone
|
||||
spec:
|
||||
serviceName: payphone
|
||||
replicas: "{{ bot_shards }}"
|
||||
selector:
|
||||
matchLabels:
|
||||
app: payphone
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: payphone
|
||||
spec:
|
||||
containers:
|
||||
- name: payphone
|
||||
image: registry.nyeki.dev/payphone/payphone:{{ hostvars['localhost'].app_version }}
|
||||
imagePullPolicy: "Always"
|
||||
args: [
|
||||
"start",
|
||||
"--shard",
|
||||
"$(SHARD_ID):$(SHARD_COUNT)",
|
||||
"--database-addr",
|
||||
"scylladb-client.scylla.svc.cluster.local",
|
||||
"--database-username",
|
||||
"cassandra",
|
||||
"--database-password",
|
||||
"cassandra"
|
||||
]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: payphone-secrets
|
||||
env:
|
||||
- name: SHARD_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
|
||||
- name: SHARD_COUNT
|
||||
value: "{{ bot_shards | string }}"
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "100Mi"
|
||||
imagePullSecrets:
|
||||
- name: payphone-git-credentials
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Rollout New Version
|
||||
ansible.builtin.command:
|
||||
kubectl set image -n payphone sts/payphone payphone=registry.nyeki.dev/payphone/payphone:{{ hostvars['localhost'].app_version }}
|
||||
changed_when: false
|
||||
+6
-10
@@ -1,15 +1,11 @@
|
||||
[project]
|
||||
name = "payphone"
|
||||
version = "0.115.0"
|
||||
version = "0.1.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 = "."
|
||||
dependencies = [
|
||||
"ansible>=11.9.0",
|
||||
"ansible-lint>=25.8.1",
|
||||
"requests>=2.32.4",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,432 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use clap::Parser;
|
||||
use payphone::{
|
||||
database::{
|
||||
self,
|
||||
models::{
|
||||
MessageData, Report as PayphoneReport, ReportResolution, Server as PayphoneServer,
|
||||
UserIncident, UserIncidentType,
|
||||
},
|
||||
},
|
||||
utils::{Bye, get_random_u64, u64_to_i64},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::{sync::Semaphore, time::Instant};
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
dir: String,
|
||||
|
||||
#[arg(long, default_value_t = String::from("127.0.0.1"))]
|
||||
database_addr: String,
|
||||
|
||||
#[arg(long)]
|
||||
database_username: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
database_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Report {
|
||||
id: u64,
|
||||
message_id: u64,
|
||||
message_content: String,
|
||||
channel_id: u64,
|
||||
user_id: u64,
|
||||
created_at: String,
|
||||
resolved: String,
|
||||
resolved_at: Option<String>,
|
||||
resolved_by_id: Option<u64>,
|
||||
user_avatar_url: String,
|
||||
user_name: String,
|
||||
reply_user_name: Option<String>,
|
||||
reply_message_content: Option<String>,
|
||||
reply_user_avatar_url: Option<String>,
|
||||
reply_user_id: Option<u64>,
|
||||
reported_by_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct User {
|
||||
id: u64,
|
||||
is_masked: String,
|
||||
is_moderator: String,
|
||||
is_supporter_until: String,
|
||||
warns_count: u64,
|
||||
bans_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Ban {
|
||||
id: u64,
|
||||
user_id: u64,
|
||||
created_at: String,
|
||||
expires_at: Option<String>,
|
||||
created_by_id: u64,
|
||||
expired_by_id: Option<u64>,
|
||||
report_id: Option<u64>,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Server {
|
||||
id: u64,
|
||||
prefix: String,
|
||||
language: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Warn {
|
||||
id: u64,
|
||||
user_id: u64,
|
||||
reason: String,
|
||||
created_at: String,
|
||||
created_by_id: u64,
|
||||
report_id: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
println!("Testing DB connection...");
|
||||
|
||||
let db = Arc::new(
|
||||
database::init(
|
||||
args.database_addr,
|
||||
args.database_username,
|
||||
args.database_password,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to initialize database"),
|
||||
);
|
||||
|
||||
println!("Reading migration file {}/reports.csv", args.dir);
|
||||
|
||||
let mut rdr = csv::Reader::from_path(format!("{}/reports.csv", args.dir))
|
||||
.expect("Failed to open reports file");
|
||||
|
||||
let mut reports = Vec::new();
|
||||
|
||||
let format = time::format_description::parse(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second].[subsecond][offset_hour]",
|
||||
)
|
||||
.expect("Failed to parse format description");
|
||||
|
||||
for result in rdr.deserialize() {
|
||||
let report: Report = result.expect("Failed to deserialize report");
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
println!("Finished reading reports, found {} reports", reports.len());
|
||||
println!("Reading migration file {}/users.csv", args.dir);
|
||||
|
||||
let mut rdr = csv::Reader::from_path(format!("{}/users.csv", args.dir))
|
||||
.expect("Failed to open users file");
|
||||
|
||||
let mut users = HashMap::new();
|
||||
|
||||
for result in rdr.deserialize() {
|
||||
let user: User = result.expect("Failed to deserialize user");
|
||||
users.insert(user.id, user).bye();
|
||||
}
|
||||
|
||||
println!("Finished reading users, found {} users", users.len());
|
||||
println!("Reading migration file {}/bans.csv", args.dir);
|
||||
|
||||
let mut rdr =
|
||||
csv::Reader::from_path(format!("{}/bans.csv", args.dir)).expect("Failed to open bans file");
|
||||
|
||||
let mut bans = HashMap::new();
|
||||
|
||||
for result in rdr.deserialize() {
|
||||
let ban: Ban = result.expect("Failed to deserialize ban");
|
||||
bans.insert(ban.id, ban).bye();
|
||||
}
|
||||
|
||||
println!("Finished reading bans, found {} bans", bans.len());
|
||||
println!("Reading migration file {}/servers.csv", args.dir);
|
||||
|
||||
let mut rdr = csv::Reader::from_path(format!("{}/servers.csv", args.dir))
|
||||
.expect("Failed to open servers file");
|
||||
|
||||
let mut servers = HashMap::new();
|
||||
|
||||
for result in rdr.deserialize() {
|
||||
let server: Server = result.expect("Failed to deserialize server");
|
||||
servers.insert(server.id, server).bye();
|
||||
}
|
||||
|
||||
println!("Finished reading servers, found {} servers", servers.len());
|
||||
println!("Reading migration file {}/warns.csv", args.dir);
|
||||
|
||||
let mut rdr = csv::Reader::from_path(format!("{}/warns.csv", args.dir))
|
||||
.expect("Failed to open warns file");
|
||||
|
||||
let mut warns = HashMap::new();
|
||||
|
||||
for result in rdr.deserialize() {
|
||||
let warn: Warn = result.expect("Failed to deserialize warn");
|
||||
warns.insert(warn.id, warn).bye();
|
||||
}
|
||||
|
||||
println!("Finished reading warns, found {} warns", warns.len());
|
||||
println!("Starting migration...");
|
||||
println!("Inserting server configurations...");
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(50));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for (i, server) in servers.values().enumerate() {
|
||||
let payphone_server = PayphoneServer {
|
||||
id: server.id,
|
||||
prefix: server.prefix.clone(),
|
||||
language: server.language.clone(),
|
||||
name: server.name.clone(),
|
||||
};
|
||||
|
||||
let task_db = db.clone();
|
||||
let task_semaphore = semaphore.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = task_semaphore.acquire().await;
|
||||
|
||||
let start = Instant::now();
|
||||
payphone_server
|
||||
.insert(&task_db)
|
||||
.await
|
||||
.expect("Failed to insert server");
|
||||
|
||||
let duration = start.elapsed();
|
||||
println!(
|
||||
"({i}) Inserted server {} in {:?}",
|
||||
payphone_server.id, duration
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
let bans = Arc::new(bans);
|
||||
let warns = Arc::new(warns);
|
||||
let reports = Arc::new(reports);
|
||||
|
||||
for (i, report) in reports.iter().enumerate() {
|
||||
let task_db = db.clone();
|
||||
let task_semaphore = semaphore.clone();
|
||||
let task_report = report.clone();
|
||||
let task_bans = bans.clone();
|
||||
let task_warns = warns.clone();
|
||||
let task_format = format.clone();
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = task_semaphore.acquire().await;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let resolution = if task_report.resolved == "t" {
|
||||
if task_bans.iter().any(|(_id, ban)| {
|
||||
ban.report_id
|
||||
.map(|report_id| task_report.id == report_id)
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
Some(ReportResolution::Ban)
|
||||
} else if task_warns
|
||||
.iter()
|
||||
.any(|(_id, warn)| warn.report_id == task_report.id)
|
||||
{
|
||||
Some(ReportResolution::Warn)
|
||||
} else {
|
||||
Some(ReportResolution::Skip)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let payphone_report = PayphoneReport {
|
||||
id: task_report.id,
|
||||
user_id: task_report.user_id,
|
||||
message: MessageData {
|
||||
id: u64_to_i64(task_report.message_id),
|
||||
call_id: 0,
|
||||
author_id: u64_to_i64(task_report.user_id),
|
||||
author_name: task_report.user_name,
|
||||
author_avatar_url: task_report.user_avatar_url,
|
||||
author_mask: None,
|
||||
content: task_report.message_content,
|
||||
attachments: vec![],
|
||||
sticker: None,
|
||||
propagation_message_id: None,
|
||||
propagation_channel_id: None,
|
||||
replying_to_message_id: None,
|
||||
is_deleted: false,
|
||||
is_edited: false,
|
||||
is_censored: false,
|
||||
},
|
||||
created_at: OffsetDateTime::parse(&task_report.created_at, &task_format)
|
||||
.expect("Failed to parse created_at")
|
||||
.to_utc(),
|
||||
created_by_ids: task_report
|
||||
.reported_by_id
|
||||
.map(|id| vec![id])
|
||||
.unwrap_or_default(),
|
||||
replying_to: None,
|
||||
reviewed_at: task_report.resolved_at.as_ref().map(|s| {
|
||||
OffsetDateTime::parse(s, &task_format)
|
||||
.expect("Failed to parse resolved_at")
|
||||
.to_utc()
|
||||
}),
|
||||
resolution,
|
||||
reviewed_by_id: task_report.resolved_by_id,
|
||||
};
|
||||
|
||||
payphone_report
|
||||
.insert(&task_db)
|
||||
.await
|
||||
.expect("Failed to insert report");
|
||||
|
||||
let duration = start.elapsed();
|
||||
println!(
|
||||
"({i}) Inserted report {} in {:?}",
|
||||
payphone_report.id, duration
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
for (i, ban) in bans.values().enumerate() {
|
||||
let task_semaphore = semaphore.clone();
|
||||
let task_format = format.clone();
|
||||
let task_db = db.clone();
|
||||
let task_reports = reports.clone();
|
||||
let task_ban = Arc::new(ban.clone());
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = task_semaphore.acquire().await;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let message = task_reports
|
||||
.iter()
|
||||
.find(|report| {
|
||||
task_ban
|
||||
.report_id
|
||||
.map(|id| id == report.id)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|report| MessageData {
|
||||
id: u64_to_i64(report.message_id),
|
||||
call_id: 0,
|
||||
author_id: u64_to_i64(report.user_id),
|
||||
author_name: report.user_name.clone(),
|
||||
author_avatar_url: report.user_avatar_url.clone(),
|
||||
author_mask: None,
|
||||
content: report.message_content.clone(),
|
||||
attachments: vec![],
|
||||
sticker: None,
|
||||
propagation_message_id: None,
|
||||
propagation_channel_id: None,
|
||||
replying_to_message_id: None,
|
||||
is_deleted: false,
|
||||
is_edited: false,
|
||||
is_censored: false,
|
||||
});
|
||||
|
||||
let payphone_incident = UserIncident {
|
||||
incident_id: get_random_u64(),
|
||||
user_id: task_ban.user_id,
|
||||
report_id: task_ban.report_id,
|
||||
r#type: UserIncidentType::Ban,
|
||||
message,
|
||||
reason: task_ban.reason.clone(),
|
||||
created_at: OffsetDateTime::parse(&task_ban.created_at, &task_format)
|
||||
.expect("Failed to parse created_at")
|
||||
.to_utc(),
|
||||
created_by_id: Some(task_ban.created_by_id),
|
||||
duration: None,
|
||||
expires_at: task_ban.expires_at.as_ref().map(|expires_at| {
|
||||
OffsetDateTime::parse(expires_at, &task_format)
|
||||
.expect("Failed to parse expires_at")
|
||||
.to_utc()
|
||||
}),
|
||||
};
|
||||
|
||||
payphone_incident
|
||||
.insert(&task_db)
|
||||
.await
|
||||
.expect("Failed to insert ban");
|
||||
|
||||
let duration = start.elapsed();
|
||||
println!(
|
||||
"({i}) Inserted incident (ban) {} in {:?}",
|
||||
payphone_incident.incident_id, duration
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
for (i, warn) in warns.values().enumerate() {
|
||||
let task_semaphore = semaphore.clone();
|
||||
let task_format = format.clone();
|
||||
let task_db = db.clone();
|
||||
let task_reports = reports.clone();
|
||||
let task_warn = Arc::new(warn.clone());
|
||||
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let _permit = task_semaphore.acquire().await;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let message = task_reports
|
||||
.iter()
|
||||
.find(|report| report.id == task_warn.report_id)
|
||||
.map(|report| MessageData {
|
||||
id: u64_to_i64(report.message_id),
|
||||
call_id: 0,
|
||||
author_id: u64_to_i64(report.user_id),
|
||||
author_name: report.user_name.clone(),
|
||||
author_avatar_url: report.user_avatar_url.clone(),
|
||||
author_mask: None,
|
||||
content: report.message_content.clone(),
|
||||
attachments: vec![],
|
||||
sticker: None,
|
||||
propagation_message_id: None,
|
||||
propagation_channel_id: None,
|
||||
replying_to_message_id: None,
|
||||
is_deleted: false,
|
||||
is_edited: false,
|
||||
is_censored: false,
|
||||
});
|
||||
|
||||
let payphone_incident = UserIncident {
|
||||
incident_id: get_random_u64(),
|
||||
user_id: task_warn.user_id,
|
||||
report_id: Some(task_warn.report_id),
|
||||
r#type: UserIncidentType::Warning,
|
||||
message,
|
||||
reason: task_warn.reason.clone(),
|
||||
created_at: OffsetDateTime::parse(&task_warn.created_at, &task_format)
|
||||
.expect("Failed to parse created_at")
|
||||
.to_utc(),
|
||||
created_by_id: Some(task_warn.created_by_id),
|
||||
duration: None,
|
||||
expires_at: None,
|
||||
};
|
||||
|
||||
payphone_incident
|
||||
.insert(&task_db)
|
||||
.await
|
||||
.expect("Failed to insert warn");
|
||||
|
||||
let duration = start.elapsed();
|
||||
println!(
|
||||
"({i}) Inserted incident (warn) {} in {:?}",
|
||||
payphone_incident.incident_id, duration
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
task.await.expect("Failed to join task");
|
||||
}
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
//! 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]
|
||||
//! ```
|
||||
//!
|
||||
//! To run multiple shards, pass the `--shard` or `-s` argument to the `start` command with the
|
||||
//! shard ID and total amount of shards as `id:total`. For example, for a single-shard bot, you'd
|
||||
//! set it to `-s 0:1`. ID is zero-indexed (0 means first shard), total is one-indexed (0 means no
|
||||
//! shards).
|
||||
//!
|
||||
//! # 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::{fmt, net::SocketAddr, str::FromStr};
|
||||
|
||||
use twilight_gateway::ShardId;
|
||||
|
||||
/// 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(Debug, Clone)]
|
||||
pub struct Shard {
|
||||
pub id: u32,
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
impl Shard {
|
||||
fn new(id: u32, total: u32) -> Self {
|
||||
Shard { id, total }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Shard {
|
||||
fn default() -> Self {
|
||||
Shard::new(0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Shard {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.split_once(":") {
|
||||
Some((id, total)) => {
|
||||
let id = id.parse().map_err(|_| "Invalid shard ID")?;
|
||||
let total = total.parse().map_err(|_| "Invalid shard total")?;
|
||||
|
||||
Ok(Shard { id, total })
|
||||
}
|
||||
None => Err("Invalid shard format, expected `id:total`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Shard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.id, self.total)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<ShardId> for Shard {
|
||||
fn into(self) -> ShardId {
|
||||
ShardId::new(self.id, self.total)
|
||||
}
|
||||
}
|
||||
|
||||
/// The available commands for the Payphone CLI and their available arguments.
|
||||
#[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,
|
||||
|
||||
/// The username to use when connecting to the database.
|
||||
#[arg(
|
||||
long,
|
||||
env = "PAYPHONE_DATABASE_USERNAME",
|
||||
value_name = "USERNAME"
|
||||
)]
|
||||
database_username: Option<String>,
|
||||
|
||||
/// The password to use when connecting to the database.
|
||||
#[arg(
|
||||
long,
|
||||
env = "PAYPHONE_DATABASE_PASSWORD",
|
||||
value_name = "PASSWORD"
|
||||
)]
|
||||
database_password: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_SHARD",
|
||||
value_name = "ID:TOTAL",
|
||||
default_value_t = Shard::default())
|
||||
]
|
||||
shard: Shard,
|
||||
},
|
||||
|
||||
/// 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,13 @@
|
||||
//! 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;
|
||||
|
||||
/// A yellow color used for mask notifications and warnings.
|
||||
pub const COLOR_YELLOW: u32 = 0xFFC868;
|
||||
@@ -0,0 +1,161 @@
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TYPE IF NOT EXISTS payphone.message (
|
||||
id BIGINT,
|
||||
call_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_message_id BIGINT,
|
||||
|
||||
is_deleted BOOLEAN,
|
||||
is_edited BOOLEAN,
|
||||
is_censored BOOLEAN
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
call_id BIGINT,
|
||||
message_id BIGINT,
|
||||
author_id BIGINT,
|
||||
propagation_message_id BIGINT,
|
||||
|
||||
data FROZEN<payphone.message>,
|
||||
|
||||
PRIMARY KEY (call_id, message_id)
|
||||
) WITH default_time_to_live = 2592000; -- 30 days
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.messages_by_propagation_id AS
|
||||
SELECT call_id, message_id, propagation_message_id FROM payphone.messages
|
||||
WHERE call_id IS NOT NULL AND message_id IS NOT NULL AND propagation_message_id IS NOT NULL
|
||||
PRIMARY KEY (call_id, propagation_message_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.servers (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
prefix TEXT,
|
||||
language TEXT,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.reports (
|
||||
report_id BIGINT,
|
||||
user_id BIGINT,
|
||||
|
||||
message FROZEN<payphone.message>,
|
||||
replying_to FROZEN<payphone.message>,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
|
||||
created_by_ids SET<BIGINT>,
|
||||
|
||||
reviewed_by_id BIGINT,
|
||||
resolution ASCII,
|
||||
|
||||
PRIMARY KEY (user_id, report_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.user_incidents (
|
||||
user_id BIGINT,
|
||||
incident_id BIGINT,
|
||||
report_id BIGINT,
|
||||
|
||||
-- ban, warning
|
||||
type ASCII,
|
||||
|
||||
message FROZEN<payphone.message>,
|
||||
|
||||
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,154 @@
|
||||
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,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<Session, DatabaseError> {
|
||||
let session = if let Some((username, password)) = username.zip(password) {
|
||||
SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.user(username, password)
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?
|
||||
} else {
|
||||
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(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user