feat(shard): Lots of code.
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
k8s/
|
||||
.vscode/
|
||||
.git/
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12 AS base
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN pip install uv
|
||||
RUN uv sync --no-dev
|
||||
|
||||
|
||||
FROM base AS shard
|
||||
|
||||
ENTRYPOINT [ "uv", "run", "--no-dev", "bot.py" ]
|
||||
|
||||
|
||||
FROM base AS tasks
|
||||
|
||||
ENTRYPOINT [ "uv", "run", "--no-dev", "tasks.py" ]
|
||||
@@ -0,0 +1,264 @@
|
||||
# Payphone
|
||||
|
||||
Payphone is deployed using Kubernetes + Docker. To set up a local kubernetes cluster, you can use
|
||||
`minikube`.
|
||||
|
||||
## Starting a Cluster
|
||||
|
||||
This section will guide you step by step to create a multi-node cluster or a local development
|
||||
cluster and start up Payphone. Make sure to have:
|
||||
|
||||
1. The Discord bot's token, either for development or production depending on what you're setting
|
||||
up the cluster for.
|
||||
2. You'll probably need at least 5 CPU cores (6 recommended) across all of your machines + 1 core
|
||||
per shard, 8 GB of ram + 512Mb of RAM per shard, and 30Gb of disk space available. It'll still
|
||||
run with less, but it'll be way slower. In multi-node environment, these specifications are
|
||||
cluster-wide, meaning that if you have 3 nodes with 3 cores, that makes it 9 cores in total and
|
||||
therefore suitable for a multi-shard deployment. Do not run nodes with less than 2 CPU cores, 2
|
||||
Gb (ideally 4 Gb) of RAM, and 15 Gb of disk space available.
|
||||
|
||||
### Kubernetes Setup
|
||||
|
||||
First of all, you need to install Kubernetes in the machine where you'll set up either a node or a
|
||||
whole cluster.
|
||||
|
||||
Make sure to have Docker installed in your machine before running the following steps. You can
|
||||
check how to install Docker in the
|
||||
[Docker documentation](https://docs.docker.com/get-started/get-docker/). If you're setting up a new
|
||||
node for production, it's better if you only install the Docker Engine without Docker Desktop.
|
||||
|
||||
#### For Development
|
||||
|
||||
Ideally, use `minikube` for local development. This'll allow you to run a complete cluster locally.
|
||||
Make sure to have downloaded and installed minikube first. On Windows, it's available with
|
||||
`chocolatey` running `choco install minikube`. You can read the full set up guide for other
|
||||
installation methods at the [official minikube documentation](https://minikube.sigs.k8s.io/docs/).
|
||||
|
||||
After `minikube` is installed, you need to set up a local cluster. This'll create a virtual machine
|
||||
in a Docker container to act as nodes in your cluster. Run the following command to start a 3-node
|
||||
local cluster:
|
||||
|
||||
```sh
|
||||
minikube start --network-plugin=cni --cni=calico --nodes=3
|
||||
```
|
||||
|
||||
This'll create 3 docker containers and start them, and set them up to use kubectl connecting them
|
||||
automatically. Management `kubectl` commands can only be ran from the master/control-panel node,
|
||||
but with `kubectl` you don't need to worry about that since running `kubectl` from your machine
|
||||
will work as if you were in a master node.
|
||||
|
||||
`kubectl` comes integrated with `Docker Desktop`. You can enable Kubernetes in Docker Desktop at
|
||||
Docker Desktop > Settings > Kubernetes > Enable Kubernetes.
|
||||
|
||||
#### For Production
|
||||
|
||||
These steps are required in order to set up a new `microk8s` node. These installation steps assume
|
||||
they're being ran in an Ubuntu 22.4+ server with at least 2 CPU cores, 4 GB RAM, and 25 GB disk
|
||||
space, and `snap` installed. You also need root privileges to follow these steps.
|
||||
|
||||
First of all, install `microk8s`.
|
||||
|
||||
```sh
|
||||
sudo snap install microk8s --classic
|
||||
```
|
||||
|
||||
After the installation, check that `microk8s` is up and running using the following command:
|
||||
|
||||
```sh
|
||||
microk8s status --wait-ready
|
||||
```
|
||||
|
||||
> [!INFO] In case the last command fails to run because of missing permissions, you'll be guided
|
||||
> on how to give your current user the required permissions to run the command. Follow those steps
|
||||
> and re-run the command afterwards.
|
||||
|
||||
Then, you need to enable the required extensions for `microk8s` to work properly. Run the following
|
||||
commands to enable them:
|
||||
|
||||
```sh
|
||||
microk8s enable dns
|
||||
microk8s enable ha-cluster
|
||||
microk8s enable community
|
||||
|
||||
sudo systemctl enable iscsid # Needed to run the following command.
|
||||
|
||||
microk8s enable openebs
|
||||
```
|
||||
|
||||
The rest of the guide uses `kubectl`, but `microk8s` has `kubectl` as a subcommand, so either:
|
||||
|
||||
1. You create an alias in `.bash_aliases`, appending `alias kubectl='microk8s kubectl'` to your
|
||||
`~/.bash_aliases` file. If you don't have one, create an empty file and add the command in it.
|
||||
After updating the file, run `source ~/.bash_aliases` to apply the changes.
|
||||
|
||||
2. Use `microk8s kubectl` instead of `kubectl` in the following steps. This'll require you to type
|
||||
`microk8s` at the front of all `kubectl` commands that are run from now on.
|
||||
|
||||
To add an alias, run the following command:
|
||||
|
||||
```sh
|
||||
echo "alias kubectl='microk8s kubectl'" >> ~/.bash_aliases && source ~/.bash_aliases
|
||||
```
|
||||
|
||||
##### Add a Node to the Cluster
|
||||
|
||||
To add a new node to the cluster, run the following command on a master node:
|
||||
|
||||
```sh
|
||||
microk8s add-node
|
||||
```
|
||||
|
||||
You'll get a command to paste in your new node. Copy the one that ends with `--worker` and paste it
|
||||
in your new node. Wait a few minutes until it connects, and then you're ready to go! No need to
|
||||
follow the deployment steps if the cluster is already up and running.
|
||||
|
||||
##### SSH Security
|
||||
|
||||
To secure the access to the server, you'll need to follow the following steps:
|
||||
|
||||
###### Disable Password Authentication
|
||||
|
||||
Having SSH password authentication leaves an open door for brute force attacks. Before disabling
|
||||
it, make sure that the server has your SSH key to prevent from getting locked out after proceeding.
|
||||
|
||||
After you have made sure your keys have been properly configured, edit the SSH configuration file:
|
||||
|
||||
```sh
|
||||
nano /etc/ssh/ssh_config
|
||||
```
|
||||
|
||||
In the file, find the following configurations:
|
||||
1. `ChallengeResponseAuthentication`
|
||||
2. `PasswordAuthentication`
|
||||
3. `UsePAM`
|
||||
|
||||
If they're set in the file, set their value to `no`. After that, restart the SSH service:
|
||||
|
||||
```sh
|
||||
systemctl restart ssh
|
||||
```
|
||||
|
||||
###### Set Up `fail2ban`
|
||||
|
||||
The next step to secure SSH is to set up `fail2ban`, which will rate limit failed attempts to log
|
||||
in. First, install it in the server:
|
||||
|
||||
```sh
|
||||
apt update
|
||||
apt install fail2ban
|
||||
```
|
||||
|
||||
Next, make a copy of the configuration file:
|
||||
|
||||
```sh
|
||||
cd /etc/fail2ban
|
||||
cp jail.conf jail.local
|
||||
```
|
||||
|
||||
You now need to update it to enable SSH protection:
|
||||
|
||||
```sh
|
||||
nano jail.local
|
||||
```
|
||||
|
||||
Find the `[ssh]` section and add the following line (or update it if it already exists):
|
||||
|
||||
```toml
|
||||
enabled = true
|
||||
```
|
||||
|
||||
After updating that line, close the file, enable, and start the service:
|
||||
|
||||
```sh
|
||||
systemctl enable fail2ban
|
||||
systemctl start fail2ban
|
||||
|
||||
# Check the status of the service to make sure it was enabled properly. You can exit the status
|
||||
# screen pressing "q".
|
||||
systemctl status fail2ban
|
||||
```
|
||||
|
||||
## Deploying Payphone
|
||||
|
||||
The following steps show how to pull and install Payphone on a running cluster of nodes.
|
||||
|
||||
### Building and Publishing the Payphone Image
|
||||
|
||||
Payphone's container images are hosted here at `git.nyeki.dev`. To login, run the following
|
||||
command:
|
||||
|
||||
```sh
|
||||
docker login git.nyeki.dev
|
||||
```
|
||||
|
||||
A new browser tab will open and you'll be prompted for log in. Once you accept, you'll be logged in
|
||||
to the container registry.
|
||||
|
||||
When you have made an update and want to update the production deployment's image, you need to
|
||||
build and push the image to the container registry:
|
||||
|
||||
```sh
|
||||
docker build -t git.nyeki.dev/payphone/payphone:latest .
|
||||
docker push git.nyeki.dev/payphone/payphone:latest
|
||||
```
|
||||
|
||||
### Starting Payphone in the Cluster
|
||||
|
||||
#### Setting Up Config Files
|
||||
|
||||
First of all, you'll need to set up the environment variables that Payphone needs. For this,
|
||||
you'll need:
|
||||
|
||||
1. A Discord bot token, base64 encoded. You can encode your token at
|
||||
[www.base64encode.org](https://www.base64encode.org/).
|
||||
2. A suggestions channel that the bot can write to (its ID).
|
||||
3. A non-expiring invite to the official server.
|
||||
|
||||
First of all, open `k8s/config.yml`. At the bottom of the file, you'll find a `data` object with
|
||||
some environment variables definitions. Set `SUGGESTIONS_CHANNEL_ID` and `SERVER_INVITE` and save
|
||||
the file.
|
||||
|
||||
Then, after you've base64-encoded your token, open `k8s/secrets.yml` and in a similar `data` object
|
||||
at the bottom of the file, set `TOKEN` to the base64-encoded Discord bot token and save the
|
||||
changes.
|
||||
|
||||
After the files are updated with their new values, run the following commands to apply these
|
||||
changes:
|
||||
|
||||
```sh
|
||||
kubectl apply -f k8s/config.yml
|
||||
kubectl apply -f k8s/secrets.yml
|
||||
```
|
||||
|
||||
If you see a ConfigMap and a Secret being created, that means you've successfully applied the
|
||||
changes.
|
||||
|
||||
#### Setting Up Redis and PostgreSQL
|
||||
|
||||
Now, you can initialize Redis. This step is pretty straightforward and you can forget about it
|
||||
later.
|
||||
|
||||
```sh
|
||||
kubectl apply -f k8s/redis.yml
|
||||
kubectl apply -f k8s/psql.yml
|
||||
```
|
||||
|
||||
#### Setting Up Payphone
|
||||
|
||||
Finally, you need to start the bot's instance. First, log into your `git.nyeki.dev` account from
|
||||
`kubectl` with the following command:
|
||||
|
||||
```sh
|
||||
kubectl create secret docker-registry regcred --docker-server=https://git.nyeki.dev/v1/ --docker-username=<your-username> --docker-password=<your-password>
|
||||
```
|
||||
|
||||
After that command is ran, the next step is to update the amount of shards. To set the amount,
|
||||
open `k8s/bot.yml` and set the total number of shards in both `spec.replicas` and
|
||||
`spec.template.spec.containers.bot.env.TOTAL_SHARDS.value`.
|
||||
|
||||
Once it's done, you can save and close the file and run the final command.
|
||||
|
||||
```sh
|
||||
kubectl apply -f k8s/tasks.yml
|
||||
kubectl apply -f k8s/bot.yml
|
||||
```
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: bot
|
||||
labels:
|
||||
app: bot
|
||||
spec:
|
||||
serviceName: "bot-headless"
|
||||
replicas: 1 # Adjust this manually together with env.TOTAL_SHARDS down below.
|
||||
selector:
|
||||
matchLabels:
|
||||
app: bot
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: bot
|
||||
spec:
|
||||
containers:
|
||||
- name: bot
|
||||
image: git.nyeki.dev/payphone/payphone-shard:latest
|
||||
imagePullPolicy: "Always"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: bot-config
|
||||
- secretRef:
|
||||
name: bot-secrets
|
||||
env:
|
||||
- name: SHARD_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
|
||||
- name: SHARD_COUNT
|
||||
value: "1" # Adjust this manually together with spec.replicas up at the top.
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "256Mi"
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: bot-storage
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce # Ensures only one node can mount the volume at a time
|
||||
storageClassName: openebs-jiva-csi-default
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi # Defines the storage size
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bot-headless
|
||||
labels:
|
||||
app: bot
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app: bot
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: bot-config
|
||||
data:
|
||||
IS_DEBUG: "false"
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -0,0 +1,35 @@
|
||||
# Redis Pod
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: redis
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7.4.1-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
resources:
|
||||
requests:
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1"
|
||||
|
||||
---
|
||||
# Redis Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: bot-secrets
|
||||
type: Opaque
|
||||
data:
|
||||
TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz
|
||||
@@ -0,0 +1,29 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: tasks
|
||||
spec:
|
||||
containers:
|
||||
- name: connect
|
||||
image: git.nyeki.dev/payphone/payphone-tasks:latest
|
||||
command: ["uv", "run", "--no-dev", "tasks.py", "connect"]
|
||||
resources:
|
||||
requests:
|
||||
memory: "250Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
- name: expire
|
||||
image: git.nyeki.dev/payphone/payphone-tasks:latest
|
||||
command: ["uv", "run", "--no-dev", "tasks.py", "expire"]
|
||||
resources:
|
||||
requests:
|
||||
memory: "250Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
restartPolicy: Always
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
+279
-15
@@ -1,10 +1,17 @@
|
||||
from typing import Literal
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord.ext.commands import errors
|
||||
|
||||
from tortoise import expressions
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from payphone.db import setup, models
|
||||
from payphone.core import constants, logging
|
||||
from payphone.core import constants, logging, exceptions, formatting, help_command
|
||||
|
||||
|
||||
class Payphone(commands.AutoShardedBot):
|
||||
@@ -29,9 +36,10 @@ class Payphone(commands.AutoShardedBot):
|
||||
command_prefix="p.",
|
||||
strip_after_prefix=True,
|
||||
case_insensitive=True,
|
||||
help_command=help_command.HelpCommand(),
|
||||
)
|
||||
|
||||
for cog in ["calls", "admin"]:
|
||||
for cog in ["calls", "admin", "mods"]:
|
||||
self.load_extension(f"payphone.cogs.{cog}.cog")
|
||||
self.log.info("Loaded cog %s.", cog)
|
||||
|
||||
@@ -59,7 +67,7 @@ class Payphone(commands.AutoShardedBot):
|
||||
shard_id=shard_id,
|
||||
)
|
||||
|
||||
async def on_shard_connect(self, shard_id: int) -> None:
|
||||
async def on_shard_connect(self, _shard_id: int) -> None:
|
||||
"""Runs when the shard connects.
|
||||
|
||||
Args:
|
||||
@@ -99,7 +107,8 @@ class Payphone(commands.AutoShardedBot):
|
||||
channel_id (int): The channel's ID.
|
||||
|
||||
Returns:
|
||||
(abc.GuildChannel | abc.PrivateChannel | discord.Thread | None): The channel, or None if not found.
|
||||
(abc.GuildChannel | abc.PrivateChannel | discord.Thread | None): The channel, or None
|
||||
if not found.
|
||||
"""
|
||||
|
||||
channel = self.get_channel(channel_id)
|
||||
@@ -109,6 +118,77 @@ class Payphone(commands.AutoShardedBot):
|
||||
|
||||
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, call) -> str:
|
||||
"""Cleans a message.
|
||||
|
||||
@@ -138,29 +218,205 @@ class Payphone(commands.AutoShardedBot):
|
||||
|
||||
return content
|
||||
|
||||
async def get_user_flag(self, user_id: int) -> str:
|
||||
"""Gets the flags of a user.
|
||||
async def is_moderator(self, user_id: int) -> bool:
|
||||
"""Checks whether a user is a moderator.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user.
|
||||
user_id (int): The user to check's ID.
|
||||
|
||||
Returns:
|
||||
(str): The flags of the user.
|
||||
(bool): Whether the user is a moderator.
|
||||
"""
|
||||
|
||||
flag = await self.redis.hget(f"user:{user_id}", "flags")
|
||||
user = await models.User.get_cache_or_db(self.redis, user_id)
|
||||
|
||||
if flag is None:
|
||||
user, _ = await models.User.get_or_create(id=user_id)
|
||||
return user.is_moderator
|
||||
|
||||
flag = user.flags
|
||||
async def is_banned(self, user_id: int) -> bool:
|
||||
"""Checks whether a user is banned.
|
||||
|
||||
await self.redis.hset(f"user:{user_id}", "flags", int(flag))
|
||||
Checks whether a user is banned, and caches the result in Redis. The cache expires in 5
|
||||
minutes.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to check's ID.
|
||||
|
||||
Returns:
|
||||
bool: Whether the user is banned or not.
|
||||
"""
|
||||
|
||||
key = f"banned:{user_id}"
|
||||
|
||||
is_banned = await self.redis.get(key)
|
||||
|
||||
if is_banned is not None:
|
||||
return bool(int(is_banned))
|
||||
|
||||
ban = await models.Ban.filter(
|
||||
expressions.Q(user_id=user_id),
|
||||
expressions.Q(
|
||||
expires_at=None,
|
||||
expires_at__gt=datetime.now(timezone.utc),
|
||||
join_type="OR",
|
||||
),
|
||||
).first()
|
||||
|
||||
await self.redis.set(
|
||||
key, int(ban is not None), exat=ban.expires_at if ban else None
|
||||
)
|
||||
|
||||
return is_banned
|
||||
|
||||
async def ban(
|
||||
self,
|
||||
user_id: int,
|
||||
report: models.Report | None,
|
||||
expires_at: datetime | None,
|
||||
created_by_id: int | None,
|
||||
) -> models.Ban:
|
||||
"""Bans a user.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to ban's ID.
|
||||
report (Report | None): The report that caused the ban.
|
||||
expires_at (datetime | None): When the ban expires.
|
||||
created_by_id (int | None): The ID of the user who created the ban.
|
||||
|
||||
Returns:
|
||||
(Ban): The ban.
|
||||
"""
|
||||
|
||||
ban = models.Ban(
|
||||
user_id=user_id,
|
||||
report=report,
|
||||
expires_at=expires_at,
|
||||
created_by_id=created_by_id,
|
||||
)
|
||||
|
||||
await ban.save()
|
||||
|
||||
await self.redis.set(f"banned:{user_id}", 1, exat=expires_at)
|
||||
|
||||
return ban
|
||||
|
||||
async def unban(self, user_id: int) -> None:
|
||||
"""Unbans a user.
|
||||
|
||||
Args:
|
||||
user_id (int): The user to unban's ID.
|
||||
"""
|
||||
|
||||
await models.Ban.filter(user_id=user_id).delete()
|
||||
|
||||
await self.redis.delete(f"banned:{user_id}")
|
||||
|
||||
async def on_command_error(
|
||||
self, context: commands.Context, exception: commands.CommandError
|
||||
) -> None:
|
||||
"""Handles each of the errors raised by commands.
|
||||
|
||||
Args:
|
||||
context (commands.Context): The context of the command.
|
||||
exception (commands.CommandError): The exception raised.
|
||||
"""
|
||||
|
||||
if isinstance(exception, exceptions.ChannelDisabledError):
|
||||
await context.send(
|
||||
"❌ You cannot run commands in this channel.", delete_after=20.0
|
||||
)
|
||||
|
||||
elif isinstance(exception, exceptions.BannedError):
|
||||
await context.send("❌ You have been banned from Payphone.")
|
||||
|
||||
elif isinstance(exception, exceptions.NotOnCallError):
|
||||
await context.send("❌ You need to be on a call to run this command.")
|
||||
|
||||
elif isinstance(exception, exceptions.NotAModeratorError):
|
||||
await context.send("❌ You're not a bot moderator.")
|
||||
|
||||
elif isinstance(exception, errors.MissingRequiredArgument):
|
||||
await context.send(
|
||||
f"❌ `{exception.param.name}` is missing. Use `{context.clean_prefix}help {context.command.name}` to learn how to use it."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.BotMissingPermissions):
|
||||
await context.send(
|
||||
f"❌ Payphone's missing {formatting.text_join([f"`{perm.replace("_", " ").title()}`" for perm in exception.missing_permissions])} permissions to run this command."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.NoPrivateMessage):
|
||||
await context.send("❌ This command can only be run in a server.")
|
||||
|
||||
elif isinstance(exception, errors.MissingPermissions):
|
||||
await context.send("❌ You don't have permission to run this command.")
|
||||
|
||||
elif isinstance(exception, errors.CommandOnCooldown):
|
||||
await context.send(
|
||||
f"❌ You're going too fast! Wait `{int(exception.retry_after)}` seconds before trying again."
|
||||
)
|
||||
|
||||
elif isinstance(exception, errors.NotOwner):
|
||||
await context.send("❌ You're not the owner of this bot.")
|
||||
|
||||
elif isinstance(exception, errors.CommandNotFound):
|
||||
return
|
||||
|
||||
else:
|
||||
flag = models.UserFlag(int(flag))
|
||||
return await super().on_command_error(context, exception)
|
||||
|
||||
return flag
|
||||
@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():
|
||||
@@ -168,6 +424,14 @@ def run():
|
||||
|
||||
bot = Payphone()
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
bot.loop.run_until_complete(setup.init())
|
||||
|
||||
bot.run(constants.TOKEN)
|
||||
|
||||
@@ -5,11 +5,16 @@ from discord.ext import commands
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Admin(commands.Cog):
|
||||
class Admin(base.Cog):
|
||||
"""Administration in Payphone."""
|
||||
|
||||
show_in_help = False
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
"""Initializes the Calls cog.
|
||||
|
||||
@@ -22,26 +27,69 @@ class Admin(commands.Cog):
|
||||
@commands.command(aliases=["mod"])
|
||||
@commands.is_owner()
|
||||
async def moderator(
|
||||
self, ctx: commands.Context, user: discord.User, toggle: bool
|
||||
self, ctx: commands.Context, user: discord.User, toggle: bool = True
|
||||
) -> None:
|
||||
"""Gives the user the Moderator role."""
|
||||
"""Gives the user the Moderator role.
|
||||
|
||||
await models.User.update_or_create(
|
||||
Options:
|
||||
- `<user>`: The user to give the role.
|
||||
- `[toggle]`: Whether to give or remove the role.
|
||||
|
||||
Permissions:
|
||||
- Bot Owner
|
||||
"""
|
||||
|
||||
instance, _ = await models.User.update_or_create(
|
||||
id=user.id, defaults={"is_moderator": toggle}
|
||||
)
|
||||
await instance.set_cache(self.bot.redis)
|
||||
|
||||
await ctx.send(
|
||||
f"Toggled the moderator role {'on' if toggle else 'off'} for {user.mention}."
|
||||
)
|
||||
|
||||
@commands.command(aliases=["ub"])
|
||||
@checks.is_moderator()
|
||||
async def unban(
|
||||
self, ctx: commands.Context, user: discord.User | None = None
|
||||
) -> None:
|
||||
"""Unbans the user from the bot.
|
||||
|
||||
Options:
|
||||
- `[user]`: The user to unban. If none is provided, the author will be unbanned.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
user = user or ctx.author
|
||||
|
||||
await self.bot.unban(user.id)
|
||||
|
||||
await ctx.send(f"{user.mention} was unbanned from Payphone.")
|
||||
|
||||
try:
|
||||
await user.send("🔔 You were unbanned from Payphone!")
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
@commands.command(aliases=["sup"])
|
||||
@commands.is_owner()
|
||||
async def supporter(
|
||||
self, ctx: commands.Context, user: discord.User, toggle: bool = True
|
||||
) -> None:
|
||||
"""Gives the user the Supporter role."""
|
||||
"""Gives the user the Supporter role.
|
||||
|
||||
await models.User.update_or_create(
|
||||
Options:
|
||||
- `<user>`: The user to give the role.
|
||||
- `[toggle]`: Whether to give or remove the role.
|
||||
|
||||
Permissions:
|
||||
- Bot Owner
|
||||
"""
|
||||
|
||||
instance, _ = await models.User.update_or_create(
|
||||
id=user.id,
|
||||
defaults={
|
||||
"is_supporter_until": (
|
||||
@@ -49,11 +97,30 @@ class Admin(commands.Cog):
|
||||
)
|
||||
},
|
||||
)
|
||||
await instance.set_cache(self.bot.redis)
|
||||
|
||||
await ctx.send(
|
||||
f"Gave the Supporter role to {ctx.author.mention} until in one month."
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@checks.is_moderator()
|
||||
async def reports(self, ctx: commands.Context) -> None:
|
||||
"""Shows the reports.
|
||||
|
||||
Permissions:
|
||||
- Bot Moderator
|
||||
"""
|
||||
|
||||
report = await models.Report.filter(resolved=False).first()
|
||||
|
||||
if report is None:
|
||||
return await ctx.send("✅ No reports left, have this cookie 🍪 !!")
|
||||
|
||||
await ctx.send(
|
||||
embed=embeds.report_embed(report), view=views.ReportView(self.bot, report)
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: Payphone) -> None:
|
||||
"""Loads the Admin cog into the bot.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import discord
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.core import constants
|
||||
|
||||
|
||||
def report_embed(report: models.Report) -> discord.Embed:
|
||||
"""Creates an embed for a report.
|
||||
|
||||
Args:
|
||||
report (models.Report): The report to create an embed for.
|
||||
|
||||
Returns:
|
||||
(discord.Embed): The embed.
|
||||
"""
|
||||
|
||||
return discord.Embed(
|
||||
color=constants.PRIMARY_COLOR,
|
||||
title="Message Report",
|
||||
description=(
|
||||
f"**ID:** {report.id}\n"
|
||||
f"**User ID**: {report.user_id}\n"
|
||||
f"**User Name**: {report.user_name}\n"
|
||||
f"**Channel ID**: {report.channel_id}\n"
|
||||
f"**Created At**: <t:{int(report.created_at.timestamp())}>\n"
|
||||
),
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content", value=report.message_content, inline=False
|
||||
)
|
||||
],
|
||||
thumbnail=report.user_avatar_url,
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.core import constants
|
||||
from payphone.cogs.admin import embeds
|
||||
|
||||
|
||||
class ReportView(discord.ui.View):
|
||||
"""The report view."""
|
||||
|
||||
def __init__(self, bot: Payphone, report: models.Report) -> None:
|
||||
"""Initializes the ReportView.
|
||||
|
||||
Args:
|
||||
bot (Payphone): The bot.
|
||||
report (models.Report): The report.
|
||||
"""
|
||||
|
||||
super().__init__(disable_on_timeout=True)
|
||||
|
||||
self.bot = bot
|
||||
self.report = report
|
||||
|
||||
@discord.ui.select(
|
||||
placeholder="Select an action",
|
||||
options=[
|
||||
discord.SelectOption(
|
||||
value="warn",
|
||||
label="Warn",
|
||||
description="Send a warning to the user. This is done automatically.",
|
||||
),
|
||||
discord.SelectOption(
|
||||
value="ban",
|
||||
label="Ban",
|
||||
description="Ban the user. The duration of the ban will be applied automatically.",
|
||||
),
|
||||
discord.SelectOption(
|
||||
value="ban_permanently",
|
||||
label="Permanent Ban",
|
||||
description="Ban the user permanently. Use this when the user must not be allowed back.",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def action_callback(
|
||||
self, select: discord.SelectMenu, interaction: discord.Interaction
|
||||
) -> None:
|
||||
"""Handles the action select."""
|
||||
|
||||
user = await models.User.get_cache_or_db(self.bot.redis, self.report.user_id)
|
||||
|
||||
if select.values[0] == "warn":
|
||||
user.warns_count += 1
|
||||
await user.save(force_update=True)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.MASK_COLOR,
|
||||
title="Warned",
|
||||
description=f"You've been warned. You've received `{user.warns_count}` warnings in total.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content",
|
||||
value=f"You sent the following message in a call:\n> {self.report.message_content}",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Name",
|
||||
value=self.report.user_name,
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Avatar",
|
||||
value=f"[Click to open your avatar URL](<{self.report.user_avatar_url}>)",
|
||||
),
|
||||
],
|
||||
footer=discord.EmbedFooter(
|
||||
text="⚠️ Repeating this action will result in a ban."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(self.report.user_id)).send(
|
||||
embed=embed
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
else:
|
||||
user.bans_count += 1
|
||||
await user.save(force_update=True)
|
||||
await user.set_cache(self.bot.redis)
|
||||
|
||||
expires_at = None
|
||||
|
||||
if select.values[0] == "ban":
|
||||
expiry_times = {
|
||||
1: datetime.now(timezone.utc) + timedelta(days=1),
|
||||
2: datetime.now(timezone.utc) + timedelta(days=7),
|
||||
3: datetime.now(timezone.utc) + timedelta(days=30),
|
||||
}
|
||||
|
||||
expires_at = expiry_times.get(user.bans_count, None)
|
||||
|
||||
await self.bot.ban(
|
||||
self.report.user_id,
|
||||
self.report,
|
||||
expires_at=expires_at,
|
||||
created_by_id=interaction.user.id,
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
title="Banned",
|
||||
description=f"You've been banned. You've been banned `{user.bans_count}` times.",
|
||||
fields=[
|
||||
discord.EmbedField(
|
||||
name="Message Content",
|
||||
value=f"You sent the following message in a call:\n> {self.report.message_content}",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Name",
|
||||
value=self.report.user_name,
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="User Avatar",
|
||||
value=f"[Click to open your avatar URL](<{self.report.user_avatar_url}>)",
|
||||
),
|
||||
discord.EmbedField(
|
||||
name="Expires At",
|
||||
value=(
|
||||
expires_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
+ f" <t:{int(expires_at.timestamp())}:R>"
|
||||
if expires_at
|
||||
else "Never"
|
||||
),
|
||||
),
|
||||
],
|
||||
footer=discord.EmbedFooter(
|
||||
text=(
|
||||
"⚠️ In case you get banned again, your ban will last longer. Multiple bans will result in a permanent ban."
|
||||
if expires_at is not None
|
||||
else "⚠️ You've been permanently banned."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
await (await self.bot.get_or_fetch_user(self.report.user_id)).send(
|
||||
embed=embed
|
||||
)
|
||||
|
||||
except discord.Forbidden:
|
||||
pass
|
||||
|
||||
self.report.resolved = True
|
||||
await self.report.save()
|
||||
|
||||
report = await models.Report.filter(resolved=False).first()
|
||||
|
||||
if report is None:
|
||||
self.stop()
|
||||
|
||||
return await interaction.response.edit_message(
|
||||
content="✅ No reports left, have this cookie 🍪 !!",
|
||||
embeds=[],
|
||||
view=None,
|
||||
)
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=embeds.report_embed(report), view=ReportView(self.bot, report)
|
||||
)
|
||||
|
||||
@discord.ui.button(label="Skip", style=discord.ButtonStyle.gray)
|
||||
async def skip_callback(
|
||||
self, _button: discord.ui.Button, interaction: discord.Interaction
|
||||
) -> None:
|
||||
"""Skips the report."""
|
||||
|
||||
self.report.resolved = True
|
||||
await self.report.save()
|
||||
|
||||
report = await models.Report.filter(resolved=False).first()
|
||||
|
||||
if report is None:
|
||||
self.stop()
|
||||
|
||||
return await interaction.response.edit_message(
|
||||
content="✅ No reports left, have this cookie 🍪 !!",
|
||||
embeds=[],
|
||||
view=None,
|
||||
)
|
||||
|
||||
await interaction.response.edit_message(
|
||||
embed=embeds.report_embed(report), view=ReportView(self.bot, report)
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
class Cog(commands.Cog):
|
||||
"""Base cog with Payphone-specific properties."""
|
||||
|
||||
show_in_help: bool = True
|
||||
+159
-44
@@ -9,10 +9,11 @@ import aiohttp
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.core import constants, censorship, messages
|
||||
from payphone.cogs import base
|
||||
from payphone.core import constants, censorship, messages, masks, checks
|
||||
|
||||
|
||||
class Calls(commands.Cog):
|
||||
class Calls(base.Cog):
|
||||
"""Calls in Payphone."""
|
||||
|
||||
def __init__(self, bot: Payphone) -> None:
|
||||
@@ -24,7 +25,8 @@ class Calls(commands.Cog):
|
||||
|
||||
self.bot = bot
|
||||
|
||||
@commands.command()
|
||||
@commands.command(aliases=["c"])
|
||||
@checks.is_not_banned()
|
||||
async def call(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Call and connect with other people.
|
||||
@@ -49,7 +51,8 @@ class Calls(commands.Cog):
|
||||
)
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@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.
|
||||
@@ -76,7 +79,8 @@ class Calls(commands.Cog):
|
||||
f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}"
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@commands.command(aliases=["h"])
|
||||
@checks.is_not_banned()
|
||||
async def hangup(self, ctx: commands.Context) -> None:
|
||||
"""
|
||||
Hang up the call.
|
||||
@@ -101,13 +105,14 @@ class Calls(commands.Cog):
|
||||
),
|
||||
)
|
||||
|
||||
@commands.command()
|
||||
@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(ctx.channel.id)
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(ctx.channel.id)
|
||||
|
||||
if call is None:
|
||||
return await ctx.send(
|
||||
@@ -125,26 +130,63 @@ class Calls(commands.Cog):
|
||||
ctx.send("📬 Your friend request has been sent!"),
|
||||
)
|
||||
|
||||
@commands.command(aliases=["m"])
|
||||
@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:
|
||||
message += f" You're currently being displayed as **{masks.get_mask_emoji(participant.mask)} {masks.get_mask_name(participant.mask)}**."
|
||||
|
||||
message += (
|
||||
" You can toggle your mask on or off using `p.mask on` or `p.mask off`."
|
||||
)
|
||||
|
||||
return await ctx.send(message)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
"""
|
||||
Handle messages in calls.
|
||||
"""
|
||||
|
||||
if message.author.bot or (await self.bot.get_context(message)).valid:
|
||||
ctx = await self.bot.get_context(message)
|
||||
|
||||
if message.author.bot or ctx.valid:
|
||||
return
|
||||
|
||||
call = await self.bot.call_handler.get_call(message.channel.id)
|
||||
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
|
||||
|
||||
is_new_participant = not bool(await call.get_participant(message.author.id))
|
||||
|
||||
await call.set_participant(
|
||||
participant, was_mask_toggled = await call.set_participant(
|
||||
message.author.id,
|
||||
message.author.display_name,
|
||||
message.author.display_avatar.url,
|
||||
message.channel.id,
|
||||
)
|
||||
|
||||
other_channel = (
|
||||
@@ -153,9 +195,14 @@ class Calls(commands.Cog):
|
||||
else call.channels[1]
|
||||
)
|
||||
|
||||
user_flag = await self.bot.get_user_flag(message.author.id)
|
||||
|
||||
blocked_section = censorship.censor(message.content)
|
||||
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:
|
||||
return await asyncio.gather(
|
||||
@@ -178,18 +225,19 @@ class Calls(commands.Cog):
|
||||
message_content = self.bot.clean_message(message, call)
|
||||
|
||||
if message.stickers:
|
||||
if user_flag & (models.UserFlag.SUPPORTER | models.UserFlag.MODERATOR):
|
||||
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:
|
||||
await message.channel.send(
|
||||
"💖 Stickers are only allowed to donators! You can become one at [our Ko-Fi "
|
||||
"page](https://ko-fi.com/payphonedev)."
|
||||
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>)."
|
||||
)
|
||||
return
|
||||
|
||||
embeds = []
|
||||
|
||||
@@ -202,38 +250,91 @@ class Calls(commands.Cog):
|
||||
message.reference.message_id
|
||||
)
|
||||
|
||||
replied_message_content = self.bot.clean_message(replied_message, call)
|
||||
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.PRIMARY_COLOR,
|
||||
description=replied_message_content,
|
||||
author=discord.EmbedAuthor(
|
||||
name=replied_message.author.display_name,
|
||||
icon_url=replied_message.author.display_avatar.url,
|
||||
),
|
||||
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
|
||||
):
|
||||
replied_message = await self.bot.get_partial_messageable(
|
||||
other_channel
|
||||
).fetch_message(original_message_id)
|
||||
|
||||
replied_message_content = self.bot.clean_message(replied_message, call)
|
||||
replied_participant = await call.get_participant(
|
||||
replied_message.author.id
|
||||
)
|
||||
|
||||
if replied_participant is None:
|
||||
message_content += (
|
||||
"\n-# ⓘ 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:
|
||||
message_content += (
|
||||
"\n-# ⓘ In reply to a message from outside this call."
|
||||
)
|
||||
|
||||
if is_new_participant:
|
||||
if user_flag & models.UserFlag.SUPPORTER:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.SUPPORTER_COLOR,
|
||||
description=f"🌟 **{message.author.display_name}** is a Payphone supporter!",
|
||||
if was_mask_toggled:
|
||||
if not participant.is_masked:
|
||||
if participant.user.flag & models.UserFlag.SUPPORTER:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.SUPPORTER_COLOR,
|
||||
description=f"🌟 **{participant.display_name}** is a Payphone supporter!",
|
||||
footer=random.choice(
|
||||
[
|
||||
discord.EmbedFooter(
|
||||
text="You can also become a supporter at our Ko-Fi page."
|
||||
)
|
||||
]
|
||||
+ [None] * 3
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if user_flag & models.UserFlag.MODERATOR:
|
||||
if participant.user.flag & models.UserFlag.MODERATOR:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
description=f"🛡️ **{participant.display_name}** is a Payphone moderator!",
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
embeds.append(
|
||||
discord.Embed(
|
||||
color=constants.MODERATOR_COLOR,
|
||||
description=f"🛡️ **{message.author.display_name}** is a Payphone moderator!",
|
||||
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."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -246,8 +347,8 @@ class Calls(commands.Cog):
|
||||
|
||||
propagated_msg = await webhook.send(
|
||||
content=message_content,
|
||||
username=message.author.display_name,
|
||||
avatar_url=message.author.display_avatar.url,
|
||||
username=participant.display_name,
|
||||
avatar_url=participant.display_avatar_url,
|
||||
embeds=embeds,
|
||||
wait=True,
|
||||
)
|
||||
@@ -267,7 +368,7 @@ class Calls(commands.Cog):
|
||||
Handle deleted messages in calls.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call(payload.channel_id)
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id)
|
||||
|
||||
if call is None:
|
||||
return
|
||||
@@ -296,7 +397,7 @@ class Calls(commands.Cog):
|
||||
Handle edited messages in calls.
|
||||
"""
|
||||
|
||||
call = await self.bot.call_handler.get_call(payload.channel_id)
|
||||
call = await self.bot.call_handler.get_call_by_channel_id(payload.channel_id)
|
||||
|
||||
if call is None:
|
||||
return
|
||||
@@ -342,6 +443,11 @@ class Calls(commands.Cog):
|
||||
Report a message in a call.
|
||||
"""
|
||||
|
||||
if self.bot.is_banned(ctx.author.id):
|
||||
return await ctx.send_response(
|
||||
"❌ You're banned from using Payphone.", ephemeral=True
|
||||
)
|
||||
|
||||
author_id = await self.bot.call_handler.get_message_author_id(message.id)
|
||||
|
||||
if author_id is None:
|
||||
@@ -363,11 +469,20 @@ class Calls(commands.Cog):
|
||||
else:
|
||||
message_id = message.id
|
||||
|
||||
if message_id != message.id:
|
||||
cached_message = await self.bot.call_handler.get_message(message_id)
|
||||
|
||||
message = await self.bot.get_partial_messageable(
|
||||
cached_message.channel_id
|
||||
).fetch_message(message_id)
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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))
|
||||
+146
-15
@@ -2,7 +2,9 @@ from dataclasses import dataclass
|
||||
|
||||
import orjson
|
||||
|
||||
from payphone.db import models
|
||||
from payphone.bot import Payphone
|
||||
from payphone.core import masks
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -14,6 +16,44 @@ class Participant:
|
||||
id: int
|
||||
name: str
|
||||
avatar_url: str
|
||||
mask: str
|
||||
is_masked: bool
|
||||
channel_id: int
|
||||
user: models.User | None = None
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Gets the display name of the participant.
|
||||
|
||||
Returns:
|
||||
str: The display name of the participant.
|
||||
"""
|
||||
|
||||
return self.name if not self.is_masked else masks.get_mask_name(self.mask)
|
||||
|
||||
@property
|
||||
def display_avatar_url(self) -> str:
|
||||
"""Gets the display avatar URL of the participant.
|
||||
|
||||
Returns:
|
||||
str: The display avatar URL of the participant.
|
||||
"""
|
||||
|
||||
return (
|
||||
self.avatar_url
|
||||
if not self.is_masked
|
||||
else masks.get_mask_image_url(self.mask)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
id: int
|
||||
call_id: int
|
||||
channel_id: int
|
||||
propagation_id: int
|
||||
propagation_channel_id: int
|
||||
author_id: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -24,6 +64,7 @@ class Call:
|
||||
|
||||
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
|
||||
@@ -54,6 +95,38 @@ class Call:
|
||||
|
||||
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.
|
||||
|
||||
@@ -97,27 +170,55 @@ class Call:
|
||||
return int(propagation_channel_id), int(propagation_id)
|
||||
|
||||
async def set_participant(
|
||||
self, user_id: int, user_name: str, user_avatar_url: str
|
||||
) -> 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: The participant that was set.
|
||||
(Participant, bool): The participant that was set and whether they changed their masked
|
||||
status.
|
||||
"""
|
||||
|
||||
data = orjson.dumps({"name": user_name, "avatar_url": user_avatar_url})
|
||||
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
|
||||
)
|
||||
_bot=self._bot,
|
||||
id=user_id,
|
||||
name=user_name,
|
||||
avatar_url=user_avatar_url,
|
||||
mask=mask,
|
||||
is_masked=user.is_masked,
|
||||
channel_id=channel_id,
|
||||
user=user,
|
||||
), (user.is_masked != participant.is_masked if participant else True)
|
||||
|
||||
async def get_participant(self, user_id: int) -> Participant | None:
|
||||
"""Gets a participant in a call.
|
||||
@@ -137,7 +238,13 @@ class Call:
|
||||
data = orjson.loads(data)
|
||||
|
||||
return Participant(
|
||||
_bot=self._bot, id=user_id, name=data["name"], avatar_url=data["avatar_url"]
|
||||
_bot=self._bot,
|
||||
id=user_id,
|
||||
name=data["name"],
|
||||
avatar_url=data["avatar_url"],
|
||||
mask=data["mask"],
|
||||
is_masked=data["is_masked"],
|
||||
channel_id=data["channel_id"],
|
||||
)
|
||||
|
||||
async def get_participants(self) -> list["Participant"]:
|
||||
@@ -160,6 +267,9 @@ class Call:
|
||||
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"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -317,7 +427,7 @@ class CallHandler:
|
||||
|
||||
return await self.bot.redis.hexists("calls:channels:active", channel_id)
|
||||
|
||||
async def get_call(self, channel_id: int) -> Call | None:
|
||||
async def get_call_by_channel_id(self, channel_id: int) -> Call | None:
|
||||
"""Gets the call that a channel is in.
|
||||
|
||||
Args:
|
||||
@@ -332,6 +442,20 @@ class CallHandler:
|
||||
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(
|
||||
@@ -339,7 +463,7 @@ class CallHandler:
|
||||
)
|
||||
]
|
||||
|
||||
return Call(_bot=self.bot, id=call_id, channels=channels)
|
||||
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.
|
||||
@@ -437,17 +561,24 @@ class CallHandler:
|
||||
|
||||
return webhook.url
|
||||
|
||||
async def get_message(self, message_id: int) -> None:
|
||||
"""Gets a message as part of a call.
|
||||
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:
|
||||
None: The channel and propagation ID of the message.
|
||||
Message | None: The message that was sent.
|
||||
"""
|
||||
|
||||
return await self.bot.redis.hget("calls:messages", message_id)
|
||||
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.
|
||||
@@ -471,9 +602,9 @@ class CallHandler:
|
||||
|
||||
message_id = int(message_id) if message_id else id
|
||||
|
||||
author_id = await self.bot.redis.hget("calls:messages:authors", id)
|
||||
author_id = await self.bot.redis.hget("calls:messages:authors", message_id)
|
||||
|
||||
return int(author_id)
|
||||
return int(author_id) if author_id else None
|
||||
|
||||
async def get_original_message_id(self, id: int) -> int | None:
|
||||
"""
|
||||
|
||||
@@ -17,11 +17,12 @@ URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
|
||||
INVITES_PATTERN = re.compile(r"(discord)?.gg/[^\s]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def censor(message: str) -> str:
|
||||
def censor(message: str, is_gif_allowed: bool = False) -> str:
|
||||
"""Returns the first censored word or pattern found.
|
||||
|
||||
Args:
|
||||
message (str): The message to moderate.
|
||||
is_gif_allowed (bool, optional): If GIFs are allowed. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str | None: The blocked pattern found as it was found.
|
||||
@@ -32,10 +33,13 @@ def censor(message: str) -> str:
|
||||
if PATTERN.match(word):
|
||||
return word
|
||||
|
||||
match = next(URL_PATTERN.finditer(message), None)
|
||||
for match in URL_PATTERN.finditer(message):
|
||||
string = message[match.start() : match.end()]
|
||||
|
||||
if match is not None:
|
||||
return message[match.start() : match.end()]
|
||||
if is_gif_allowed and string.startswith("https://tenor.com/view/"):
|
||||
continue
|
||||
|
||||
return string
|
||||
|
||||
match = next(INVITES_PATTERN.finditer(message), None)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from discord.ext import commands
|
||||
|
||||
from payphone.core import exceptions
|
||||
|
||||
|
||||
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 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)
|
||||
@@ -29,3 +29,4 @@ BRAILE_CHARACTER = "\u2800"
|
||||
PRIMARY_COLOR = 0xE2D9D0
|
||||
SUPPORTER_COLOR = 0xAB9EFF
|
||||
MODERATOR_COLOR = 0xFF8787
|
||||
MASK_COLOR = 0xFFC868
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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."""
|
||||
@@ -0,0 +1,221 @@
|
||||
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 not matches:
|
||||
raise ValueError("Invalid time string format")
|
||||
|
||||
years, months, weeks, days, hours, minutes, seconds = matches.groups()
|
||||
|
||||
years = int(years) if years else 0
|
||||
months = int(months) if months else 0
|
||||
weeks = int(weeks) if weeks else 0
|
||||
days = int(days) if days else 0
|
||||
hours = int(hours) if hours else 0
|
||||
minutes = int(minutes) if minutes else 0
|
||||
seconds = int(seconds) if seconds else 0
|
||||
|
||||
days += weeks * 7
|
||||
days += months * 30
|
||||
days += years * 365
|
||||
|
||||
return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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="https://discord.gg/payphone",
|
||||
),
|
||||
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):
|
||||
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"Use {self.context.clean_prefix}help <command> to get more information about a specific command."
|
||||
),
|
||||
)
|
||||
|
||||
await self.context.send(embed=embed, view=HelpView())
|
||||
|
||||
async def send_command_help(self, command: commands.Command) -> None:
|
||||
command_name = (
|
||||
command.name
|
||||
if isinstance(command, discord.SlashCommand)
|
||||
else command.qualified_name
|
||||
)
|
||||
command_options = "".join(
|
||||
[f" <{option.name}>" for option in command.options]
|
||||
if isinstance(command, discord.SlashCommand)
|
||||
else [
|
||||
(
|
||||
f" [{name}]"
|
||||
if 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}")
|
||||
@@ -0,0 +1,109 @@
|
||||
import random
|
||||
|
||||
from payphone.core import constants
|
||||
|
||||
|
||||
MASKS = {
|
||||
"ferret": 1278176917123760139 if constants.IS_DEBUG else 1278173356423909448,
|
||||
"octopus": 1278176904410697880 if constants.IS_DEBUG else 1278173331421397072,
|
||||
"cat": 1278176891290783824 if constants.IS_DEBUG else 1278173317034934282,
|
||||
"monkey": 1278176881195094049 if constants.IS_DEBUG else 1278173297456185344,
|
||||
"panda": 1278176868767629455 if constants.IS_DEBUG else 1278173272390762538,
|
||||
"sheep": 1278176856910073946 if constants.IS_DEBUG else 1278173251914432563,
|
||||
"horse": 1278176842116894760 if constants.IS_DEBUG else 1278173237582237708,
|
||||
"bee": 1278176823846506577 if constants.IS_DEBUG else 1278173216652787826,
|
||||
"wild boar": 1278176806498996235 if constants.IS_DEBUG else 1278173196658413649,
|
||||
"duck": 1278176794591232102 if constants.IS_DEBUG else 1278173181483679918,
|
||||
"chicken": 1278176781790216233 if constants.IS_DEBUG else 1278173167218589726,
|
||||
"elephant": 1278176771413512213 if constants.IS_DEBUG else 1278173150063886346,
|
||||
"cow": 1278176761565413483 if constants.IS_DEBUG else 1278173125024153733,
|
||||
"deer": 1278176744691597312 if constants.IS_DEBUG else 1278173110105014406,
|
||||
"turkey": 1278176730619576321 if constants.IS_DEBUG else 1278173091796877363,
|
||||
"god": 1278176719932751903 if constants.IS_DEBUG else 1278173070846201856,
|
||||
"beaver": 1278176709530882091 if constants.IS_DEBUG else 1278173051485425705,
|
||||
"camel": 1278176699921465394 if constants.IS_DEBUG else 1278173035018584086,
|
||||
"koala": 1278176687493746709 if constants.IS_DEBUG else 1278173021345021952,
|
||||
"hippo": 1278176675846295603 if constants.IS_DEBUG else 1278173007419932804,
|
||||
"ostrich": 1278176661317095494 if constants.IS_DEBUG else 1278172994669248523,
|
||||
"rhino": 1278176650340864000 if constants.IS_DEBUG else 1278172980848885832,
|
||||
"squirrel": 1278176637627924570 if constants.IS_DEBUG else 1278172965590274088,
|
||||
"chamaleon": 1278176625392877648 if constants.IS_DEBUG else 1278172953305022496,
|
||||
"frog": 1278176614512857088 if constants.IS_DEBUG else 1278172937043710094,
|
||||
"skunk": 1278176604509442079 if constants.IS_DEBUG else 1278172923495976981,
|
||||
"walrus": 1278176554723311678 if constants.IS_DEBUG else 1278172912368619603,
|
||||
"hedgehog": 1278176541750067252 if constants.IS_DEBUG else 1278172899911405678,
|
||||
"pig": 1278176531415564332 if constants.IS_DEBUG else 1278172885239861338,
|
||||
"hen": 1278176518643646485 if constants.IS_DEBUG else 1278172871302184994,
|
||||
"turtle": 1278176507428343891 if constants.IS_DEBUG else 1278172858199314455,
|
||||
"mouse": 1278175957047578696 if constants.IS_DEBUG else 1278172846442414081,
|
||||
"rabbit": 1278175944007487489 if constants.IS_DEBUG else 1278172831921999945,
|
||||
"goat": 1278175933915856938 if constants.IS_DEBUG else 1278172815421472808,
|
||||
"lion": 1278175922964664382 if constants.IS_DEBUG else 1278172803534684235,
|
||||
"wolf": 1278175902047535165 if constants.IS_DEBUG else 1278172791052570687,
|
||||
"snake": 1278175869331968061 if constants.IS_DEBUG else 1278172776259260547,
|
||||
"llama": 1278175850008674325 if constants.IS_DEBUG else 1278172764913799168,
|
||||
"crab": 1278175835634925620 if constants.IS_DEBUG else 1278172752322232330,
|
||||
"owl": 1278175822947029152 if constants.IS_DEBUG else 1278172738309193749,
|
||||
"bat": 1278175813572886550 if constants.IS_DEBUG else 1278172726426865724,
|
||||
"zebra": 1278175799962505216 if constants.IS_DEBUG else 1278172715114565753,
|
||||
"fox": 1278175788818104474 if constants.IS_DEBUG else 1278172702955274292,
|
||||
"gorilla": 1278175775891259456 if constants.IS_DEBUG else 1278172687797194824,
|
||||
"sloth": 1278175765661356175 if constants.IS_DEBUG else 1278172676837343242,
|
||||
"puffer fish": 1278175753497743360 if constants.IS_DEBUG else 1278172665093558294,
|
||||
"bear": 1278175741569142867 if constants.IS_DEBUG else 1278172653076746301,
|
||||
"giraffe": 1278175732601983006 if constants.IS_DEBUG else 1278172640296702073,
|
||||
"penguin": 1278175721956573236 if constants.IS_DEBUG else 1278172627629899808,
|
||||
"parrot": 1278175706798489642 if constants.IS_DEBUG else 1278172600702337074,
|
||||
}
|
||||
|
||||
|
||||
def get_random_mask(not_int: list[str] = []) -> str:
|
||||
"""Returns a random mask.
|
||||
|
||||
Args:
|
||||
not_int (list[str], optional): Masks to exclude. Defaults to [].
|
||||
|
||||
Returns:
|
||||
str: The mask.
|
||||
"""
|
||||
|
||||
return random.choice([mask for mask in MASKS if mask not in not_int])
|
||||
|
||||
|
||||
def get_mask_name(mask: str) -> str:
|
||||
"""Returns the name of the mask.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The name of the mask.
|
||||
"""
|
||||
|
||||
return mask.title()
|
||||
|
||||
|
||||
def get_mask_image_url(mask: str) -> str:
|
||||
"""Returns the URL of the mask image.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The URL of the mask image.
|
||||
"""
|
||||
|
||||
return f"https://cdn.discordapp.com/emojis/{MASKS[mask]}.png"
|
||||
|
||||
|
||||
def get_mask_emoji(mask: str) -> str:
|
||||
"""Returns the emoji of the mask.
|
||||
|
||||
Args:
|
||||
mask (str): The mask.
|
||||
|
||||
Returns:
|
||||
str: The emoji of the mask.
|
||||
"""
|
||||
|
||||
return f"<:{mask.replace(" ", "")}:{MASKS[mask]}>"
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "report" ADD "user_avatar_url" TEXT NOT NULL;
|
||||
ALTER TABLE "report" ADD "user_name" TEXT NOT NULL;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "report" DROP COLUMN "user_avatar_url";
|
||||
ALTER TABLE "report" DROP COLUMN "user_name";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -0,0 +1,21 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "user" ADD "warns_count" INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "user" ADD "bans_count" INT NOT NULL DEFAULT 0;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "user" DROP COLUMN "warns_count";
|
||||
ALTER TABLE "user" DROP COLUMN "bans_count";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -0,0 +1,24 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
CREATE TABLE IF NOT EXISTS "server" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"prefix" TEXT NOT NULL,
|
||||
"language" TEXT NOT NULL
|
||||
);
|
||||
COMMENT ON TABLE "server" IS 'A server.';;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
DROP TABLE IF EXISTS "server";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
@@ -0,0 +1,19 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "server" ADD "name" TEXT NOT NULL;
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX "idx_channel_server__d940e4";
|
||||
DROP INDEX "idx_ban_user_id_37a9cf";
|
||||
ALTER TABLE "server" DROP COLUMN "name";
|
||||
CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");;
|
||||
CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;"""
|
||||
+100
-2
@@ -4,6 +4,10 @@ import enum
|
||||
|
||||
from tortoise import models, fields
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
import orjson
|
||||
|
||||
|
||||
class Report(models.Model):
|
||||
"""
|
||||
@@ -14,6 +18,8 @@ class Report(models.Model):
|
||||
message_content = fields.TextField()
|
||||
channel_id = fields.BigIntField()
|
||||
user_id = fields.BigIntField()
|
||||
user_name = fields.TextField()
|
||||
user_avatar_url = fields.TextField()
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
resolved = fields.BooleanField(default=False, index=True)
|
||||
resolved_at = fields.DatetimeField(null=True)
|
||||
@@ -37,12 +43,15 @@ class User(models.Model):
|
||||
|
||||
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 flags(self) -> UserFlag:
|
||||
def flag(self) -> UserFlag:
|
||||
"""
|
||||
The flags of the user.
|
||||
"""
|
||||
@@ -60,6 +69,69 @@ class User(models.Model):
|
||||
|
||||
return flags
|
||||
|
||||
@classmethod
|
||||
async def get_cache_or_db(cls, redis_client: redis.Redis, user_id: int) -> "User":
|
||||
"""
|
||||
Returns the user from the cache or the database.
|
||||
|
||||
Args:
|
||||
redis_client (redis.Redis): The Redis client.
|
||||
user_id (int): The user ID.
|
||||
|
||||
Returns:
|
||||
(User): The user.
|
||||
"""
|
||||
|
||||
cache = await redis_client.get(f"user:{user_id}")
|
||||
|
||||
instance = User(id=user_id)
|
||||
|
||||
if cache is not None:
|
||||
cache = orjson.loads(cache)
|
||||
|
||||
instance.warns_count = cache["warns_count"]
|
||||
instance.bans_count = cache["bans_count"]
|
||||
instance.is_masked = cache["is_masked"]
|
||||
instance.is_moderator = cache["is_moderator"]
|
||||
instance.is_supporter_until = (
|
||||
datetime.fromisoformat(cache["is_supporter_until"])
|
||||
if cache["is_supporter_until"] is not None
|
||||
else None
|
||||
)
|
||||
|
||||
else:
|
||||
instance, _ = await User.update_or_create(id=user_id)
|
||||
|
||||
await instance.set_cache(redis_client)
|
||||
|
||||
return instance
|
||||
|
||||
async def set_cache(self, redis_client: redis.Redis) -> None:
|
||||
"""
|
||||
Sets the user in the cache.
|
||||
|
||||
Args:
|
||||
redis_client (redis.Redis): The Redis client.
|
||||
"""
|
||||
|
||||
await redis_client.set(
|
||||
f"user:{self.id}",
|
||||
orjson.dumps(
|
||||
{
|
||||
"warns_count": self.warns_count,
|
||||
"bans_count": self.bans_count,
|
||||
"is_masked": self.is_masked,
|
||||
"is_moderator": self.is_moderator,
|
||||
"is_supporter_until": (
|
||||
self.is_supporter_until.timestamp()
|
||||
if self.is_supporter_until
|
||||
else None
|
||||
),
|
||||
}
|
||||
),
|
||||
ex=60 * 15,
|
||||
)
|
||||
|
||||
|
||||
class Ban(models.Model):
|
||||
"""
|
||||
@@ -91,7 +163,7 @@ class Channel(models.Model):
|
||||
id = fields.BigIntField(pk=True)
|
||||
server_id = fields.BigIntField(index=True)
|
||||
|
||||
is_enabled = fields.BooleanField(default=True)
|
||||
is_enabled = fields.BooleanField(null=True)
|
||||
|
||||
class Meta:
|
||||
"""
|
||||
@@ -101,3 +173,29 @@ class Channel(models.Model):
|
||||
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)
|
||||
|
||||
+4
-16
@@ -1,26 +1,14 @@
|
||||
[project]
|
||||
name = "payphone-2"
|
||||
version = "0.1.0"
|
||||
name = "payphone"
|
||||
version = "0.18.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aerich==0.7.1",
|
||||
"aiohttp>=3.10.10",
|
||||
"orjson>=3.10.11",
|
||||
"py-cord>=2.6.1",
|
||||
"redis[hiredis]>=5.2.0",
|
||||
"rich>=13.9.4",
|
||||
"tortoise-orm[accel,asyncpg]>=0.21.7",
|
||||
"typer>=0.13.0",
|
||||
]
|
||||
dependencies = [ "aerich==0.7.1", "aiohttp>=3.10.10", "orjson>=3.10.11", "py-cord>=2.6.1", "redis[hiredis]>=5.2.0", "rich>=13.9.4", "tortoise-orm[accel,asyncpg]>=0.21.7", "typer>=0.13.0",]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"python-dotenv>=1.0.1",
|
||||
]
|
||||
dev = [ "python-dotenv>=1.0.1", "toml>=0.10.2",]
|
||||
|
||||
[tool]
|
||||
[tool.aerich]
|
||||
tortoise_orm = "payphone.db.setup.CONFIG"
|
||||
location = "./payphone/db/migrations"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
import sys
|
||||
import getpass
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
||||
except ImportError:
|
||||
print(
|
||||
"You need to install `requests` to use this file. Run `pip install requests` and try "
|
||||
"again."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
username = input("Username for git.nyeki.dev: ")
|
||||
password = getpass.getpass("Password for git.nyeki.dev: ")
|
||||
|
||||
tags = requests.get(
|
||||
"https://git.nyeki.dev/v2/payphone/payphone-shard/tags/list",
|
||||
auth=(username, password),
|
||||
).json()["tags"]
|
||||
|
||||
latest_version = 1
|
||||
|
||||
for tag in tags:
|
||||
tag: str
|
||||
|
||||
if tag[1:].isdecimal():
|
||||
version = int(tag[1:])
|
||||
|
||||
if version > latest_version:
|
||||
latest_version = version
|
||||
|
||||
print(f"Updating to tag v{version}...")
|
||||
|
||||
os.system(f"git pull https://{username}:{password}@git.nyeki.dev/payphone/payphone")
|
||||
os.system(
|
||||
f"microk8s kubectl set image sts/bot bot=git.nyeki.dev/payphone/payphone-shard:v{version}"
|
||||
)
|
||||
os.system(
|
||||
f"""
|
||||
microk8s kubectl set image deploy/tasks \
|
||||
flush=git.nyeki.dev/payphone/payphone-shard:v{version} \
|
||||
delete-accounts=git.nyeki.dev/payphone/payphone-shard:v{version} \
|
||||
hangup-inactive-calls=git.nyeki.dev/payphone/payphone-shard:v{version}
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import toml
|
||||
|
||||
except ImportError:
|
||||
print(
|
||||
"You need to install `toml` to use this file. Run `pip install toml` and try again."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
data = toml.load(open("pyproject.toml"))
|
||||
version = data["project"]["version"].split(".")
|
||||
version[1] = str(int(version[1]) + 1)
|
||||
data["project"]["version"] = ".".join(version)
|
||||
|
||||
version_num = version[1]
|
||||
|
||||
targets = ["tasks", "shard"]
|
||||
|
||||
for t in targets:
|
||||
tag = f"git.nyeki.dev/payphone/payphone-{t}:v{version_num}"
|
||||
|
||||
os.system(f"docker build --target {t} --tag {tag} .")
|
||||
os.system(f"docker push {tag}")
|
||||
|
||||
tag = f"git.nyeki.dev/payphone/payphone-{t}:latest"
|
||||
|
||||
os.system(f"docker build --target {t} --tag {tag} .")
|
||||
os.system(f"docker push {tag}")
|
||||
|
||||
toml.dump(data, open("pyproject.toml", "w"))
|
||||
@@ -352,8 +352,8 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "payphone-2"
|
||||
version = "0.1.0"
|
||||
name = "payphone"
|
||||
version = "0.18.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aerich" },
|
||||
@@ -369,6 +369,7 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "toml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -384,7 +385,10 @@ requires-dist = [
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "python-dotenv", specifier = ">=1.0.1" }]
|
||||
dev = [
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "toml", specifier = ">=0.10.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
@@ -560,6 +564,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.10.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.13.2"
|
||||
|
||||
Reference in New Issue
Block a user