From 6377cb66c2ba5ac8900d3234efbfaf3c9d0cffcf Mon Sep 17 00:00:00 2001 From: "Nyeki.py" <84998222+Nekidev@users.noreply.github.com> Date: Fri, 15 Nov 2024 23:24:11 -0300 Subject: [PATCH] feat(shard): Lots of code. --- .dockerignore | 166 ++++++++++ Dockerfile | 17 + README.md | 264 ++++++++++++++++ k8s/bot.yml | 63 ++++ k8s/config.yml | 6 + k8s/psql.yml | 64 ++++ k8s/redis.yml | 35 +++ k8s/secrets.yml | 7 + k8s/tasks.yml | 29 ++ payphone/bot.py | 294 +++++++++++++++++- payphone/cogs/admin/cog.py | 79 ++++- payphone/cogs/admin/embeds.py | 33 ++ payphone/cogs/admin/views.py | 197 ++++++++++++ payphone/cogs/base.py | 7 + payphone/cogs/calls/cog.py | 203 +++++++++--- payphone/cogs/mods/__init__.py | 0 payphone/cogs/mods/cog.py | 139 +++++++++ payphone/core/calls.py | 161 +++++++++- payphone/core/censorship.py | 12 +- payphone/core/checks.py | 44 +++ payphone/core/constants.py | 1 + payphone/core/exceptions.py | 17 + payphone/core/formatting.py | 221 +++++++++++++ payphone/core/help_command.py | 152 +++++++++ payphone/core/masks.py | 109 +++++++ payphone/core/utils.py | 25 ++ .../models/6_20241114014251_update.py | 21 ++ .../models/7_20241114024101_update.py | 21 ++ .../models/8_20241115225636_update.py | 24 ++ .../models/9_20241115230145_update.py | 19 ++ payphone/db/models.py | 102 +++++- pyproject.toml | 20 +- scripts/deploy.py | 47 +++ scripts/update.py | 33 ++ uv.lock | 19 +- 35 files changed, 2546 insertions(+), 105 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 k8s/bot.yml create mode 100644 k8s/config.yml create mode 100644 k8s/psql.yml create mode 100644 k8s/redis.yml create mode 100644 k8s/secrets.yml create mode 100644 k8s/tasks.yml create mode 100644 payphone/cogs/admin/embeds.py create mode 100644 payphone/cogs/admin/views.py create mode 100644 payphone/cogs/base.py create mode 100644 payphone/cogs/mods/__init__.py create mode 100644 payphone/cogs/mods/cog.py create mode 100644 payphone/core/checks.py create mode 100644 payphone/core/exceptions.py create mode 100644 payphone/core/help_command.py create mode 100644 payphone/core/masks.py create mode 100644 payphone/core/utils.py create mode 100644 payphone/db/migrations/models/6_20241114014251_update.py create mode 100644 payphone/db/migrations/models/7_20241114024101_update.py create mode 100644 payphone/db/migrations/models/8_20241115225636_update.py create mode 100644 payphone/db/migrations/models/9_20241115230145_update.py create mode 100644 scripts/deploy.py create mode 100644 scripts/update.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f7fae1e --- /dev/null +++ b/.dockerignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..679000e --- /dev/null +++ b/Dockerfile @@ -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" ] diff --git a/README.md b/README.md index e69de29..bbb7757 100644 --- a/README.md +++ b/README.md @@ -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= --docker-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 +``` diff --git a/k8s/bot.yml b/k8s/bot.yml new file mode 100644 index 0000000..1b038a9 --- /dev/null +++ b/k8s/bot.yml @@ -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 diff --git a/k8s/config.yml b/k8s/config.yml new file mode 100644 index 0000000..262f725 --- /dev/null +++ b/k8s/config.yml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: bot-config +data: + IS_DEBUG: "false" diff --git a/k8s/psql.yml b/k8s/psql.yml new file mode 100644 index 0000000..7251720 --- /dev/null +++ b/k8s/psql.yml @@ -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 diff --git a/k8s/redis.yml b/k8s/redis.yml new file mode 100644 index 0000000..ef14cdb --- /dev/null +++ b/k8s/redis.yml @@ -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 diff --git a/k8s/secrets.yml b/k8s/secrets.yml new file mode 100644 index 0000000..6fd2315 --- /dev/null +++ b/k8s/secrets.yml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: bot-secrets +type: Opaque +data: + TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz diff --git a/k8s/tasks.yml b/k8s/tasks.yml new file mode 100644 index 0000000..3ed268c --- /dev/null +++ b/k8s/tasks.yml @@ -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 diff --git a/payphone/bot.py b/payphone/bot.py index e3f53e1..d0d2b7a 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -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) diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py index e0b6d54..df36da3 100644 --- a/payphone/cogs/admin/cog.py +++ b/payphone/cogs/admin/cog.py @@ -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. + + Options: + - ``: The user to give the role. + - `[toggle]`: Whether to give or remove the role. - await models.User.update_or_create( + 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. + + Options: + - ``: The user to give the role. + - `[toggle]`: Whether to give or remove the role. - await models.User.update_or_create( + 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. diff --git a/payphone/cogs/admin/embeds.py b/payphone/cogs/admin/embeds.py new file mode 100644 index 0000000..4294d86 --- /dev/null +++ b/payphone/cogs/admin/embeds.py @@ -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**: \n" + ), + fields=[ + discord.EmbedField( + name="Message Content", value=report.message_content, inline=False + ) + ], + thumbnail=report.user_avatar_url, + ) diff --git a/payphone/cogs/admin/views.py b/payphone/cogs/admin/views.py new file mode 100644 index 0000000..a712d27 --- /dev/null +++ b/payphone/cogs/admin/views.py @@ -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" " + 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) + ) diff --git a/payphone/cogs/base.py b/payphone/cogs/base.py new file mode 100644 index 0000000..3ae1872 --- /dev/null +++ b/payphone/cogs/base.py @@ -0,0 +1,7 @@ +from discord.ext import commands + + +class Cog(commands.Cog): + """Base cog with Payphone-specific properties.""" + + show_in_help: bool = True diff --git a/payphone/cogs/calls/cog.py b/payphone/cogs/calls/cog.py index 62a355f..386f216 100644 --- a/payphone/cogs/calls/cog.py +++ b/payphone/cogs/calls/cog.py @@ -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]()." ) - 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, }, ) diff --git a/payphone/cogs/mods/__init__.py b/payphone/cogs/mods/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payphone/cogs/mods/cog.py b/payphone/cogs/mods/cog.py new file mode 100644 index 0000000..3f1c2f8 --- /dev/null +++ b/payphone/cogs/mods/cog.py @@ -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 ` 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: + - ``: 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: + - `[]`: 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: + - `[]`: 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)) diff --git a/payphone/core/calls.py b/payphone/core/calls.py index c812c5d..13e9a89 100644 --- a/payphone/core/calls.py +++ b/payphone/core/calls.py @@ -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: """ diff --git a/payphone/core/censorship.py b/payphone/core/censorship.py index d98ea42..e850ab1 100644 --- a/payphone/core/censorship.py +++ b/payphone/core/censorship.py @@ -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) diff --git a/payphone/core/checks.py b/payphone/core/checks.py new file mode 100644 index 0000000..938acc1 --- /dev/null +++ b/payphone/core/checks.py @@ -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) diff --git a/payphone/core/constants.py b/payphone/core/constants.py index b251dd6..564ba50 100644 --- a/payphone/core/constants.py +++ b/payphone/core/constants.py @@ -29,3 +29,4 @@ BRAILE_CHARACTER = "\u2800" PRIMARY_COLOR = 0xE2D9D0 SUPPORTER_COLOR = 0xAB9EFF MODERATOR_COLOR = 0xFF8787 +MASK_COLOR = 0xFFC868 diff --git a/payphone/core/exceptions.py b/payphone/core/exceptions.py new file mode 100644 index 0000000..1b02823 --- /dev/null +++ b/payphone/core/exceptions.py @@ -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.""" diff --git a/payphone/core/formatting.py b/payphone/core/formatting.py index e69de29..732b3a7 100644 --- a/payphone/core/formatting.py +++ b/payphone/core/formatting.py @@ -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) diff --git a/payphone/core/help_command.py b/payphone/core/help_command.py new file mode 100644 index 0000000..116b6d6 --- /dev/null +++ b/payphone/core/help_command.py @@ -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 `." + ), + 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 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 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}") diff --git a/payphone/core/masks.py b/payphone/core/masks.py new file mode 100644 index 0000000..d638a67 --- /dev/null +++ b/payphone/core/masks.py @@ -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]}>" diff --git a/payphone/core/utils.py b/payphone/core/utils.py new file mode 100644 index 0000000..693893e --- /dev/null +++ b/payphone/core/utils.py @@ -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 diff --git a/payphone/db/migrations/models/6_20241114014251_update.py b/payphone/db/migrations/models/6_20241114014251_update.py new file mode 100644 index 0000000..e1b9a07 --- /dev/null +++ b/payphone/db/migrations/models/6_20241114014251_update.py @@ -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");;""" diff --git a/payphone/db/migrations/models/7_20241114024101_update.py b/payphone/db/migrations/models/7_20241114024101_update.py new file mode 100644 index 0000000..9607b60 --- /dev/null +++ b/payphone/db/migrations/models/7_20241114024101_update.py @@ -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");;""" diff --git a/payphone/db/migrations/models/8_20241115225636_update.py b/payphone/db/migrations/models/8_20241115225636_update.py new file mode 100644 index 0000000..c22000b --- /dev/null +++ b/payphone/db/migrations/models/8_20241115225636_update.py @@ -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");;""" diff --git a/payphone/db/migrations/models/9_20241115230145_update.py b/payphone/db/migrations/models/9_20241115230145_update.py new file mode 100644 index 0000000..6c484c7 --- /dev/null +++ b/payphone/db/migrations/models/9_20241115230145_update.py @@ -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");;""" diff --git a/payphone/db/models.py b/payphone/db/models.py index 54d8c8f..e4468e1 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index b935517..24f1625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..0e03007 --- /dev/null +++ b/scripts/deploy.py @@ -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} +""" +) diff --git a/scripts/update.py b/scripts/update.py new file mode 100644 index 0000000..6362056 --- /dev/null +++ b/scripts/update.py @@ -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")) diff --git a/uv.lock b/uv.lock index e867467..9afb9bf 100644 --- a/uv.lock +++ b/uv.lock @@ -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"