feat(shard): Lots of code.

This commit is contained in:
Nyeki.py
2024-11-15 23:24:11 -03:00
parent d9d752c730
commit 6377cb66c2
35 changed files with 2546 additions and 105 deletions
+166
View File
@@ -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
View File
@@ -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" ]
+264
View File
@@ -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
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: bot-config
data:
IS_DEBUG: "false"
+64
View File
@@ -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
+35
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: bot-secrets
type: Opaque
data:
TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz
+29
View File
@@ -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
View File
@@ -1,10 +1,17 @@
from typing import Literal
from datetime import datetime, timezone
import discord import discord
from discord.ext import commands from discord.ext import commands
from discord.ext.commands import errors
from tortoise import expressions
import redis.asyncio as redis import redis.asyncio as redis
from payphone.db import setup, models 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): class Payphone(commands.AutoShardedBot):
@@ -29,9 +36,10 @@ class Payphone(commands.AutoShardedBot):
command_prefix="p.", command_prefix="p.",
strip_after_prefix=True, strip_after_prefix=True,
case_insensitive=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.load_extension(f"payphone.cogs.{cog}.cog")
self.log.info("Loaded cog %s.", cog) self.log.info("Loaded cog %s.", cog)
@@ -59,7 +67,7 @@ class Payphone(commands.AutoShardedBot):
shard_id=shard_id, 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. """Runs when the shard connects.
Args: Args:
@@ -99,7 +107,8 @@ class Payphone(commands.AutoShardedBot):
channel_id (int): The channel's ID. channel_id (int): The channel's ID.
Returns: 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) channel = self.get_channel(channel_id)
@@ -109,6 +118,77 @@ class Payphone(commands.AutoShardedBot):
return channel 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: def clean_message(self, message: discord.Message, call) -> str:
"""Cleans a message. """Cleans a message.
@@ -138,29 +218,205 @@ class Payphone(commands.AutoShardedBot):
return content return content
async def get_user_flag(self, user_id: int) -> str: async def is_moderator(self, user_id: int) -> bool:
"""Gets the flags of a user. """Checks whether a user is a moderator.
Args: Args:
user_id (int): The ID of the user. user_id (int): The user to check's ID.
Returns: 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: return user.is_moderator
user, _ = await models.User.get_or_create(id=user_id)
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: 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(): def run():
@@ -168,6 +424,14 @@ def run():
bot = Payphone() bot = Payphone()
try:
import uvloop
uvloop.install()
except ImportError:
pass
bot.loop.run_until_complete(setup.init()) bot.loop.run_until_complete(setup.init())
bot.run(constants.TOKEN) bot.run(constants.TOKEN)
+73 -6
View File
@@ -5,11 +5,16 @@ from discord.ext import commands
from payphone.db import models from payphone.db import models
from payphone.bot import Payphone 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.""" """Administration in Payphone."""
show_in_help = False
def __init__(self, bot: Payphone) -> None: def __init__(self, bot: Payphone) -> None:
"""Initializes the Calls cog. """Initializes the Calls cog.
@@ -22,26 +27,69 @@ class Admin(commands.Cog):
@commands.command(aliases=["mod"]) @commands.command(aliases=["mod"])
@commands.is_owner() @commands.is_owner()
async def moderator( async def moderator(
self, ctx: commands.Context, user: discord.User, toggle: bool self, ctx: commands.Context, user: discord.User, toggle: bool = True
) -> None: ) -> None:
"""Gives the user the Moderator role.""" """Gives the user the Moderator role.
Options:
- `<user>`: 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} id=user.id, defaults={"is_moderator": toggle}
) )
await instance.set_cache(self.bot.redis)
await ctx.send( await ctx.send(
f"Toggled the moderator role {'on' if toggle else 'off'} for {user.mention}." 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.command(aliases=["sup"])
@commands.is_owner() @commands.is_owner()
async def supporter( async def supporter(
self, ctx: commands.Context, user: discord.User, toggle: bool = True self, ctx: commands.Context, user: discord.User, toggle: bool = True
) -> None: ) -> None:
"""Gives the user the Supporter role.""" """Gives the user the Supporter role.
Options:
- `<user>`: 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, id=user.id,
defaults={ defaults={
"is_supporter_until": ( "is_supporter_until": (
@@ -49,11 +97,30 @@ class Admin(commands.Cog):
) )
}, },
) )
await instance.set_cache(self.bot.redis)
await ctx.send( await ctx.send(
f"Gave the Supporter role to {ctx.author.mention} until in one month." 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: def setup(bot: Payphone) -> None:
"""Loads the Admin cog into the bot. """Loads the Admin cog into the bot.
+33
View File
@@ -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,
)
+197
View File
@@ -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)
)
+7
View File
@@ -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
View File
@@ -9,10 +9,11 @@ import aiohttp
from payphone.db import models from payphone.db import models
from payphone.bot import Payphone 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.""" """Calls in Payphone."""
def __init__(self, bot: Payphone) -> None: def __init__(self, bot: Payphone) -> None:
@@ -24,7 +25,8 @@ class Calls(commands.Cog):
self.bot = bot self.bot = bot
@commands.command() @commands.command(aliases=["c"])
@checks.is_not_banned()
async def call(self, ctx: commands.Context) -> None: async def call(self, ctx: commands.Context) -> None:
""" """
Call and connect with other people. 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: async def skip(self, ctx: commands.Context) -> None:
""" """
Skip the current call and search for another one. Skip the current call and search for another one.
@@ -76,7 +79,8 @@ class Calls(commands.Cog):
f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}" 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: async def hangup(self, ctx: commands.Context) -> None:
""" """
Hang up the call. 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: async def friendreq(self, ctx: commands.Context) -> None:
""" """
Send a friend request to the other party. 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: if call is None:
return await ctx.send( return await ctx.send(
@@ -125,26 +130,63 @@ class Calls(commands.Cog):
ctx.send("📬 Your friend request has been sent!"), 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() @commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None: async def on_message(self, message: discord.Message) -> None:
""" """
Handle messages in calls. 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 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: if call is None:
return return
is_new_participant = not bool(await call.get_participant(message.author.id)) participant, was_mask_toggled = await call.set_participant(
await call.set_participant(
message.author.id, message.author.id,
message.author.display_name, message.author.display_name,
message.author.display_avatar.url, message.author.display_avatar.url,
message.channel.id,
) )
other_channel = ( other_channel = (
@@ -153,9 +195,14 @@ class Calls(commands.Cog):
else call.channels[1] else call.channels[1]
) )
user_flag = await self.bot.get_user_flag(message.author.id) blocked_section = (
censorship.censor(
blocked_section = censorship.censor(message.content) message.content,
participant.user.flag & models.UserFlag.SUPPORTER,
)
if not (participant.user.flag & models.UserFlag.MODERATOR)
else None
)
if blocked_section: if blocked_section:
return await asyncio.gather( return await asyncio.gather(
@@ -178,18 +225,19 @@ class Calls(commands.Cog):
message_content = self.bot.clean_message(message, call) message_content = self.bot.clean_message(message, call)
if message.stickers: 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] sticker = message.stickers[0]
if sticker.format != discord.StickerFormatType.lottie: if sticker.format != discord.StickerFormatType.lottie:
message_content = f"[Sticker: {sticker.name}]({sticker.url})" message_content = f"[Sticker: {sticker.name}]({sticker.url})"
else: else:
await message.channel.send( return await message.channel.send(
"💖 Stickers are only allowed to donators! You can become one at [our Ko-Fi " "💖 Stickers and GIFs are only allowed to donators! You can become one at [our Ko-Fi "
"page](https://ko-fi.com/payphonedev)." "page](<https://ko-fi.com/payphonedev>)."
) )
return
embeds = [] embeds = []
@@ -202,38 +250,91 @@ class Calls(commands.Cog):
message.reference.message_id message.reference.message_id
) )
replied_message_content = self.bot.clean_message(replied_message, call) original_message_id = (
await self.bot.call_handler.get_original_message_id(
embeds.append( replied_message.id
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,
),
) )
) )
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: else:
message_content += ( message_content += (
"\n-# ⓘ In reply to a message from outside this call." "\n-# ⓘ In reply to a message from outside this call."
) )
if is_new_participant: if was_mask_toggled:
if user_flag & models.UserFlag.SUPPORTER: if not participant.is_masked:
embeds.append( if participant.user.flag & models.UserFlag.SUPPORTER:
discord.Embed( embeds.append(
color=constants.SUPPORTER_COLOR, discord.Embed(
description=f"🌟 **{message.author.display_name}** is a Payphone supporter!", 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( embeds.append(
discord.Embed( discord.Embed(
color=constants.MODERATOR_COLOR, color=constants.MASK_COLOR,
description=f"🛡️ **{message.author.display_name}** is a Payphone moderator!", 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( propagated_msg = await webhook.send(
content=message_content, content=message_content,
username=message.author.display_name, username=participant.display_name,
avatar_url=message.author.display_avatar.url, avatar_url=participant.display_avatar_url,
embeds=embeds, embeds=embeds,
wait=True, wait=True,
) )
@@ -267,7 +368,7 @@ class Calls(commands.Cog):
Handle deleted messages in calls. 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: if call is None:
return return
@@ -296,7 +397,7 @@ class Calls(commands.Cog):
Handle edited messages in calls. 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: if call is None:
return return
@@ -342,6 +443,11 @@ class Calls(commands.Cog):
Report a message in a call. 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) author_id = await self.bot.call_handler.get_message_author_id(message.id)
if author_id is None: if author_id is None:
@@ -363,11 +469,20 @@ class Calls(commands.Cog):
else: else:
message_id = message.id 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( await models.Report.update_or_create(
message_id=message_id, message_id=message_id,
defaults={ defaults={
"channel_id": message.channel.id, "channel_id": message.channel.id,
"user_id": author_id, "user_id": author_id,
"user_name": message.author.display_name,
"user_avatar_url": message.author.display_avatar.url,
"message_content": message.content, "message_content": message.content,
}, },
) )
View File
+139
View File
@@ -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
View File
@@ -2,7 +2,9 @@ from dataclasses import dataclass
import orjson import orjson
from payphone.db import models
from payphone.bot import Payphone from payphone.bot import Payphone
from payphone.core import masks
@dataclass @dataclass
@@ -14,6 +16,44 @@ class Participant:
id: int id: int
name: str name: str
avatar_url: 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 @dataclass
@@ -24,6 +64,7 @@ class Call:
id: int id: int
channels: tuple[int, int] channels: tuple[int, int]
masks: list[str]
async def set_message( async def set_message(
self, id: int, channel_id: int, propagation_id: int, author_id: int self, id: int, channel_id: int, propagation_id: int, author_id: int
@@ -54,6 +95,38 @@ class Call:
await pipe.execute() 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: async def is_message_from_call(self, id: int) -> bool:
"""Checks if a message is from a call. """Checks if a message is from a call.
@@ -97,27 +170,55 @@ class Call:
return int(propagation_channel_id), int(propagation_id) return int(propagation_channel_id), int(propagation_id)
async def set_participant( async def set_participant(
self, user_id: int, user_name: str, user_avatar_url: str self, user_id: int, user_name: str, user_avatar_url: str, channel_id: int
) -> Participant: ) -> tuple[Participant, bool]:
"""Sets a participant in a call. """Sets a participant in a call.
Args: Args:
user_id (int): The ID of the user to set. user_id (int): The ID of the user to set.
user_name (str): The name 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. 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: 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.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.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( 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: async def get_participant(self, user_id: int) -> Participant | None:
"""Gets a participant in a call. """Gets a participant in a call.
@@ -137,7 +238,13 @@ class Call:
data = orjson.loads(data) data = orjson.loads(data)
return Participant( 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"]: async def get_participants(self) -> list["Participant"]:
@@ -160,6 +267,9 @@ class Call:
id=int(user_id), id=int(user_id),
name=data["name"], name=data["name"],
avatar_url=data["avatar_url"], 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) 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. """Gets the call that a channel is in.
Args: Args:
@@ -332,6 +442,20 @@ class CallHandler:
if call_id is None: if call_id is None:
return 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 = [ channels = [
int(c) int(c)
for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( 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]: async def hangup(self, channel_id: int) -> tuple[int, int] | tuple[int]:
"""Ends a call in a channel. """Ends a call in a channel.
@@ -437,17 +561,24 @@ class CallHandler:
return webhook.url return webhook.url
async def get_message(self, message_id: int) -> None: async def get_message(self, message_id: int) -> Message | None:
"""Gets a message as part of a call. """Gets a message sent in a call.
Args: Args:
message_id (int): The ID of the message to get. message_id (int): The ID of the message to get.
Returns: 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: async def get_message_author_id(self, id: int) -> int | None:
"""Gets the author of a message as part of a call. """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 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: async def get_original_message_id(self, id: int) -> int | None:
""" """
+8 -4
View File
@@ -17,11 +17,12 @@ URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE)
INVITES_PATTERN = re.compile(r"(discord)?.gg/[^\s]+", re.IGNORECASE) INVITES_PATTERN = re.compile(r"(discord)?.gg/[^\s]+", re.IGNORECASE)
def censor(message: str) -> str: def censor(message: str, is_gif_allowed: bool = False) -> str:
"""Returns the first censored word or pattern found. """Returns the first censored word or pattern found.
Args: Args:
message (str): The message to moderate. message (str): The message to moderate.
is_gif_allowed (bool, optional): If GIFs are allowed. Defaults to False.
Returns: Returns:
str | None: The blocked pattern found as it was found. str | None: The blocked pattern found as it was found.
@@ -32,10 +33,13 @@ def censor(message: str) -> str:
if PATTERN.match(word): if PATTERN.match(word):
return 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: if is_gif_allowed and string.startswith("https://tenor.com/view/"):
return message[match.start() : match.end()] continue
return string
match = next(INVITES_PATTERN.finditer(message), None) match = next(INVITES_PATTERN.finditer(message), None)
+44
View File
@@ -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)
+1
View File
@@ -29,3 +29,4 @@ BRAILE_CHARACTER = "\u2800"
PRIMARY_COLOR = 0xE2D9D0 PRIMARY_COLOR = 0xE2D9D0
SUPPORTER_COLOR = 0xAB9EFF SUPPORTER_COLOR = 0xAB9EFF
MODERATOR_COLOR = 0xFF8787 MODERATOR_COLOR = 0xFF8787
MASK_COLOR = 0xFFC868
+17
View File
@@ -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."""
+221
View File
@@ -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)
+152
View File
@@ -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}")
+109
View File
@@ -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]}>"
+25
View File
@@ -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
View File
@@ -4,6 +4,10 @@ import enum
from tortoise import models, fields from tortoise import models, fields
import redis.asyncio as redis
import orjson
class Report(models.Model): class Report(models.Model):
""" """
@@ -14,6 +18,8 @@ class Report(models.Model):
message_content = fields.TextField() message_content = fields.TextField()
channel_id = fields.BigIntField() channel_id = fields.BigIntField()
user_id = fields.BigIntField() user_id = fields.BigIntField()
user_name = fields.TextField()
user_avatar_url = fields.TextField()
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
resolved = fields.BooleanField(default=False, index=True) resolved = fields.BooleanField(default=False, index=True)
resolved_at = fields.DatetimeField(null=True) resolved_at = fields.DatetimeField(null=True)
@@ -37,12 +43,15 @@ class User(models.Model):
id = fields.BigIntField(pk=True) 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_masked = fields.BooleanField(default=False)
is_moderator = fields.BooleanField(default=False) is_moderator = fields.BooleanField(default=False)
is_supporter_until = fields.DatetimeField(null=True) is_supporter_until = fields.DatetimeField(null=True)
@property @property
def flags(self) -> UserFlag: def flag(self) -> UserFlag:
""" """
The flags of the user. The flags of the user.
""" """
@@ -60,6 +69,69 @@ class User(models.Model):
return flags 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): class Ban(models.Model):
""" """
@@ -91,7 +163,7 @@ class Channel(models.Model):
id = fields.BigIntField(pk=True) id = fields.BigIntField(pk=True)
server_id = fields.BigIntField(index=True) server_id = fields.BigIntField(index=True)
is_enabled = fields.BooleanField(default=True) is_enabled = fields.BooleanField(null=True)
class Meta: class Meta:
""" """
@@ -101,3 +173,29 @@ class Channel(models.Model):
indexes = [ indexes = [
models.Index(fields=["server_id", "is_enabled"]), 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
View File
@@ -1,26 +1,14 @@
[project] [project]
name = "payphone-2" name = "payphone"
version = "0.1.0" version = "0.18.0"
description = "Add your description here" description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ 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",]
"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] [dependency-groups]
dev = [ dev = [ "python-dotenv>=1.0.1", "toml>=0.10.2",]
"python-dotenv>=1.0.1",
]
[tool]
[tool.aerich] [tool.aerich]
tortoise_orm = "payphone.db.setup.CONFIG" tortoise_orm = "payphone.db.setup.CONFIG"
location = "./payphone/db/migrations" location = "./payphone/db/migrations"
+47
View File
@@ -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}
"""
)
+33
View File
@@ -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"))
Generated
+16 -3
View File
@@ -352,8 +352,8 @@ wheels = [
] ]
[[package]] [[package]]
name = "payphone-2" name = "payphone"
version = "0.1.0" version = "0.18.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aerich" }, { name = "aerich" },
@@ -369,6 +369,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "toml" },
] ]
[package.metadata] [package.metadata]
@@ -384,7 +385,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [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]] [[package]]
name = "propcache" 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 }, { 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]] [[package]]
name = "tomlkit" name = "tomlkit"
version = "0.13.2" version = "0.13.2"