diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 75fa7bf..0000000 --- a/.dockerignore +++ /dev/null @@ -1,164 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -k8s/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 51f7c52..efa407c 100644 --- a/.gitignore +++ b/.gitignore @@ -123,7 +123,6 @@ celerybeat.pid # Environments .env -.env.shared .venv env/ venv/ @@ -160,4 +159,4 @@ cython_debug/ # 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/ +#.idea/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 74bb012..610c3a2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,4 @@ { - "conventionalCommits.scopes": ["bot", "db", "tasks", "scripts"], - "yaml.schemas": { - "kubernetes://schema/apps/v1%40deployment": "file:///c%3A/Users/rafab/Documents/Repos/Payphone-1/k8s.yml" - } + "pylint.args": ["--extension-pkg-allow-list=orjson"], + "conventionalCommits.scopes": ["shard"] } diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 679000e..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -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 bbb7757..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,264 +0,0 @@ -# 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/assets/avatar.png b/assets/avatar.png deleted file mode 100644 index 2ce5967..0000000 Binary files a/assets/avatar.png and /dev/null differ diff --git a/assets/banner.png b/assets/banner.png deleted file mode 100644 index 2a91047..0000000 Binary files a/assets/banner.png and /dev/null differ diff --git a/bot.py b/bot.py index 74d16b1..ca1460b 100644 --- a/bot.py +++ b/bot.py @@ -1,29 +1,8 @@ -import os - from payphone import bot -from payphone.db import setup -from payphone.core import config, logging - - -bot = bot.bot - - -bot.shard_ids = [config.SHARD_ID] -bot.shard_count = config.SHARD_COUNT +from payphone.core import logging logging.setup("Payphone") -async def init() -> None: - """Initializes the DB.""" - - if config.SHARD_ID == 0: - os.system("aerich upgrade") - - await setup.init() - - -bot.loop.run_until_complete(init()) - -bot.run(config.TOKEN) +bot.run() diff --git a/censored.txt b/censored.txt new file mode 100644 index 0000000..d7a22ea --- /dev/null +++ b/censored.txt @@ -0,0 +1,471 @@ +2g1c +2 girls 1 cup +acrotomophilia +alabama hot pocket +alaskan pipeline +anal +anilingus +anus +apeshit +arsehole +asshole +assmunch +auto erotic +autoerotic +babeland +baby batter +baby juice +ball gag +ball gravy +ball kicking +ball licking +ball sack +ball sucking +bangbros +bangbus +bareback +barely legal +barenaked +bastard +bastardo +bastinado +bbw +bdsm +beaner +beaners +beaver cleaver +beaver lips +beastiality +bestiality +big black +big breasts +big knockers +big tits +bimbos +birdlock +bitch +bitches +black cock +blonde action +blonde on blonde action +blowjob +blow job +blow your load +blue waffle +blumpkin +bollocks +bondage +boner +boob +boobs +booty call +brown showers +brunette action +bukkake +bulldyke +bullet vibe +bullshit +bung hole +bunghole +busty +butt +buttcheeks +butthole +camel toe +camgirl +camslut +camwhore +carpet muncher +carpetmuncher +chocolate rosebuds +cialis +circlejerk +cleveland steamer +clit +clitoris +clover clamps +clusterfuck +cock +cocks +coprolagnia +coprophilia +cornhole +coon +coons +creampie +cum +cumming +cumshot +cumshots +cunnilingus +cunt +darkie +date rape +daterape +deep throat +deepthroat +dendrophilia +dick +dildo +dingleberry +dingleberries +dirty pillows +dirty sanchez +doggie style +doggiestyle +doggy style +doggystyle +dog style +dolcett +domination +dominatrix +dommes +donkey punch +double dong +double penetration +dp action +dry hump +dvda +eat my ass +ecchi +ejaculation +erotic +erotism +escort +eunuch +fag +faggot +fecal +felch +fellatio +feltch +female squirting +femdom +figging +fingerbang +fingering +fisting +foot fetish +footjob +frotting +fuck buttons +fucktards +fudge packer +fudgepacker +futanari +gangbang +gang bang +gay sex +genitals +giant cock +girl on +girl on top +girls gone wild +goatcx +goatse +god damn +gokkun +golden shower +goodpoop +goo girl +goregasm +grope +group sex +g-spot +guro +hand job +handjob +hard core +hardcore +hentai +homoerotic +honkey +hooker +horny +hot carl +hot chick +how to kill +how to murder +huge fat +humping +incest +intercourse +jack off +jail bait +jailbait +jelly donut +jerk off +jigaboo +jiggaboo +jiggerboo +jizz +juggs +kike +kinbaku +kinkster +kinky +knobbing +leather restraint +leather straight jacket +lemon party +livesex +lolita +lovemaking +make me come +male squirting +masturbate +masturbating +masturbation +menage a trois +milf +missionary position +mong +motherfucker +mound of venus +mr hands +muff diver +muffdiving +nambla +nawashi +negro +neonazi +nigga +nigger +nig nog +nimphomania +nipple +nipples +nsfw images +nude +nudity +nutten +nympho +nymphomania +octopussy +omorashi +one cup two girls +one guy one jar +orgasm +orgy +paedophile +paki +panties +panty +pedobear +pedophile +pegging +penis +phone sex +piece of shit +pikey +pissing +piss pig +pisspig +playboy +pleasure chest +pole smoker +ponyplay +poof +poon +poontang +punany +poop chute +poopchute +porn +porno +pornography +prince albert piercing +pthc +pubes +pussy +queaf +queef +quim +raghead +raging boner +rape +raping +rapist +rectum +reverse cowgirl +rimjob +rimming +rosy palm +rosy palm and her 5 sisters +rusty trombone +sadism +santorum +scat +schlong +scissoring +semen +sex +sexcam +sexo +sexy +sexual +sexually +sexuality +shaved beaver +shaved pussy +shemale +shibari +shitblimp +shota +shrimping +skeet +slanteye +slut +s&m +smut +snatch +snowballing +sodomize +sodomy +spastic +spic +splooge +splooge moose +spooge +spread legs +spunk +strap on +strapon +strappado +strip club +style doggy +suicide girls +sultry women +swastika +swinger +tainted love +taste my +tea bagging +threesome +throating +thumbzilla +tied up +tight white +spank +sperm +tit +vagina +vulva +whore +ballsack +blowjob +busty +clitoris +cockring +cunt +deepthroat +dominatrix +ejaculation +fetish +gangbang +genital +jizzed +lube +milf +nudity +pecker +pussylick +semen +shemale +slutty +threesome +titfuck +tit +tits +titties +titty +tongue in a +topless +tosser +towelhead +tranny +tribadism +tub girl +tubgirl +tushy +twat +twink +twinkle +two girls one cup +undressing +upskirt +urethra play +urophilia +vagina +venus mound +viagra +vibrator +violet wand +vorarephilia +voyeur +voyeurweb +voyuer +vulva +wank +wetback +wet dream +white powder +whore +worldsex +wrapping men +wrinkled starfish +xxx +yellow showers +yiffy +zoophilia +🖕 +honky +kike +weed +cocaine +heroin +meth +ecstasy +lsd +marijuana +opioid +self-harm +self harm +self-injury +self injury +self-mutilation +self mutilation +hurting myself +abortion +rape +molest +pedophile +incest +abuse +trafficking +domestic violence +child abuse +neglect +victim +heathen +infidel +heretic +blasphemer +pagan +zealot +cultist +fanatic +apostate +idolater +fatty +lardass +twig +beanpole +chubby +porker +skeletor +chunk +blubber +niga +niger +nga \ No newline at end of file diff --git a/dev/compose.yml b/dev-compose.yml similarity index 100% rename from dev/compose.yml rename to dev-compose.yml diff --git a/k8s/bot.yml b/k8s/bot.yml deleted file mode 100644 index 1b038a9..0000000 --- a/k8s/bot.yml +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 7e565c5..0000000 --- a/k8s/config.yml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: bot-config -data: - SUGGESTIONS_CHANNEL_ID: "1287841493343801447" - SERVER_INVITE: "https://discord.gg/Cb3rCrTxmm" - IS_DEBUG: "false" diff --git a/k8s/psql.yml b/k8s/psql.yml deleted file mode 100644 index 7251720..0000000 --- a/k8s/psql.yml +++ /dev/null @@ -1,64 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: postgresql -spec: - type: ClusterIP # Internal access only - selector: - app: postgresql - ports: - - protocol: TCP - port: 5432 - targetPort: 5432 - ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: postgres-storage -spec: - accessModes: - - ReadWriteOnce - storageClassName: openebs-hostpath # Your storage class - resources: - requests: - storage: 20Gi # Adjust storage as needed - ---- -apiVersion: v1 -kind: Pod -metadata: - name: postgresql - labels: - app: postgresql -spec: - containers: - - name: postgres - image: postgres:latest # Standard PostgreSQL image - args: - - -c - - max_connections=2000 - env: - - name: POSTGRES_DB - value: "payphone" - - name: POSTGRES_USER - value: "payphone" - - name: POSTGRES_PASSWORD - value: "payphone" - ports: - - containerPort: 5432 - volumeMounts: - - name: postgres-storage - mountPath: /var/lib/postgresql/data - resources: - requests: - cpu: "1.5" - memory: "3Gi" - limits: - cpu: "2" - memory: "4Gi" - volumes: - - name: postgres-storage - persistentVolumeClaim: - claimName: postgres-storage - restartPolicy: Always diff --git a/k8s/redis.yml b/k8s/redis.yml deleted file mode 100644 index ef14cdb..0000000 --- a/k8s/redis.yml +++ /dev/null @@ -1,35 +0,0 @@ -# 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 deleted file mode 100644 index 6fd2315..0000000 --- a/k8s/secrets.yml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: bot-secrets -type: Opaque -data: - TOKEN: TVRJMU56ZzVOemMxT1RRM09URTNNekl4TWcuR2J0d0dsLk9IVGJmN2JScVZHa0tTeXNPTzRGUXAzQnV0MmxISUF6dGpuZnhz diff --git a/k8s/tasks.yml b/k8s/tasks.yml deleted file mode 100644 index fd106ce..0000000 --- a/k8s/tasks.yml +++ /dev/null @@ -1,42 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: tasks -spec: - containers: - - name: flush - image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "flush"] - resources: - requests: - memory: "250Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "1" - - name: delete-accounts - image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "delete-accounts"] - resources: - requests: - memory: "250Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "1" - - name: hangup-inactive-calls - image: git.nyeki.dev/payphone/payphone-tasks:latest - command: ["uv", "run", "--no-dev", "tasks.py", "hangup-inactive-calls"] - envFrom: - - secretRef: - name: bot-secrets - resources: - requests: - memory: "250Mi" - cpu: "100m" - limits: - memory: "1Gi" - cpu: "1" - restartPolicy: Always - imagePullSecrets: - - name: regcred diff --git a/payphone/cogs/mods/__init__.py b/payphone/__init__.py similarity index 100% rename from payphone/cogs/mods/__init__.py rename to payphone/__init__.py diff --git a/payphone/bot.py b/payphone/bot.py index b12f4a2..e3f53e1 100644 --- a/payphone/bot.py +++ b/payphone/bot.py @@ -1,54 +1,44 @@ -from typing import Literal - -from datetime import datetime, timezone, timedelta - -import sys - import discord -from discord import abc from discord.ext import commands -from discord.ext.commands import errors -from tortoise import expressions, transactions, exceptions as tortoise_exc +import redis.asyncio as redis -from redis import Redis - -import asyncpg.exceptions - -from payphone.db import models -from payphone.core import ( - constants, - exceptions, - formatting, - help_command, - config, - logging, -) +from payphone.db import setup, models +from payphone.core import constants, logging class Payphone(commands.AutoShardedBot): log = logging.logger - redis: Redis = Redis( - url="redis://localhost" if config.IS_DEBUG else "redis://redis", + redis = redis.Redis( + host=constants.REDIS_HOST, port=constants.REDIS_PORT, decode_responses=True ) - def __init__(self) -> None: + def __init__(self): intents = discord.Intents.default() - intents.message_content = True intents.guilds = True + intents.guild_messages = True + intents.guild_reactions = True + intents.message_content = True - if config.IS_DEBUG: - intents.members = True + self.shard_ids = [constants.SHARD_ID] + self.shard_count = constants.SHARD_COUNT super().__init__( intents=intents, - command_prefix=self.get_prefix, - help_command=help_command.HelpCommand(), + command_prefix="p.", strip_after_prefix=True, case_insensitive=True, ) + for cog in ["calls", "admin"]: + self.load_extension(f"payphone.cogs.{cog}.cog") + self.log.info("Loaded cog %s.", cog) + + from payphone.core import calls + + self.call_handler = calls.CallHandler(self) + async def on_ready(self) -> None: """Runs when the bot has finished initializing.""" @@ -102,7 +92,7 @@ class Payphone(commands.AutoShardedBot): async def get_or_fetch_channel( self, channel_id: int - ) -> abc.GuildChannel | abc.PrivateChannel | discord.Thread | None: + ) -> discord.abc.GuildChannel | discord.abc.PrivateChannel | discord.Thread | None: """Get's a channel from the bot's cache, or fetches it from Discord if it's not available. Args: @@ -119,365 +109,65 @@ class Payphone(commands.AutoShardedBot): return channel - async def is_banned( - self, user: discord.User | discord.Member | models.User | int - ) -> bool: - """Checks whether a user is banned. - - Checks whether a user is banned, and caches the result in Redis. The cache expires in 5 - minutes. + def clean_message(self, message: discord.Message, call) -> str: + """Cleans a message. Args: - user (discord.User | discord.Member | models.User | int): The user to check or their - ID. + message (discord.Message): The message to clean. + call (Call): The call the message is in. Returns: - bool: Whether the user is banned or not. + (str): The cleaned message. """ - user_id = user if isinstance(user, int) else user.id - key = constants.BAN_TEMPLATE % user_id + content = message.content - is_banned = await self.redis.get(key) + content = content.replace( + "@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone" + ).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here") - if is_banned is not None: - return bool(int(is_banned)) + for mention in message.mentions + message.role_mentions: + content = content.replace( + f"<@{mention.id}>", f"@**{mention.display_name}**" + ) - is_banned = 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", - ), - ).exists() + for mention in message.channel_mentions: + content = content.replace( + f"<@{mention.id}>", f"#**{mention.display_name}**" + ) - await self.redis.set(key, int(is_banned), ex=300) + return content - return is_banned - - @transactions.atomic() - async def ban( - self, - user: discord.User | discord.Member | models.User | int, - duration: timedelta | None, - reason: str, - moderator: discord.User | discord.Member | models.User | int | None = None, - report_id: int | None = None, - ) -> models.Ban: - """Bans a user from Payphone. + async def get_user_flag(self, user_id: int) -> str: + """Gets the flags of a user. Args: - user (discord.User | discord.Member | models.User | int): The user to ban or their ID. - duration (timedelta | None): The expiry date, or None if it does not expire. - reason (str): The reason of the ban. - moderator (discord.User | discord.Member | models.User | int | None, optional): The - moderator who performed the action. - report_id (int | None, optional): The ID of the report the ban is related to. + user_id (int): The ID of the user. Returns: - models.Ban: The newly created ban. + (str): The flags of the user. """ - user_id = user if isinstance(user, int) else user.id - key = constants.BAN_TEMPLATE % user_id + flag = await self.redis.hget(f"user:{user_id}", "flags") - mod_id = ( - moderator.id - if isinstance(moderator, (discord.User, discord.Member, models.User)) - else moderator - ) + if flag is None: + user, _ = await models.User.get_or_create(id=user_id) - until = datetime.now(timezone.utc) + duration if duration is not None else None + flag = user.flags - if duration is not None: - last_ban = ( - await models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ), - user_id=user_id, - ) - .order_by("-created_at") - .limit(1) - .first() - ) - - if last_ban is not None: - until = ( - duration + last_ban.expires_at - if last_ban.expires_at is not None - else None - ) - - row = await models.Ban.create( - user_id=user_id, - reason=reason, - expires_at=until, - banned_by_id=mod_id, - report_id=report_id, - ) - - await self.redis.set(key, "1", ex=300) - - return row - - @transactions.atomic() - async def unban( - self, - user: discord.User | discord.Member | models.User | int, - unbanned_by: discord.User | discord.Member | models.User | int | None = None, - ) -> None: - """Unbans a user from Payphone. - - Args: - user (discord.User | discord.Member | models.User | int): The user to unban or their - ID. - unbanned_by (discord.User | discord.Member | models.User | int | None, optional): The - moderator who unbanned the user or their ID. - """ - - user_id = user if isinstance(user, int) else user.id - unbanned_by_id = unbanned_by if isinstance(unbanned_by, int) else unbanned_by.id - - key = constants.BAN_TEMPLATE % user_id - - await models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ), - user_id=user_id, - ).update(expires_at=datetime.now(timezone.utc), unbanned_by_id=unbanned_by_id) - - await self.redis.set(key, "0", ex=300) - - 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] - - 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 = constants.PREFIX_TEMPLATE % 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 - - async def set_prefix( - self, server: discord.Guild | models.Server | int, prefix: str = "p." - ) -> None: - """Update a server's prefix. - - Args: - server (discord.Guild | models.Server | int): The server to update or it's ID. - prefix (str, optional): The new prefix. It cannot be longer than 5 characters. Defaults - to "p.". - - Raises: - ValueError: The prefix provided is invalid. - """ - - if len(prefix) > 5 or len(prefix) == 0: - raise ValueError( - "The prefix must not be longer than 5 characters and must not be an empty string." - ) - - server_id = server if isinstance(server, int) else server.id - - await models.Server.filter(id=server_id).update(prefix=prefix) - - await self.redis.set(constants.PREFIX_TEMPLATE % server_id, prefix, ex=60 * 60) - - 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 - - elif isinstance(exception, tortoise_exc.TransactionManagementError): - # Tortoise ORM bug. This may need to be logged for debugging, but for now they just - # clutter the logs. - return + await self.redis.hset(f"user:{user_id}", "flags", int(flag)) else: - return await super().on_command_error(context, exception) + flag = models.UserFlag(int(flag)) - async def on_error( - self, event_method: str, *args: discord.Any, **kwargs: discord.Any - ) -> None: - _, exception, _ = sys.exc_info() - - if isinstance( - exception, - (asyncpg.exceptions.InterfaceError, asyncpg.exceptions.PostgresError), - ): - # IMPORTANT! These are just spam at the moment, but may cause trouble in the future as - # the bot grows. - return - - return await super().on_error(event_method, *args, **kwargs) - - @property - def embed_author(self) -> discord.EmbedAuthor: - """The embed author for embeds sent by Payphone. - - Returns: - discord.EmbedAuthor: The embed author object. - """ - - return discord.EmbedAuthor( - name=self.user.display_name, icon_url=self.user.display_avatar.url - ) + return flag -bot = Payphone() +def run(): + """Runs the bot.""" -bot.load_extension("payphone.cogs.admin.cog") -bot.load_extension("payphone.cogs.calls.cog") -bot.load_extension("payphone.cogs.easter.cog") -bot.load_extension("payphone.cogs.mods.cog") -bot.load_extension("payphone.cogs.users.cog") -bot.load_extension("payphone.cogs.utility.cog") + bot = Payphone() + bot.loop.run_until_complete(setup.init()) -@bot.check -async def is_channel_enabled(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 - - await models.Server.get_or_create(id=server_id) - channel, _ = await models.Channel.get_or_create(id=channel_id, server_id=server_id) - - if channel.enabled: - return True - - if channel.enabled is False: - raise exceptions.ChannelDisabledError() - - if await models.Channel.filter(server_id=server_id, enabled=True).exists(): - raise exceptions.ChannelDisabledError() - - return True + bot.run(constants.TOKEN) diff --git a/payphone/cogs/users/__init__.py b/payphone/cogs/__init__.py similarity index 100% rename from payphone/cogs/users/__init__.py rename to payphone/cogs/__init__.py diff --git a/payphone/cogs/admin/cog.py b/payphone/cogs/admin/cog.py index 79bac6c..e0b6d54 100644 --- a/payphone/cogs/admin/cog.py +++ b/payphone/cogs/admin/cog.py @@ -1,209 +1,65 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import discord -from discord import errors -from discord.ext import commands, tasks +from discord.ext import commands -from tortoise import transactions, functions, exceptions - -from payphone import bot -from payphone.db import utils as dbutils, models -from payphone.cogs import base -from payphone.core import utils +from payphone.db import models +from payphone.bot import Payphone -class Admin(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance +class Admin(commands.Cog): + """Administration in Payphone.""" - self.update_leaderboard.add_exception_type(exceptions.ConfigurationError) - self.update_leaderboard.start() - - def cog_unload(self) -> None: - self.update_leaderboard.cancel() - - @tasks.loop(minutes=15) - async def update_leaderboard(self) -> None: - """Updates the leaderboard.""" - - calls = ( - await models.Call.annotate(messages_count=functions.Count("messages")) - .filter( - started_at__month=datetime.now(timezone.utc).month, - started_at__year=datetime.now(timezone.utc).year, - messages_count__gte=25, - ) - .prefetch_related( - "call_rss", "call_rss__channel", "call_rss__channel__server" - ) - ) - - # ID: Count - servers_map: dict[int, int] = {} - servers: dict[int, models.Server] = {} - - for call in calls: - for rs in utils.deduplicate(call.call_rss, lambda i: i.channel.server_id): - server_id = rs.channel.server_id - servers[server_id] = rs.channel.server - - if server_id in servers_map: - servers_map[server_id] += 1 - - else: - servers_map[server_id] = 1 - - server_counts = sorted(servers_map.items(), key=lambda i: i[1], reverse=True) - - for pos, (server_id, calls_count) in enumerate(server_counts): - servers[server_id].leaderboard_calls_count = calls_count - servers[server_id].leaderboard_position = pos + 1 - - await models.Server.bulk_update( - servers.values(), - ["leaderboard_calls_count", "leaderboard_position"], - batch_size=500, - ) - - @commands.Cog.listener() - async def on_guild_update( - self, _before: discord.Guild, after: discord.Guild - ) -> None: - """Updates a server. + def __init__(self, bot: Payphone) -> None: + """Initializes the Calls cog. Args: - _before (discord.Guild): The server before it being updated. - after (discord.Guild): The server after it being updated. + bot (Payphone): The bot instance. """ - vanity_code = None + self.bot = bot - try: - vanity = await after.vanity_invite() + @commands.command(aliases=["mod"]) + @commands.is_owner() + async def moderator( + self, ctx: commands.Context, user: discord.User, toggle: bool + ) -> None: + """Gives the user the Moderator role.""" - if vanity is not None: - vanity_code = vanity.code - - except errors.DiscordException: - pass - - await models.Server.update_or_create( - {"name": after.name, "vanity_code": vanity_code}, id=after.id + await models.User.update_or_create( + id=user.id, defaults={"is_moderator": toggle} ) - @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) - @transactions.atomic() - async def prefix(self, ctx: commands.Context, prefix: str = "p.") -> None: - """ - Set a custom bot prefix. - - Options: - - ``: The new custom prefix. - - Permissions: - - Manage Server - """ - - try: - await self.bot.set_prefix(ctx.guild, prefix) - except ValueError: - await ctx.send("❌ The prefix must not be longer than 5 characters.") - return - - 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) - @transactions.atomic() - 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(enabled=None) - - await ctx.send( - "✅ All channels were enabled! You can now run commands in any channel." - ) - - else: - row = await dbutils.get_channel(channel) - - row.enabled = True - await row.save() - - 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) - @transactions.atomic() - 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 - - row = await dbutils.get_channel(channel) - - row.enabled = False - await row.save() - await ctx.send( - f"✅ {channel.mention} was disabled. Commands cannot be ran in this channel anymore." + f"Toggled the moderator role {'on' if toggle else 'off'} for {user.mention}." + ) + + @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.""" + + await models.User.update_or_create( + id=user.id, + defaults={ + "is_supporter_until": ( + datetime.now(timezone.utc) + timedelta(days=30) if toggle else None + ) + }, + ) + + await ctx.send( + f"Gave the Supporter role to {ctx.author.mention} until in one month." ) -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. +def setup(bot: Payphone) -> None: + """Loads the Admin cog into the bot. Args: - b (bot.Payphone): The bot instance to load the cog into. + bot (Payphone): The bot instance. """ - b.add_cog(Admin(b)) + bot.add_cog(Admin(bot)) diff --git a/payphone/cogs/base.py b/payphone/cogs/base.py deleted file mode 100644 index 3ae1872..0000000 --- a/payphone/cogs/base.py +++ /dev/null @@ -1,7 +0,0 @@ -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 7ba9fa7..62a355f 100644 --- a/payphone/cogs/calls/cog.py +++ b/payphone/cogs/calls/cog.py @@ -1,1148 +1,383 @@ -from datetime import datetime, timezone, timedelta +import asyncio -import secrets +import random import discord -from discord import errors -from discord.ext import commands, tasks -from discord.utils import find - -from tortoise import transactions, exceptions +from discord.ext import commands import aiohttp -import orjson - -from payphone import bot -from payphone.db import utils as dbutils, models -from payphone.cogs import base -from payphone.cogs.calls import embeds -from payphone.core import constants, checks, censorship, formatting +from payphone.db import models +from payphone.bot import Payphone +from payphone.core import constants, censorship, messages -class Calls(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance +class Calls(commands.Cog): + """Calls in Payphone.""" - def cog_unload(self) -> None: - return super().cog_unload() + def __init__(self, bot: Payphone) -> None: + """Initializes the Calls cog. - @commands.command(aliases=["c"]) - @commands.guild_only() - @commands.cooldown(1, 15, commands.BucketType.channel) - @checks.is_unbanned() - @transactions.atomic() + Args: + bot (Payphone): The bot instance. + """ + + self.bot = bot + + @commands.command() async def call(self, ctx: commands.Context) -> None: """ Call and connect with other people. """ - if ctx.channel.is_nsfw(): - await ctx.send("❌ Calls cannot be started in NSFW channels.") - return + if await self.bot.call_handler.is_on_call(ctx.channel.id): + return await ctx.send( + f"☎️ You're already on a call! Use `{ctx.clean_prefix}help` to hang up." + ) - user_id = ctx.author.id - channel_id = ctx.channel.id + if await self.bot.call_handler.is_searching(ctx.channel.id): + return await ctx.send( + f"☎️ You're already searching for a call. Use `{ctx.clean_prefix}hangup` to hang up the phone." + ) - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) + await self.bot.call_handler.set_searching(ctx.channel.id, True) + + await ctx.send( + ( + f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" + f"☎️ {random.choice(messages.CALLING)}" + ) ) - if is_on_call: - await ctx.reply( - f"☎️ You're already on a call! Use `{ctx.clean_prefix}hangup` to hangup." - ) - return - - is_searching = await self.bot.redis.sismember( - constants.CHANNELS_SEARCH_QUEUE, str(channel_id) - ) - - if is_searching: - await ctx.reply( - f"☎️ You're already searching for a call! Use `{ctx.clean_prefix}hangup` to stop searching." - ) - return - - last_channel_calls = [ - i[1] - for i in ( - await models.CallUserChannelRelationship.filter(channel_id=channel_id) - .limit(5) - .distinct() - .order_by("-id") - .values_list("id", "call_id") - ) - ] - last_user_calls = [ - i[1] - for i in ( - await models.CallUserChannelRelationship.filter(user_id=user_id) - .limit(5) - .limit(5) - .distinct() - .order_by("-id") - .values_list("id", "call_id") - ) - ] - - last_calls = set(last_user_calls + last_channel_calls) - - last_calls_rss = await models.CallUserChannelRelationship.filter( - call_id__in=last_calls - ) - last_calls_rss_channels_map = {} - - for call_rs in last_calls_rss: - if call_rs.user_id == user_id or call_rs.channel_id == channel_id: - last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id - - last_calls_other_channels_ids = set() - - for call_rs in last_calls_rss: - if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id: - last_calls_other_channels_ids.add(call_rs.channel_id) - - other_channel_id: int = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None - ) - i = 0 - - while True: - # For some weird reason, sometimes it returns None and sometimes b"None". - if other_channel_id is None or other_channel_id == b"None": - other_channel_id = None - break - - # self.bot.redis returns the ID as a bytes string. - other_channel_id = int(other_channel_id) - - if i == 5: - break - - i += 1 - - if other_channel_id in last_calls_other_channels_ids: - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) - ) - - other_channel_id = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), - None, - ) - - if other_channel_id is None: - break - - continue - - break - - channel = await dbutils.get_channel(ctx.channel) - - if other_channel_id is not None: - await self.bot.redis.sadd( - constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id) - ) - - msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}" - - try: - await self.bot.get_partial_messageable(other_channel_id).send( - msg_phone_answered - ) - - call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw()) - - user, profile = await dbutils.get_user(ctx.author) - - await models.CallUserChannelRelationship.create( - call=call, - channel_id=channel_id, - user=user, - profile=profile, - is_masked=user.is_masked, - mask=secrets.choice(constants.MASKS_LIST).value, - ) - - await models.Channel.filter( - id__in=[other_channel_id, channel.id] - ).update(call=call) - - except errors.DiscordException: - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - str(channel_id), - str(other_channel_id), - ) - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(channel_id) - ) - msg_phone_answered = "" - - await ctx.send( - ( - f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" - f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n" - ) - + msg_phone_answered - ) - - else: - await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id)) - - await ctx.send( - ( - f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" - f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n" - ) - ) - - @commands.command(aliases=["s"]) - @commands.guild_only() - @commands.cooldown(1, 15, commands.BucketType.channel) - @checks.is_unbanned() - @transactions.atomic() + @commands.command() async def skip(self, ctx: commands.Context) -> None: """ - Hang up the current call and search for another. + Skip the current call and search for another one. """ - if ctx.channel.is_nsfw(): - await ctx.send("❌ Calls cannot be started in NSFW channels.") - return + if not await self.bot.call_handler.is_on_call(ctx.channel.id): + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." + ) - user_id = ctx.author.id - channel_id = ctx.channel.id + channels = await self.bot.call_handler.skip(ctx.channel.id) - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) - ) - - if not is_on_call: - await ctx.send("❌ You're not in a call!") - return - - call = await models.Call.get(channels__id=channel_id).prefetch_related( - "channels" - ) - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - call.ended_at = datetime.now(timezone.utc) - await call.save() - - channel = find(lambda c: c.id == channel_id, call.channels) - other_channel = find(lambda c: c.id != channel_id, call.channels) - - if other_channel is not None: - other_channel.call = None - await other_channel.save() - - try: - await self.bot.get_partial_messageable(other_channel.id).send( - f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}" + for channel in channels: + if channel == ctx.channel.id: + await ctx.send( + ( + f"**TIP**: {random.choice(messages.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" + "☎️ You skipped the call. Searching for another one..." + ) ) - except errors.DiscordException: - pass - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - *( - [str(channel_id)] - + ([str(other_channel.id)] if other_channel is not None else []) + else: + await self.bot.get_partial_messageable(channel).send( + f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}" + ) + + @commands.command() + async def hangup(self, ctx: commands.Context) -> None: + """ + Hang up the call. + """ + + if not await self.bot.call_handler.is_on_call(ctx.channel.id): + if await self.bot.call_handler.is_searching(ctx.channel.id): + return await ctx.send(f"☎️ {random.choice(messages.NO_ANSWER)}") + + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." + ) + + channels = await self.bot.call_handler.hangup(ctx.channel.id) + + other_channel = channels[0] if channels[0] != ctx.channel.id else channels[1] + + await asyncio.gather( + ctx.send(f"☎️ {random.choice(messages.HANGUP)}"), + self.bot.get_partial_messageable(other_channel).send( + f"☎️ {random.choice(messages.OTHER_PARTY_HANGUP)}" ), ) - last_channel_calls = [ - i[1] - for i in ( - await models.CallUserChannelRelationship.filter(channel_id=channel_id) - .limit(5) - .distinct() - .order_by("-id") - .values_list("id", "call_id") - ) - ] - last_user_calls = [ - i[1] - for i in ( - await models.CallUserChannelRelationship.filter(user_id=user_id) - .limit(5) - .limit(5) - .distinct() - .order_by("-id") - .values_list("id", "call_id") - ) - ] - - last_calls = set(last_user_calls + last_channel_calls) - - last_calls_rss = await models.CallUserChannelRelationship.filter( - call_id__in=last_calls - ) - last_calls_rss_channels_map = {} - - for call_rs in last_calls_rss: - if call_rs.user_id == user_id or call_rs.channel_id == channel_id: - last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id - - last_calls_other_channels_ids = set() - - for call_rs in last_calls_rss: - if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id: - last_calls_other_channels_ids.add(call_rs.channel_id) - - other_channel_id: int = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), None - ) - i = 0 - - while True: - # For some weird reason, sometimes it returns None and sometimes b"b'None'". - if other_channel_id is None or other_channel_id == b"b'None'": - other_channel_id = None - break - - # self.bot.redis returns the ID as a bytes string. - other_channel_id = int(other_channel_id) - - if i == 5: - break - - i += 1 - - if other_channel_id in last_calls_other_channels_ids: - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id) - ) - - other_channel_id = next( - iter(await self.bot.redis.spop(constants.CHANNELS_SEARCH_QUEUE)), - None, - ) - - if other_channel_id is None: - break - - continue - - break - - if other_channel_id is not None: - await self.bot.redis.sadd( - constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id) - ) - - msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}" - - try: - await self.bot.get_partial_messageable(other_channel_id).send( - msg_phone_answered - ) - - call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw()) - - user, profile = await dbutils.get_user(ctx.author) - - await models.CallUserChannelRelationship.create( - call=call, - channel_id=channel_id, - user=user, - profile=profile, - is_masked=user.is_masked, - mask=secrets.choice(constants.MASKS_LIST).value, - ) - - await models.Channel.filter( - id__in=[other_channel_id, channel.id] - ).update(call=call) - - except errors.DiscordException: - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - str(channel_id), - str(other_channel_id), - ) - await self.bot.redis.sadd( - constants.CHANNELS_SEARCH_QUEUE, str(channel_id) - ) - msg_phone_answered = "" - - await ctx.send( - ( - f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" - f"☎️ You skipped the call.\n" - ) - + ( - msg_phone_answered - if msg_phone_answered - else f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n" - ) - ) - - else: - await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id)) - - channel.call = None - await channel.save() - - await ctx.send( - ( - f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n" - f"☎️ You skipped the call.\n" - f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n" - ) - ) - - @commands.command(aliases=["hang", "h"], extras={"help_name": "Hang Up"}) - @commands.guild_only() - @checks.is_unbanned() - @transactions.atomic() - async def hangup(self, ctx: commands.Context) -> None: - """ - Hang up the phone. - """ - - channel_id = ctx.channel.id - - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) - ) - - if is_on_call: - channel = await dbutils.get_channel(ctx.channel) - - try: - call = await models.Call.get(id=channel.call_id).prefetch_related( - "channels" - ) - - except exceptions.DoesNotExist: - await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, channel_id) - - await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}") - - return - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - other_channel: models.Channel = find( - lambda c: c.id != channel_id, call.channels - ) - - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - *( - [str(channel_id)] - + ([str(other_channel.id)] if other_channel is not None else []) - ), - ) - - if other_channel is not None: - try: - await (await self.bot.get_or_fetch_channel(other_channel.id)).send( - f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}" - ) - - except errors.DiscordException: - pass - - other_channel.call = None - - await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}") - - channel.call = None - - await models.Channel.bulk_update( - [channel] + ([other_channel] if other_channel is not None else []), - ["call_id"], - ) - - call.ended_at = datetime.now(timezone.utc) - await call.save() - - is_searching = await self.bot.redis.sismember( - constants.CHANNELS_SEARCH_QUEUE, str(channel_id) - ) - - if is_searching: - await self.bot.redis.srem(constants.CHANNELS_SEARCH_QUEUE, str(channel_id)) - - await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_NO_ANSWER)}") - - if not is_searching and not is_on_call: - await ctx.send(f"❌ {secrets.choice(constants.MSGS_PHONE_NOT_IN_CALL)}") - @commands.command() - @commands.guild_only() - @checks.is_unbanned() - async def report( - self, ctx: commands.Context, call_id: int | None = None, *, reason: str - ) -> None: - """ - Report a call by it's ID, or the current/last call if no ID is specified. - - Options: - - `[]`: The ID of the call to report. - - ``: The reason why you're reporting the call. - """ - - user, _ = await dbutils.get_user(ctx.author) - channel_id = ctx.channel.id - - if call_id is None: - call_rs = ( - await models.CallUserChannelRelationship.filter(channel_id=channel_id) - .order_by("-id") - .limit(1) - .first() - ) - - if call_rs is None: - await ctx.send("❌ No calls occurred on this channel.") - return - - call_id = call_rs.call_id - - call = await models.Call.get_or_none(id=call_id).prefetch_related( - "channels", "report", "call_rss" - ) - - if call is None: - await ctx.send(f"❌ There is no call with ID `{call_id}`.") - - if call.report is not None: - await ctx.send( - "✅ This call has already been reported. Our moderator team will review it as soon as possible." - ) - return - - if not any(map(lambda rs: rs.user_id == user.id, call.call_rss)): - await ctx.send("❌ You have not participated in this call.") - return - - reviewed_reports = await models.Report.filter( - reported_by_id=ctx.author.id, - status__in=[models.ReportStatus.ACCEPTED, models.ReportStatus.DECLINED], - ) - - priority = 0.5 - - if len(reviewed_reports) > 3: - accepted_reports = len( - [ - report - for report in reviewed_reports - if report.status == models.ReportStatus.ACCEPTED - ] - ) - - priority = accepted_reports / len(reviewed_reports) - - await models.Report.create( - call_id=call_id, reported_by_id=user.id, reason=reason, priority=priority - ) - - message = "✅ The call has been reported." - - if call.ended_at is None: - call.ended_at = datetime.now(timezone.utc) - await call.save() - - await self.bot.redis.sadd(constants.CALL_IDS_TO_FLUSH, call.id) - - updated_channels = [] - - for channel in call.channels: - channel.call = None - updated_channels.append(channel) - - await models.Channel.bulk_update(updated_channels, ["call_id"]) - - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, - str(updated_channels[0]), - str(updated_channels[1]), - ) - - message += f"\n☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}" - - other_channel = find(lambda c: c.id != channel_id, updated_channels) - - await self.bot.redis.srem( - constants.CHANNELS_ONCALL_SET, str(other_channel.id), str(channel_id) - ) - - try: - await self.bot.get_partial_messageable(other_channel.id).send( - f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}" - ) - - except errors.DiscordException: - pass - - await ctx.send(message) - - @commands.command(aliases=["fr"], extras={"help_name": "Friend Request"}) - @commands.guild_only() - @checks.is_unbanned() - @checks.is_on_call() async def friendreq(self, ctx: commands.Context) -> None: """ - Ask the other party to send you a friend request. + Send a friend request to the other party. """ - user_id = ctx.author.id - channel_id = ctx.channel.id + call = await self.bot.call_handler.get_call(ctx.channel.id) - call = await models.Call.get(channels__id=channel_id).prefetch_related( - "channels", "call_rss", "call_rss__profile" - ) - - other_channel = find(lambda c: c.id != channel_id, call.channels) - call_rs: models.CallUserChannelRelationship = find( - lambda rs: rs.user_id == user_id, call.call_rss - ) - - try: - await self.bot.get_partial_messageable(other_channel.id).send( - f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** (`{ctx.author.name}`) wants you to add them as a friend on Discord." + if call is None: + return await ctx.send( + f"☎️ You're not on a call! Use `{ctx.clean_prefix}call` to start one." ) - except errors.DiscordException: - await ctx.send("⚠️ Your friend request couldn't be sent.") - return + other_channel = ( + call.channels[0] if call.channels[0] != ctx.channel.id else call.channels[1] + ) - await ctx.send("✅ Your friend request was sent!") + await asyncio.gather( + self.bot.get_partial_messageable(other_channel).send( + f"📬 **{ctx.author.display_name}** wants you to send them a friend request on Discord! Their username is `{ctx.author.name}`." + ), + ctx.send("📬 Your friend request has been sent!"), + ) @commands.Cog.listener() - @commands.guild_only() async def on_message(self, message: discord.Message) -> None: - """Processes every message received. - - Args: - message (discord.Message): The message received. + """ + Handle messages in calls. """ - if ( - message.author.bot - or message.is_system() - or len(message.stickers) > 0 - or not message.content - ): + if message.author.bot or (await self.bot.get_context(message)).valid: return - if (await self.bot.get_context(message)).valid: + call = await self.bot.call_handler.get_call(message.channel.id) + + if call is None: return - user_id = message.author.id - channel_id = message.channel.id + is_new_participant = not bool(await call.get_participant(message.author.id)) - is_on_call = await self.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) + await call.set_participant( + message.author.id, + message.author.display_name, + message.author.display_avatar.url, ) - if not is_on_call: - return - - is_banned = await self.bot.is_banned(user_id) - - if is_banned: - return - - channel = await dbutils.get_channel(message.channel) - - try: - call = await models.Call.get(id=channel.call_id).prefetch_related( - "channels" - ) - - except exceptions.DoesNotExist: - await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, str(channel_id)) - return - - other_channel: models.Channel = find( - lambda c: c.id != channel_id, call.channels + other_channel = ( + call.channels[0] + if call.channels[0] != message.channel.id + else call.channels[1] ) - if other_channel is None: - # BUG: The call wasn't properly set up. - return + user_flag = await self.bot.get_user_flag(message.author.id) - user, profile = await dbutils.get_user(message.author) + blocked_section = censorship.censor(message.content) - call_rss = await models.CallUserChannelRelationship.filter( - call=call, channel=channel - ).select_related("profile") - used_masks = [rs.mask for rs in call_rss] - - call_rs, _ = await models.CallUserChannelRelationship.get_or_create( - { - "mask": secrets.choice( - [i for i in constants.MASKS_LIST if i not in used_masks] + if blocked_section: + return await asyncio.gather( + message.channel.send( + ( + "❌ Your message wasn't sent because it contained a blocked word or " + f"pattern: `{blocked_section}`" + ), + delete_after=10, + ), + self.bot.get_partial_messageable(other_channel).send( + ( + f"❌ **{message.author.display_name}** sent a message that was blocked.\n" + "-# If you see an inappropiate message, please report it using `Apps > " + "Report Message`." + ), ), - "is_masked": user.is_masked, - "profile": profile, - }, - call=call, - user=user, - channel=channel, - ) - - # Since .select_related() can't be used and profile was already fetched, we need to - # manually set it. - call_rs.profile = profile - - blocked_word = censorship.moderate(message.content) - - webhook_message = None - cleaned_content = formatting.trim(formatting.clean(message, call_rss), 2000) - - replying_to_id = None - - if blocked_word: - try: - await self.bot.get_partial_messageable(channel_id).send( - f"Your message wasn't delivered!\nBlocked word: `{formatting.trim(blocked_word, 10)}`." - ) - - except errors.DiscordException: - pass - - prefix = await self.bot.get_prefix(server_id=other_channel.server_id) - - try: - await self.bot.get_partial_messageable(other_channel.id).send( - f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** said something that's not allowed. If you think they're doing something inappropiate, use `{prefix}report`." - ) - - except errors.DiscordException: - pass - - else: - async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - embed = None - - if message.reference: - embed, replying_message = await embeds.ReplyEmbed( - self.bot.redis, message.reference, call_id=call.id - ) - - if replying_message is not None: - replying_to_id = replying_message.id - - try: - webhook_message = await webhook.send( - cleaned_content, - username=call_rs.display_name, - avatar_url=call_rs.display_avatar, - embed=embed, - wait=True, - ) - - except errors.NotFound: - other_channel.webhook_id = None - other_channel.webhook_url = None - - await other_channel.save() - - try: - await dbutils.get_channel( - await self.bot.get_or_fetch_channel(other_channel.id) - ) - - finally: - pass - - pipe = self.bot.redis.pipeline() - - pipe.set(constants.CALL_IS_ACTIVE % call.id, "", ex=60 * 15) - - pipe.hset( - constants.CALL_MESSAGES_TEMPLATE % call.id, - { - message.id: orjson.dumps( - { - "id": message.id, - "call_id": call.id, - "channel_id": channel.id, - "user_id": user.id, - "profile_id": profile.id, - "mask": call_rs.mask if call_rs.is_masked else None, - "propagation_id": ( - webhook_message.id if webhook_message else None - ), - "propagation_channel_id": ( - other_channel.id if webhook_message else None - ), - "replying_to_id": replying_to_id, - "created_at": datetime.now(timezone.utc), - } - ).decode() - }, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call.id, - { - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, 1): orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode() - }, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call.id, - {message.id: 1}, - ) - - if webhook_message is not None: - pipe.hset( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call.id, - {webhook_message.id: message.id}, ) - if replying_to_id is not None: - other_replies = await self.bot.redis.hget( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, replying_to_id - ) + message_content = self.bot.clean_message(message, call) - if other_replies is None: - pipe.hset( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - {replying_to_id: message.id}, + if message.stickers: + if 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 + + embeds = [] + + if message.reference: + if await call.is_message_from_call(message.reference.message_id): + replied_message = message.reference.cached_message + + if replied_message is None: + replied_message = await message.channel.fetch_message( + message.reference.message_id + ) + + 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, + ), + ) ) else: - pipe.hset( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call.id, - {replying_to_id: other_replies + f",{message.id}"}, + message_content += ( + "\n-# ⓘ In reply to a message from outside this call." ) - await pipe.execute() + 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 user_flag & models.UserFlag.MODERATOR: + embeds.append( + discord.Embed( + color=constants.MODERATOR_COLOR, + description=f"🛡️ **{message.author.display_name}** is a Payphone moderator!", + ) + ) + + async with aiohttp.ClientSession() as session: + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url(other_channel), + session=session, + bot_token=constants.TOKEN, + ) + + propagated_msg = await webhook.send( + content=message_content, + username=message.author.display_name, + avatar_url=message.author.display_avatar.url, + embeds=embeds, + wait=True, + ) + + await call.set_message( + message.id, + message.channel.id, + propagated_msg.id, + message.author.id, + ) @commands.Cog.listener() - @commands.guild_only() - @transactions.atomic() async def on_raw_message_delete( self, payload: discord.RawMessageDeleteEvent ) -> None: - """Processes a message deletion received. - - Args: - payload (discord.RawMessageDeleteEvent): The received payload. + """ + Handle deleted messages in calls. """ - message_id = payload.message_id - channel_id = payload.channel_id + call = await self.bot.call_handler.get_call(payload.channel_id) - channel = await models.Channel.get_or_none(id=channel_id) - - if channel is None: + if call is None: return - if channel.call_id is None: + propagation = await call.get_message_propagation(payload.message_id) + + if propagation is None: return - message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, message_id - ) - - if message_json is None: - return - - message_dict = orjson.loads(message_json) - - message = models.Message(**message_dict) - - message_dict["was_deleted"] = True - - await self.bot.redis.hset( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, - {message.id: orjson.dumps(message_dict).decode()}, - ) - - other_channel = await models.Channel.get_or_none( - id=message.propagation_channel_id - ) - - if other_channel is None: - return - - if other_channel.call_id != message.call_id: - return + propagation_channel, propagation_id = propagation async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - try: - await webhook.delete_message(message.propagation_id) - - except errors.DiscordException: - # This message was already deleted. - pass - - message_replies = await self.bot.redis.hget( - constants.CALL_MESSAGE_REPLIES_TEMPLATE % channel.call_id, message.id + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url( + propagation_channel + ), + session=session, + bot_token=constants.TOKEN, ) - if message_replies is None: - return - - for message_id in message_replies.split(","): - try: - await webhook.edit_message(message_id, embed=None) - - except errors.DiscordException: - pass + await webhook.delete_message(propagation_id) @commands.Cog.listener() - @commands.guild_only() - @transactions.atomic() async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None: - """Processes a message edit received. - - Args: - payload (discord.RawMessageUpdateEvent): The event payload. + """ + Handle edited messages in calls. """ - if ( - payload.cached_message is not None - and ( - payload.cached_message.author.bot or payload.cached_message.is_system() + call = await self.bot.call_handler.get_call(payload.channel_id) + + if call is None: + return + + blocked_section = censorship.censor(payload.data["content"]) + + if blocked_section: + return ( + await self.bot.get_partial_messageable(payload.channel_id) + .get_partial_message(payload.message_id) + .reply( + ( + "❌ Your message wasn't sent because it contained a blocked word or " + f"pattern: `{blocked_section}`" + ), + delete_after=10, + ) ) - or "content" not in payload.data - ): + + propagation = await call.get_message_propagation(payload.message_id) + + if propagation is None: return - message_id = payload.message_id - channel_id = payload.channel_id - content = formatting.trim(payload.data["content"], 2000) + propagation_channel, propagation_id = propagation - channel = await models.Channel.get_or_none(id=channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, message_id - ) - - if message_json is None: - return - - message = models.Message(**orjson.loads(message_json)) - - other_channel = await models.Channel.get(id=message.propagation_channel_id) - - if other_channel.call_id != message.call_id: - return - - original_message = await self.bot.get_partial_messageable( - channel_id - ).fetch_message(message.id) - - if original_message is None: - return - - call_rss = await models.CallUserChannelRelationship.filter( - call_id=channel.call_id - ).select_related("profile") - - cleaned_content = formatting.clean(original_message, call_rss) - - blocked_word = censorship.moderate(content) - - iteration = int( - await self.bot.redis.hget( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % channel.call_id, - message.id, + async with aiohttp.ClientSession() as session: + webhook = discord.Webhook.from_url( + await self.bot.call_handler.get_channel_webhook_url( + propagation_channel + ), + session=session, + bot_token=constants.TOKEN, ) - ) - pipe = self.bot.redis.pipeline() + await webhook.edit_message(propagation_id, content=payload.data["content"]) - pipe.hincrby( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % channel.call_id, - message.id, - ) - pipe.hset( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % channel.call_id, - { - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, iteration + 1): orjson.dumps( - { - "message_id": message.id, - "content": cleaned_content, - "was_propagated": not bool(blocked_word), - "created_at": datetime.now(timezone.utc), - } - ).decode() + @commands.message_command(name="Report Message") + async def report_message( + self, ctx: discord.ApplicationContext, message: discord.Message + ) -> None: + """ + Report a message in a call. + """ + + author_id = await self.bot.call_handler.get_message_author_id(message.id) + + if author_id is None: + return await ctx.send_response( + ( + "❌ More than 15 minutes have passed since this message was sent.\n" + "-# You'll also see this error if the message wasn't sent during a call." + ), + ephemeral=True, + ) + + await ctx.send_response("✅ The message has been reported.", ephemeral=True) + + message_id: int + + if message.author.bot: + message_id = await self.bot.call_handler.get_original_message_id(message.id) + + else: + message_id = message.id + + await models.Report.update_or_create( + message_id=message_id, + defaults={ + "channel_id": message.channel.id, + "user_id": author_id, + "message_content": message.content, }, ) - await pipe.execute() - async with aiohttp.ClientSession() as session: - webhook = other_channel.webhook(session) - - try: - await webhook.edit_message( - message.propagation_id, - content=( - cleaned_content - if blocked_word is None - else "*This message was edited and censored.*" - ), - ) - - except errors.DiscordException: - # This message was deleted. - pass - - # TODO: Edit replies. - - @commands.Cog.listener() - async def on_raw_reaction_add( - self, payload: discord.RawReactionActionEvent - ) -> None: - if payload.user_id == self.bot.user.id: - return - - channel = await models.Channel.get_or_none(id=payload.channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - call_id = channel.call_id - - other_channel_id = None - other_channel_message_id = await self.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - payload.message_id, - ) - - if other_channel_message_id is None: - other_channel_message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, payload.message_id - ) - - if other_channel_message_json is None: - return - - other_channel_message_dict = orjson.loads(other_channel_message_json) - - if other_channel_message_dict["propagation_id"] is None: - # The message wasn't propagated - return - - other_channel_id = other_channel_message_dict["propagation_channel_id"] - other_channel_message_id = other_channel_message_dict["propagation_id"] - - else: - other_channel = await models.Channel.get_or_none( - call_id=call_id, id__not=payload.channel_id - ) - - if other_channel is not None: - other_channel_id = other_channel.id - - else: - return - - other_channel_message_id = int(other_channel_message_id) - - channel = await self.bot.get_or_fetch_channel(other_channel_id) - - if channel is None: - return - - await channel.get_partial_message(other_channel_message_id).add_reaction( - payload.emoji - ) - - @commands.Cog.listener() - async def on_raw_reaction_remove( - self, payload: discord.RawReactionActionEvent - ) -> None: - if payload.user_id == self.bot.user.id: - return - - channel = await models.Channel.get_or_none(id=payload.channel_id) - - if channel is None: - return - - if channel.call_id is None: - return - - call_id = channel.call_id - - other_channel_id = None - other_channel_message_id = await self.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - payload.message_id, - ) - - if other_channel_message_id is None: - other_channel_message_json = await self.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, payload.message_id - ) - - if other_channel_message_json is None: - return - - other_channel_message_dict = orjson.loads(other_channel_message_json) - - if other_channel_message_dict["propagation_id"] is None: - # The message wasn't propagated - return - - other_channel_id = other_channel_message_dict["propagation_channel_id"] - other_channel_message_id = other_channel_message_dict["propagation_id"] - - else: - other_channel = await models.Channel.get_or_none( - call_id=call_id, id__not=payload.channel_id - ) - - if other_channel is not None: - other_channel_id = other_channel.id - - else: - return - - other_channel_message_id = int(other_channel_message_id) - - channel = await self.bot.get_or_fetch_channel(other_channel_id) - - if channel is None: - return - - await channel.get_partial_message(other_channel_message_id).remove_reaction( - payload.emoji, self.bot.user - ) - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. +def setup(bot: Payphone) -> None: + """Loads the Calls cog into the bot. Args: - b (bot.Payphone): The bot instance to load the cog into. + bot (Payphone): The bot instance. """ - b.add_cog(Calls(b)) + bot.add_cog(Calls(bot)) diff --git a/payphone/cogs/calls/embeds.py b/payphone/cogs/calls/embeds.py deleted file mode 100644 index 6828997..0000000 --- a/payphone/cogs/calls/embeds.py +++ /dev/null @@ -1,104 +0,0 @@ -import discord - -import orjson - -from redis import Redis - -from payphone.db import models -from payphone.core import constants, formatting - - -async def ReplyEmbed( - redis: Redis, reference: discord.MessageReference, call_id: int -) -> tuple[discord.Embed | None, models.Message | None]: - """Generates the embed sent on calls to represent replies. - - Args: - redis (redis.Redis): An instance of a Redis connection handler. - reference (discord.MessageReference): The message reference. - call_id (int): The ID of the call the message was sent in. - - Returns: - tuple[discord.Embed | None, models.Message | None]: The generated embed and the replied - message. - """ - - message = None - - unflushed_message = await redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, reference.message_id - ) - - if unflushed_message is None: - message_id = await redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - reference.message_id, - ) - - if message_id is not None: - unflushed_message = await redis.hget( - constants.CALL_MESSAGES_TEMPLATE % call_id, message_id - ) - - if unflushed_message is None: - return (None, None) - - message = models.Message(**orjson.loads(unflushed_message)) - - unflushed_message_content_iteration = int( - await redis.hget( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call_id, message.id - ) - ) - unflushed_message_content = await redis.hget( - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id, - constants.CALL_MESSAGE_CONTENTS_HASH_TEMPLATE - % (message.id, unflushed_message_content_iteration), - ) - - last_content = models.MessageContent(**orjson.loads(unflushed_message_content)) - - message.channel = await models.Channel.get(id=message.channel_id) - message.profile = await models.Profile.get(id=message.profile_id) - message.propagation_channel = await models.Channel.get( - id=message.propagation_channel_id - ) - - if message is None: - return None, None - - is_this_channel = message.id == reference.message_id - - if last_content is None: - for content in message.contents: - if last_content is None: - last_content = content - continue - - if last_content.message_id < content.message_id: - last_content = content - - author_name = message.display_name if is_this_channel else message.profile.name - author_avatar_url = ( - message.display_avatar if is_this_channel else message.profile.avatar_url - ) - - embed = discord.Embed( - color=( - constants.EMBED_COLOR - if not is_this_channel and message.mask is not None - else message.profile.color or constants.EMBED_COLOR - ), - description=( - formatting.trim(last_content.content) - if last_content.was_propagated or is_this_channel - else "*This message was censored.*" - ), - author=discord.EmbedAuthor( - name=f"Replied to {author_name}", - icon_url=author_avatar_url, - url=message.jump_url(is_this_channel), - ), - ) - - return embed, message diff --git a/payphone/cogs/easter/__init__.py b/payphone/cogs/easter/__init__.py deleted file mode 100644 index 5a1ea2d..0000000 --- a/payphone/cogs/easter/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -import enum - - -class EasterEggCommand(enum.IntFlag): - """Flags representing the easter eggs. - - This is used to keep track of which easter eggs did the user find.""" - - CATMASK = 1 - USERPHONE = 2 diff --git a/payphone/cogs/easter/checks.py b/payphone/cogs/easter/checks.py deleted file mode 100644 index 5e78f4d..0000000 --- a/payphone/cogs/easter/checks.py +++ /dev/null @@ -1,35 +0,0 @@ -from discord.ext import commands - -from payphone.db import utils -from payphone.cogs.easter import EasterEggCommand - - -def easter_egg(command: EasterEggCommand): - """Send rewards to the user when they find an easter egg.""" - - async def predicate(ctx: commands.Context) -> bool: - if ctx.author.bot: - return False - - user, _ = await utils.get_user(ctx.author) - - if command not in user.found_easter_eggs: - user.found_easter_eggs |= command - - remaining_easter_eggs = len(list(EasterEggCommand)) - len( - user.found_easter_eggs.name.split("|") - ) - await ctx.send( - "✨ You found an easter egg! " - + ( - f"There are {remaining_easter_eggs} easter eggs remaining." - if remaining_easter_eggs > 0 - else "There are no easter eggs left, congratulations! 🎉" - ) - ) - - await user.save() - - return True - - return commands.check(predicate) diff --git a/payphone/cogs/easter/cog.py b/payphone/cogs/easter/cog.py deleted file mode 100644 index ef91f0c..0000000 --- a/payphone/cogs/easter/cog.py +++ /dev/null @@ -1,66 +0,0 @@ -import secrets - -from discord.ext import commands - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.cogs import base -from payphone.core import constants -from payphone.cogs.easter import EasterEggCommand, checks - - -class Easter(base.Cog): - show_in_help = False - - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - @commands.command() - @checks.easter_egg(EasterEggCommand.CATMASK) - @transactions.atomic() - async def catmask(self, ctx: commands.Context) -> None: - """ - This is a secret command! - """ - - await ctx.message.delete() - - new_mask = secrets.choice(constants.CAT_MASKS_LIST) - - channel_rs = ( - await models.CallUserChannelRelationship.filter( - channel_id=ctx.channel.id, user_id=ctx.author.id - ) - .order_by("-created_at") - .limit(1) - .select_related("call") - .first() - ) - - if channel_rs is None or channel_rs.call.ended_at is not None: - return - - channel_rs.mask = new_mask.value - channel_rs.is_masked = True - await channel_rs.save() - - @commands.command(aliases=["speakerphone"]) - @checks.easter_egg(EasterEggCommand.USERPHONE) - async def userphone(self, ctx: commands.Context) -> None: - """ - The second easter egg! You found it. - """ - - await ctx.send("☎️ Please insert 25 cents to complete this call.") - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. - - Args: - b (bot.Payphone): The bot instance to load the cog into. - """ - - b.add_cog(Easter(b)) diff --git a/payphone/cogs/mods/cog.py b/payphone/cogs/mods/cog.py deleted file mode 100644 index 0b1156c..0000000 --- a/payphone/cogs/mods/cog.py +++ /dev/null @@ -1,443 +0,0 @@ -from typing import Awaitable - -from datetime import datetime, timezone, timedelta - -import asyncio - -import discord -from discord import errors -from discord.ext import commands - -from tortoise import transactions, expressions, functions - -from procs import flush_messages - -from payphone import bot -from payphone.db import utils, models -from payphone.cogs import base -from payphone.cogs.mods import embeds, views -from payphone.core import checks, converters, constants, formatting as fmt - - -class Mods(base.Cog): - show_in_help = False - - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - @commands.command(extras={"reply_argument": "user"}) - @checks.is_moderator() - @transactions.atomic() - async def ban( - self, - ctx: commands.Context, - user: converters.DiscordUserID, - duration: converters.Timedelta | None = None, - *, - reason: str | None = "", - ) -> None: - """ - Ban a user by their ID, pinging them, or replying to their message. - - Options: - - ``: The user to ban or their ID. - - `[]`: The amount of time to ban the user for. For example, `1d 5h`. - - `[]`: The reason of their ban. - """ - - user_id = user - - profile = await models.Profile.filter(user_id=user_id).limit(1).first() - - row = await self.bot.ban( - user_id, - duration, - reason, - ctx.author, - ) - - message = f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was banned" - - if row.expires_at: - message += f"{f" for `{fmt.time_to_text(duration)}`" if duration is not None else ""} until ()" - - else: - message += " permanently" - - message += "." - - await ctx.send(message) - - try: - notification_message = await ( - await self.bot.get_or_fetch_user(user_id) - ).send( - embed=embeds.BanNotificationEmbed(self.bot, row), - ) - - row.notification_message_id = notification_message.id - await row.save() - - except errors.DiscordException: - pass - - @commands.command(extras={"reply_argument": "user"}) - @checks.is_moderator() - @transactions.atomic() - async def unban( - self, - ctx: commands.Context, - user: converters.DiscordUserID, - ) -> None: - """ - Unbans a user by their ID, pinging them, or replying to their message. The user will be - attempted to get notified of the unban. - - Options: - - ``: The user to unban or their ID. - """ - - user_id = user - - await self.bot.unban(user_id, ctx.author) - - profile = await models.Profile.filter(user_id=user_id).limit(1).first() - - await ctx.send( - f"✅ {f"**{profile.name}**" if profile is not None else f"`ID: {user_id}`"} was unbanned." - ) - - try: - await (await self.bot.get_or_fetch_user(user_id)).send( - "🔔 You have been unbanned from Payphone." - ) - except errors.DiscordException: - pass - - @commands.command(extras={"reply_argument": "user"}) - @checks.is_moderator() - @transactions.atomic() - async def warn( - self, - ctx: commands.Context, - user: converters.DiscordUserID, - *, - reason: str, - ) -> None: - """ - Warn a user by their ID or mention. - - Options: - - ``: The user's mention or their ID. - - ``: The reason of the warn. - """ - - user_id = user - - user = None - - try: - user = await self.bot.get_or_fetch_user(user_id) - except errors.DiscordError: - await ctx.send( - "❌ Could not find this user. It may be because the user has left all servers with Payphone in them." - ) - return - - embed = discord.Embed( - color=constants.EMBED_COLOR, - title="You've been warned", - description=reason, - timestamp=datetime.now(timezone.utc), - author=self.bot.embed_author, - footer=discord.EmbedFooter( - text="Ignoring this warning will result in a ban." - ), - thumbnail=self.bot.user.display_avatar.url, - ) - - profile = await models.Profile.filter(user_id=user_id).limit(1).first() - - try: - await user.send(embed=embed) - except errors.DiscordException: - await ctx.send( - f"❌ Could not send the warning to {f"**{profile.name}**" if profile is not None else "the user"}, probably because they have their DMs closed." - ) - return - - await ctx.send( - f"✅ {f"**{profile.name}**" if profile is not None else "the user"} has been warned." - ) - - @commands.command() - @checks.is_moderator() - @transactions.atomic() - async def reports(self, ctx: commands.Context) -> None: - """ - Shows the next report to review. - """ - - report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - if report is None: - await ctx.send("✅ No reports left, have this cookie 🍪 !!") - return - - await ctx.send( - embed=embeds.ReportEmbed(self.bot, report), - view=views.ReportView(self.bot, report, total), - ) - - @commands.command(aliases=["mod"]) - @commands.is_owner() - @transactions.atomic() - async def moderator( - self, - ctx: commands.Context, - user: discord.User | discord.Member, - toggle: bool | None = None, - ) -> None: - """ - Toggles a user's moderator status. It can only be ran by Payphone's owner. - - Options: - - ``: The user to toggle the moderation status for. - - `[]`: The moderator status to set to the user. - """ - - user_row, profile_row = await utils.get_user(user) - - if toggle is not None: - user_row.is_moderator = toggle - - else: - user_row.is_moderator = not user_row.is_moderator - - await user_row.save() - - await ctx.send( - f"✅ **{profile_row.name}**'s moderator status was set to `{"on" if user_row.is_moderator else "off"}`." - ) - - @commands.command() - @commands.is_owner() - async def stats( - self, ctx: commands.Context, iterations: int = 4, wait: int = 5 - ) -> None: - """ - Shows all Payphone's stats. - - Options: - - ``: The amount of iterations (to display progress). Defaults to 5. - - ``: The amount of seconds to wait between iterations. Defaults to 5. - """ - - async def count_banned_users() -> int: - return ( - await models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ) - ) - .annotate(unique_users_count=functions.Count("user_id", distinct=True)) - .values("unique_users_count") - )[0]["unique_users_count"] - - stats = [ - ( - "On-call Channels", - lambda: self.bot.redis.scard(constants.CHANNELS_ONCALL_SET), - ), - ( - "Searching Channels", - lambda: self.bot.redis.scard(constants.CHANNELS_SEARCH_QUEUE), - ), - ( - "Messages", - lambda: models.Message.all().count(), - ), - ( - "Message Contents", - lambda: models.MessageContent.all().count(), - ), - ("Calls", lambda: models.Call.all().count()), - ( - "Ongoing Calls", - lambda: models.Call.filter(ended_at=None).count(), - ), - ("Bans", lambda: models.Ban.all().count()), - ( - "Ongoing Bans", - lambda: models.Ban.filter( - expressions.Q( - expires_at=None, - expires_at__gt=datetime.now(timezone.utc), - join_type="OR", - ) - ).count(), - ), - ( - "Banned Users", - count_banned_users, - ), - ("Reports", lambda: models.Report.all().count()), - ( - "On-review Reports", - lambda: models.Report.filter( - status=models.ReportStatus.ON_REVIEW - ).count(), - ), - ("Users", lambda: models.User.all().count()), - ( - "Profiles", - lambda: models.Profile.all().count(), - ), - ("Servers", lambda: models.Server.all().count()), - ("Shards", lambda: self.bot.shard_count), - ("Current Shard", lambda: ctx.guild.shard_id + 1), - ] - - rows = [[stat[0]] for stat in stats] - rows_with_diff = [[stat[0]] for stat in stats] - - i = 0 - message = None - - while i < iterations: - i += 1 - - for stat_i, stat in enumerate(stats): - value = stat[1]() - - if isinstance(value, Awaitable): - value = await value - - rows[stat_i].append(value) - prev_value = rows[stat_i][-2] - - if isinstance(prev_value, str): - rows_with_diff[stat_i].append(f"{value:,}") - - else: - diff = value - prev_value - - rows_with_diff[stat_i].append( - f"{value:,}" - if diff == 0 - else (f"{value:,}", f"({"+" if diff > 0 else ""}{diff})") - ) - - table = fmt.monospace_table( - rows_with_diff, - [""] + [f"{wait * j}s" for j in range(i)], - padding=2, - ) - - if message is None: - message = await ctx.send(f"```\n{table}\n```") - - else: - message = await message.edit(f"```\n{table}\n```") - - if i < iterations: - await asyncio.sleep(wait) - - @commands.command() - @checks.is_moderator() - async def user(self, ctx: commands.Context, user: converters.DiscordUserID) -> None: - """ - Shows all the information about a user. - - Options: - - `[]`: The user, their ID, or `.` or `reply` replying to their message. - """ - - user_id = user - - user = await models.User.get_or_none(id=user_id).prefetch_related("profiles") - profile: models.Profile | None = user.profiles[0] if user is not None else None - - bans = await models.Ban.filter( - user_id=user_id, - ).order_by("-created_at") - - ongoing_bans = [ - ban - for ban in bans - if ban.expires_at is None or ban.expires_at > datetime.now(timezone.utc) - ] - expired_bans = [ - ban - for ban in bans - if ban.expires_at is not None - and ban.expires_at < datetime.now(timezone.utc) - ] - - embed = discord.Embed( - color=( - (profile.color or constants.EMBED_COLOR) - if profile is not None - else constants.EMBED_COLOR - ), - title="Profile Information", - timestamp=datetime.now(timezone.utc), - fields=[ - discord.EmbedField( - name="Ongoing Bans", - value=fmt.trim( - "\n".join( - [ - f"- `ID: {ban.id}`{f" (by <@{ban.banned_by_id}>)" if ban.banned_by_id else ""}: {ban.reason if ban.reason else "*No reason*"}. Banned {f"until " if ban.expires_at is not None else "permanently"}." - for ban in ongoing_bans - ] - ), - 1024, - ) - or "*No ongoing bans.*", - ), - discord.EmbedField( - name="Past Bans", - value=fmt.trim( - "\n".join( - [ - f"- `ID: {ban.id}`{f" (by <@{ban.banned_by_id}>)" if ban.banned_by_id else None}: {ban.reason if ban.reason else "*No reason*"}. {"Expired" if ban.unbanned_by_id is None else f"Unbanned by <@{ban.unbanned_by_id}>"} ." - for ban in expired_bans - ] - ), - 1024, - ) - or "*No expired bans.*", - ), - ], - author=( - profile.embed_author if profile is not None else self.bot.embed_author - ), - thumbnail=( - profile.avatar_url - if profile is not None - else self.bot.user.display_avatar.url - ), - ) - - await ctx.send(embed=embed) - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. - - Args: - b (bot.Payphone): The bot instance to load the cog into. - """ - - b.add_cog(Mods(b)) diff --git a/payphone/cogs/mods/embeds.py b/payphone/cogs/mods/embeds.py deleted file mode 100644 index 1a8f475..0000000 --- a/payphone/cogs/mods/embeds.py +++ /dev/null @@ -1,84 +0,0 @@ -import discord -from discord.utils import find - -from payphone import bot -from payphone.db import models -from payphone.core import constants, formatting - - -class BanNotificationEmbed(discord.Embed): - """Notification embed sent to users when they get banned.""" - - def __init__(self, bot: bot.Payphone, ban: models.Ban) -> None: - """Initializes the embed. - - Args: - bot (bot.Payphone): The instance of the bot. - ban (models.Ban): The ban. - """ - - super().__init__( - color=constants.EMBED_COLOR, - title="You've Been Banned from Payphone", - description="You have been banned from using Payphone.", - fields=[ - discord.EmbedField( - name="Reason", - value=ban.reason if ban.reason else "*No reason.*", - ), - discord.EmbedField( - name="Expires At", - value=( - f" ()" - if ban.expires_at - else "*Does not expire.*" - ), - ), - ], - author=bot.embed_author, - footer=discord.EmbedFooter( - text="If the ban was automatically set, it may be removed after a moderator checks it." - ), - thumbnail=bot.user.display_avatar.url, - ) - - -class ReportEmbed(discord.Embed): - """The report embed shown to moderators.""" - - def __init__(self, bot: bot.Payphone, report: models.Report) -> None: - """Initializes the embed. - - Args: - bot (bot.Payphone): The instance of the bot. - report (models.Report): The report. - """ - - reported_by_profile: models.Profile = find( - lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss - ).profile - - super().__init__( - color=constants.EMBED_COLOR, - title="Report", - description=f"{reported_by_profile.link} reported a call.", - fields=[ - discord.EmbedField(name="Reason", value=report.reason), - discord.EmbedField( - name="Participants", - value=( - f"Channel 1: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id == report.call.call_rss[0].channel_id, report.call.call_rss)))}\n" - f"Channel 2: {", ".join(map(lambda rs: rs.profile.link, filter(lambda rs: rs.channel_id != report.call.call_rss[0].channel_id, report.call.call_rss)))}" - ), - ), - discord.EmbedField( - name="Duration", - value=f"{formatting.time_to_text(report.call.ended_at - report.call.started_at)}, from to .", - ), - ], - author=bot.embed_author, - footer=discord.EmbedFooter( - text=f"Call ID: {report.call.id} | Report ID: {report.id}" - ), - thumbnail=bot.user.display_avatar.url, - ) diff --git a/payphone/cogs/mods/modals.py b/payphone/cogs/mods/modals.py deleted file mode 100644 index a2edc6d..0000000 --- a/payphone/cogs/mods/modals.py +++ /dev/null @@ -1,90 +0,0 @@ -import discord -from discord import errors - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.core import formatting -from payphone.cogs.mods import embeds - - -class BanUserModal(discord.ui.Modal): - """Modal sent to ban a user.""" - - def __init__( - self, bot: bot.Payphone, profile: models.Profile, report_id: int | None = None - ) -> None: - """Initializes the modal. - - Args: - bot (bot.Bot): The bot. - report_id (int, optional): The ID of the report, if any. - profile (models.Profile): The profile of the user to ban. - """ - - self.bot = bot - self.profile = profile - self.report_id = report_id - - super().__init__( - discord.ui.InputText( - style=discord.InputTextStyle.long, - label="Reason", - max_length=500, - required=False, - ), - discord.ui.InputText( - label="Duration", placeholder="30d", value="30d", required=False - ), - title=f"Ban {profile.name}", - ) - - @transactions.atomic() - async def callback(self, interaction: discord.Interaction): - await interaction.response.defer() - - duration = None - - if self.children[1].value: - try: - duration = formatting.parse_timedelta(self.children[1].value) - - except ValueError: - await interaction.response.send_message( - f"❌ The ban's duration (`{self.children[1].value}`) is not a valid time. Time must be formatted like `28d 3h 5s`." - ) - return - - ban = await self.bot.ban( - self.profile.user_id, - duration, - self.children[0].value, - interaction.user.id, - self.report_id - ) - - try: - notification_message = await ( - await self.bot.get_or_fetch_user(self.profile.user_id) - ).send( - embed=embeds.BanNotificationEmbed(self.bot, ban) - ) - - ban.notification_message_id = notification_message.id - await ban.save() - - except errors.DiscordException: - pass - - message = f"✅ **{self.profile.name}** (User ID: {self.profile.user_id}) was banned by {interaction.user.mention}" - - if ban.expires_at is not None: - message += f"{f" for `{formatting.time_to_text(duration)}`" if duration is not None else ""} until ()" - - else: - message += " permanently" - - message += "." - - await interaction.channel.send(message) diff --git a/payphone/cogs/mods/views.py b/payphone/cogs/mods/views.py deleted file mode 100644 index 5e09e6f..0000000 --- a/payphone/cogs/mods/views.py +++ /dev/null @@ -1,233 +0,0 @@ -import discord -from discord import errors -from discord.ext import pages -from discord.utils import find - -from tortoise import transactions - -from payphone import bot -from payphone.db import models -from payphone.core import constants, formatting as fmt -from payphone.cogs.mods import modals, views, embeds - - -class ReportView(discord.ui.View): - """The view sent together with the report embed.""" - - def __init__( - self, bot: bot.Payphone, report: models.Report, remaining_reports: int - ) -> None: - """Initializes the view. - - Args: - bot (bot.Payphone): The bot. - report (models.Report): The report. - remaining_reports (int): The amount of reports that need to be reviewed remaining. - """ - - super().__init__(timeout=900, disable_on_timeout=True) - - self.bot = bot - self.report = report - - option_labels = set( - f"Ban {rs.profile.name} [ID: {rs.user_id}]" for rs in report.call.call_rss - ) - - actions = discord.ui.Select( - placeholder="Actions", - options=[discord.SelectOption(label=label) for label in option_labels], - ) - - async def actions_callback(interaction: discord.Interaction) -> None: - # interaction.data = {'values': ['Ban Nyeki (JPP)'], 'custom_id': 'staff_call_report:actions', 'component_type': 3} - value = interaction.data["values"][0] - - user_id = int(value.rsplit("[ID: ", 1)[1][:-1]) - - await interaction.response.send_modal( - modals.BanUserModal( - bot, - find( - lambda rs: rs.user_id == user_id, report.call.call_rss - ).profile, - report.id, - ) - ) - - actions.callback = actions_callback - - self.add_item(actions) - self.add_item( - discord.ui.Button( - label=f"Remaining: {remaining_reports}", disabled=True, row=2 - ) - ) - - @discord.ui.button(label="Message Logs", style=discord.ButtonStyle.gray, row=2) - async def message_logs( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - messages = ( - await models.Message.filter(call=report.call) - .order_by("id") - .prefetch_related("contents") - ) - - logs = [] - - reporter_channel_id = find( - lambda rs: rs.user_id == report.reported_by_id, report.call.call_rss - ).channel_id - - profiles_to_relationship_mapping = { - rs.profile_id: rs for rs in report.call.call_rss - } - - last_user_id = None - last_channel_id = None - - current_block = "" - - for message in messages: - is_new_block = ( - message.user_id != last_user_id or last_channel_id != message.channel_id - ) - - if is_new_block: - logs.append(current_block) - current_block = "" - - current_block += f"{"+" if message.channel_id == reporter_channel_id else "-"} {f"{profiles_to_relationship_mapping[message.profile_id].profile.name}{f" (Masked as {profiles_to_relationship_mapping[message.profile_id].mask_name})" if profiles_to_relationship_mapping[message.profile_id].is_masked else ""}" if message.profile_id in profiles_to_relationship_mapping else "Unknown"}:" - - for content in message.contents: - current_block += ( - f"\n{"!" if not content.was_propagated else "X" if message.was_deleted else " "} {content.content}" - ) - - last_user_id = message.user_id - last_channel_id = message.channel_id - - log_pages = [] - - for current_block in logs: - if len(log_pages) > 0 and len(log_pages[-1]) + len(current_block) < 1000: - log_pages[-1] += "\n\n" + current_block.replace( - "```", - f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", - ) - - else: - log_pages.append( - current_block.replace( - "```", - f"`{constants.INVISIBLE_CHARACTER}`{constants.INVISIBLE_CHARACTER}`", - ) - ) - - await pages.Paginator( - [f"```diff\n{page}\n```" for page in log_pages], - timeout=300, - ).respond(interaction, ephemeral=True) - - @discord.ui.button(label="Accept", style=discord.ButtonStyle.green, row=2) - @transactions.atomic() - async def accept( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - report.status = models.ReportStatus.ACCEPTED - report.reviewed_by_id = interaction.user.id - await report.save() - - next_report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - banned_user_ids = ( - await models.Ban.filter(report=report) - .distinct() - .values_list("user_id", flat=True) - ) - - if next_report is None: - await interaction.response.edit_message( - content="✅ No reports left, have this cookie 🍪 !!", - embed=None, - view=None, - ) - - else: - await interaction.response.edit_message( - embed=embeds.ReportEmbed(self.bot, next_report), - view=views.ReportView(self.bot, next_report, total), - ) - - if len(banned_user_ids) > 0: - profiles = [ - rs.profile - for rs in report.call.call_rss - if rs.user_id in banned_user_ids - ] - - try: - await (await self.bot.get_or_fetch_user(report.reported_by_id)).send( - f"🔔 Your report for call `{report.call_id}` helped us ban " - + fmt.text_join( - [ - ( - f"`{p.name}`" - if p.user_id != report.reported_by_id - else "`you`" - ) - for p in profiles - ] - ) - + ". Thank you for your report!" - ) - - except errors.DiscordException: - pass - - @discord.ui.button(label="Decline", style=discord.ButtonStyle.red, row=2) - @transactions.atomic() - async def decline( - self, _button: discord.ui.Button, interaction: discord.Interaction - ) -> None: - report = self.report - - report.status = models.ReportStatus.DECLINED - report.reviewed_by_id = interaction.user.id - await report.save() - - report = ( - await models.Report.filter(status=models.ReportStatus.ON_REVIEW) - .order_by("-priority") - .limit(1) - .first() - .select_related("call") - .prefetch_related("call__call_rss", "call__call_rss__profile") - ) - total = await models.Report.filter(status=models.ReportStatus.ON_REVIEW).count() - - if report is None: - await interaction.response.edit_message( - content="✅ No reports left, have this cookie 🍪 !!", - embed=None, - view=None, - ) - return - - await interaction.response.edit_message( - embed=embeds.ReportEmbed(self.bot, report), - view=views.ReportView(self.bot, report, total), - ) diff --git a/payphone/cogs/users/cog.py b/payphone/cogs/users/cog.py deleted file mode 100644 index 896b148..0000000 --- a/payphone/cogs/users/cog.py +++ /dev/null @@ -1,592 +0,0 @@ -import io - -from datetime import datetime, timezone - -import discord -from discord.ext import commands, pages, tasks -from discord.utils import find - -from tortoise import transactions, functions, expressions, exceptions - -import colorthief - -import httpx - -from payphone import bot -from payphone.db import models, utils -from payphone.core import constants -from payphone.cogs import base -from payphone.cogs.users import views - - -class Users(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - self.update_leaderboard.add_exception_type(exceptions.ConfigurationError) - self.update_leaderboard.start() - - def cog_unload(self) -> None: - self.update_leaderboard.cancel() - - @tasks.loop(minutes=15) - async def update_leaderboard(self) -> None: - """Updates the leaderboard.""" - - calls = ( - await models.Call.annotate(messages_count=functions.Count("messages")) - .filter( - started_at__month=datetime.now(timezone.utc).month, - started_at__year=datetime.now(timezone.utc).year, - messages_count__gte=25, - ) - .prefetch_related("call_rss", "call_rss__user") - ) - - # ID to count - users_count_map: dict[int, int] = {} - - users_map: dict[int, models.User] = { - rs.user.id: rs.user for call in calls for rs in call.call_rss - } - - for call in calls: - for rs in call.call_rss: - if rs.user_id in users_count_map: - users_count_map[rs.user_id] += 1 - - else: - users_count_map[rs.user_id] = 0 - - user_counts = list(users_count_map.items()) - - user_counts.sort(key=lambda i: i[1], reverse=True) - - for i, user_map in enumerate(user_counts): - users_map[user_map[0]].leaderboard_position = i + 1 - users_map[user_map[0]].leaderboard_calls_count = user_map[1] - - await models.User.bulk_update( - users_map.values(), ["leaderboard_position", "leaderboard_calls_count"], 500 - ) - - @commands.Cog.listener() - @transactions.atomic() - async def on_user_update(self, _before: discord.User, after: discord.User) -> None: - """Updates a user's Profile. - - Args: - _before (discord.User): The user before the change. - after (discord.User): The user after the change. - """ - - profile = await models.Profile.get_or_none(user_id=after.id, server_id=None) - - is_updated = False - - if profile is None: - return - - if profile.avatar_url != after.display_avatar.url: - async with httpx.AsyncClient() as client: - response = await client.get(after.display_avatar.url) - - if response.status_code not in range(200, 300): - return - - f = io.BytesIO(response.content) - - (r, g, b) = colorthief.ColorThief(f).get_color(quality=1) - - profile.color = (r << 16) | (g << 8) | b - - is_updated = True - - if profile.name != after.display_name: - profile.name = after.display_name - is_updated = True - - if is_updated: - await profile.save() - - @commands.Cog.listener() - @transactions.atomic() - async def on_member_update( - self, _before: discord.Member, after: discord.Member - ) -> None: - """Updates a member's profile. - - Args: - _before (discord.Member): The member's profile before the update. - after (discord.Member): The member's profile after the update. - """ - - profile = await models.Profile.get_or_none( - user_id=after.id, server_id=after.guild.id - ) - - is_updated = False - - if profile is None: - return - - if profile.avatar_url != after.display_avatar.url: - async with httpx.AsyncClient() as client: - response = await client.get(after.display_avatar.url) - - if response.status_code not in range(200, 300): - return - - f = io.BytesIO(response.content) - - (r, g, b) = colorthief.ColorThief(f).get_color(quality=1) - - profile.color = (r << 16) | (g << 8) | b - - is_updated = True - - if profile.name != after.display_name: - profile.name = after.display_name - is_updated = True - - if is_updated: - await profile.save() - - @commands.command() - @transactions.atomic() - async def mask(self, ctx: commands.Context, toggle: bool | None = None) -> None: - """ - Hide your identity during calls with a mask. - - Options: - - `[]`: On or off. If not set, your current status and mask will be displayed. - """ - - user, _ = await utils.get_user(ctx.author) - - channel_id = ctx.channel.id - - if toggle is None: - if user.is_masked: - channel = await models.Channel.get_or_none( - id=channel_id - ).select_related("call") - call_rs = ( - await models.CallUserChannelRelationship.get_or_none( - channel=channel, user=user, call=channel.call - ) - if channel is not None - else None - ) - - message = "🕵️ You're masked!" - - if call_rs is not None: - message += f" You're currently being displayed as **{call_rs.mask_emoji} {call_rs.display_name}**." - - else: - message += " Your identity will be hidden during calls." - - message += f" Use `{ctx.clean_prefix}mask off` to turn it off." - - await ctx.send(message) - - else: - await ctx.send( - f"🕵️ You're not masked! Use `{ctx.clean_prefix}mask on` to mask yourself." - ) - - else: - user.is_masked = toggle - await user.save() - - past_user_call_rss = ( - await models.CallUserChannelRelationship.filter(user=user) - .order_by("-id") - .limit(5) - .select_related("call") - ) - - def update( - rs: models.CallUserChannelRelationship, - ) -> models.CallUserChannelRelationship: - rs.is_masked = user.is_masked - return rs - - ongoing_call_user_rss = list( - map( - update, - filter(lambda rs: rs.call.ended_at is None, past_user_call_rss), - ) - ) - - if len(ongoing_call_user_rss) > 0: - await models.CallUserChannelRelationship.bulk_update( - ongoing_call_user_rss, ["is_masked"] - ) - - message = ( - "🕵️ From now on your profile will be " - + ("hidden" if toggle else "displayed") - + "." - ) - - current_channel_ongoing_call_user_rs = find( - lambda rs: rs.channel_id == channel_id, ongoing_call_user_rss - ) - - if current_channel_ongoing_call_user_rs and toggle: - message += f" Your profile will be displayed as **{current_channel_ongoing_call_user_rs.mask_emoji} {current_channel_ongoing_call_user_rs.display_name}**." - - message += ( - f" Use `{ctx.clean_prefix}mask` during a call to see your mask." - ) - - else: - if toggle: - message += f" Use `{ctx.clean_prefix}mask off` to turn it off." - - else: - message += f" Use `{ctx.clean_prefix}mask on` to turn it on." - - await ctx.send(message) - - @commands.command() - @transactions.atomic() - async def history(self, ctx: commands.Context) -> None: - """ - See your calls history. Links will take you to the beginning of the call. - """ - - user_id = ctx.author.id - - call_rss = ( - await models.CallUserChannelRelationship.filter(user_id=user_id) - .order_by("-id") - .select_related("profile") - .limit(30) - ) - - if len(call_rss) == 0: - await ctx.send( - f"☎️ You haven't participated in a call yet! Use `{ctx.clean_prefix}call` to start one." - ) - return - - call_ids = [rs.call_id for rs in call_rss] - - profile = call_rss[0].profile - - call_rss = ( - await models.CallUserChannelRelationship.filter(call_id__in=call_ids) - .select_related("profile") - .order_by("-id") - .all() - ) - calls_rss_map = {} - - for call_rs in call_rss: - if call_rs.call_id not in calls_rss_map: - calls_rss_map[call_rs.call_id] = { - "user_rs": None, - "others_rs": [], - "first_message": None, - } - - if call_rs.user_id == user_id: - calls_rss_map[call_rs.call_id]["user_rs"] = call_rs - - else: - calls_rss_map[call_rs.call_id]["others_rs"].append(call_rs) - - min_ids = await ( - models.Message.filter(call_id__in=call_ids) - .exclude(propagation_id=None) - .annotate(min_id=functions.Min("id")) - .group_by("call_id") - .values_list("min_id", flat=True) - ) - - # Step 2: Fetch the actual messages using these IDs - first_messages = ( - await models.Message.filter(id__in=min_ids) - .select_related("propagation_channel", "channel") - .all() - ) - - for msg in first_messages: - calls_rss_map[msg.call_id]["first_message"] = msg - - embeds = [] - - logs = "" - logs_count = 0 - - for call_id, data in calls_rss_map.items(): - logs += f"- `ID: {call_id}`: " - - participant_names = ", ".join( - ( - call_rs.display_name - if call_rs.channel_id != data["user_rs"].channel_id - else call_rs.profile.name - ) - for call_rs in data["others_rs"] + [data["user_rs"]] - ) - - link = None - - first_message: models.Message | None = data["first_message"] - - if first_message is not None: - if first_message.channel_id == data["user_rs"].channel_id: - link = f"https://discord.com/channels/{first_message.channel.server_id}/{first_message.channel_id}/{first_message.id}" - - else: - link = f"https://discord.com/channels/{first_message.propagation_channel.server_id}/{first_message.propagation_channel.id}/{first_message.propagation_id}" - - if link is not None: - logs += f"[{participant_names}]({link})" - - else: - logs += participant_names - - logs += "\n" - logs_count += 1 - - if logs.count("\n") == 10: - embeds.append( - discord.Embed( - title="Call History", - description="Last calls:\n" + logs, - thumbnail=profile.avatar_url, - author=discord.EmbedAuthor( - name=profile.name, icon_url=profile.avatar_url - ), - footer=discord.EmbedFooter(text=f"Page {len(embeds) + 1} of 3"), - ) - ) - - logs = "" - - if logs != "": - embeds.append( - discord.Embed( - color=profile.color, - title="Call History", - description="Last calls:\n" + logs, - thumbnail=profile.avatar_url, - author=discord.EmbedAuthor( - name=profile.name, icon_url=profile.avatar_url - ), - footer=discord.EmbedFooter(text=f"Page {len(embeds) + 1} of 3"), - ) - ) - - paginator = pages.Paginator( - embeds, - show_indicator=False, - use_default_buttons=False, - custom_buttons=[ - pages.PaginatorButton( - button_type="prev", label="⬅️", style=discord.ButtonStyle.blurple - ), - pages.PaginatorButton( - button_type="next", label="➡️", style=discord.ButtonStyle.blurple - ), - ], - ) - - await paginator.send(ctx) - - @commands.command() - @transactions.atomic() - async def me(self, ctx: commands.Context) -> None: - """ - Shows your user statistics. - """ - - user, profile = await utils.get_user(ctx.author) - - messages_count = await models.Message.filter(user=user).count() - - channel_ids = ( - await models.CallUserChannelRelationship.filter(user=user) - .distinct() - .values_list("channel_id", flat=True) - ) - call_ids = ( - await models.CallUserChannelRelationship.filter(user=user) - .distinct() - .values_list("call_id", flat=True) - ) - - people_met_count = await models.CallUserChannelRelationship.filter( - call_id__in=call_ids, channel_id__not_in=channel_ids - ).count() - ban = ( - await models.Ban.filter( - expressions.Q( - expires_at__gt=datetime.now(timezone.utc), - expires_at=None, - join_type="OR", - ), - user_id=user.id, - ) - .limit(1) - .first() - ) - - reviewed_reports = ( - (await models.Report.filter(reviewed_by_id=user.id).count()) - if user.is_moderator - else None - ) - bans_made = ( - (await models.Ban.filter(banned_by_id=user.id).count()) - if user.is_moderator - else None - ) - bans_unmade = ( - (await models.Ban.filter(unbanned_by_id=user.id).count()) - if user.is_moderator - else None - ) - - embed = discord.Embed( - color=profile.color or constants.EMBED_COLOR, - timestamp=datetime.now(timezone.utc), - fields=[ - discord.EmbedField( - name="Messages", value=str(messages_count), inline=True - ), - discord.EmbedField(name="Calls", value=len(call_ids), inline=True), - discord.EmbedField( - name="People Met", value=people_met_count, inline=True - ), - discord.EmbedField( - name="Joined At", - value=f" ", - inline=True, - ), - discord.EmbedField( - name="Leaderboard", - value=( - f"#{user.leaderboard_position} (Monthly)" - if user.leaderboard_position is not None - else "*No calls yet.*" - ), - inline=True, - ), - discord.EmbedField( - name="Banned", - value=( - "*Not banned.*" - if ban is None - else ( - "*Permanent.*" - if ban.expires_at is None - else f"Until ()" - ) - ), - inline=True, - ), - ] - + ( - [ - discord.EmbedField( - name="Reviewed Reports", value=reviewed_reports, inline=True - ), - discord.EmbedField(name="Bans Made", value=bans_made, inline=True), - discord.EmbedField( - name="Bans Unmade", value=bans_unmade, inline=True - ), - ] - if user.is_moderator - else [] - ), - author=discord.EmbedAuthor(name=profile.name, icon_url=profile.avatar_url), - footer=discord.EmbedFooter( - text="Thank you for using Payphone! Leaderboard updates every 15 minutes." - ), - thumbnail=profile.avatar_url, - ) - - await ctx.send(embed=embed, view=views.MeView(user)) - - @commands.command(aliases=["l", "leader", "ranking"]) - async def leaderboard(self, ctx: commands.Context, board: str = "users") -> None: - """ - Shows the leaderboard for the month. - - Options: - - `[]`: The board to show (users/servers). Defaults to users. - """ - - users_board_options = ["u", "user", "users"] - server_board_options = ["s", "server", "servers", "g", "guild", "guilds"] - - board = board.lower() - - if board not in users_board_options + server_board_options: - await ctx.send( - f"❌ This leaderboard does not exist. The leaderboards are `servers` and `users`, for example `{ctx.clean_prefix}leaderboard users`." - ) - return - - table = "" - table_name = "users" if board in users_board_options else "servers" - - if board in users_board_options: - top_users = ( - await models.User.filter(leaderboard_position__range=[1, 5]) - .prefetch_related("profiles") - .order_by("leaderboard_position") - .limit(5) - ) - user_rows = [ - f"**#{pos + 1} - {user.profiles[0].name}**{" (You!)" if user.id == ctx.author.id else ""}\nParticipated in {user.leaderboard_calls_count} calls." - for pos, user in enumerate(top_users) - ] - - table = "__**Top Users:**__\n\n" + "\n\n".join(user_rows) - - else: - top_servers = ( - await models.Server.filter(leaderboard_position__range=[1, 5]) - .order_by("leaderboard_position") - .limit(5) - ) - server_rows = [ - f"**#{pos + 1} - {server.name or "Updating..."}{f" · .gg/{server.vanity_code}" if server.vanity_code else ""}**{" (Here!)" if ctx.guild is not None and server.id == ctx.guild.id else ""}\nParticipated in {server.leaderboard_calls_count} calls." - for pos, server in enumerate(top_servers) - ] - - table = "__**Top Servers:**__\n\n" + "\n\n".join(server_rows) - - embed = discord.Embed( - color=constants.EMBED_COLOR, - title=f"Monthly Leaderboard ({datetime.now(timezone.utc).strftime("%B")})", - description=( - f"Based on the amount of calls {table_name} have participated in with more than 25 " - "messages sent (to prevent spam).\n\n" - ) - + table, - author=self.bot.embed_author, - footer=discord.EmbedFooter( - text="The leaderboard is updated every 15 minutes." - ), - thumbnail=self.bot.user.display_avatar.url, - ) - - await ctx.send(embed=embed) - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. - - Args: - b (bot.Payphone): The bot instance to load the cog into. - """ - - b.add_cog(Users(b)) diff --git a/payphone/cogs/users/views.py b/payphone/cogs/users/views.py deleted file mode 100644 index c7c6632..0000000 --- a/payphone/cogs/users/views.py +++ /dev/null @@ -1,69 +0,0 @@ -import discord - -from payphone.db import models - - -class MeView(discord.ui.View): - status: int = 0 - - def __init__(self, user: models.User) -> None: - """Initializes the view. - - Args: - user (models.user): The user to show the view for. - """ - - super().__init__(disable_on_timeout=True) - - async def callback(interaction: discord.Interaction) -> None: - """Handles the interaction of the button.""" - - if interaction.user.id != user.id: - await interaction.response.send_message( - "❌ You cannot delete other person's account.", ephemeral=True - ) - return - - if self.status == 0: - self.children[0].label = "Confirm?" - await interaction.response.edit_message(view=self) - - self.status += 1 - return - - user.is_deleting = True - await user.save() - - self.disable_all_items() - self.children[0].label = "Your account will be deleted" - await interaction.response.send_message( - ( - "👋 Your account was scheduled for deletion. It may take up to 1 hour for " - "your account information to be deleted.\n\n" - "What will be deleted:\n" - "- Your messages\n" - "- Your calls\n" - "- Your profiles and user information\n" - "- Your stats\n" - "- Your reports\n\n" - "What won't be deleted:\n" - "- Your bans and ban history" - ), - ephemeral=True, - ) - await interaction.message.edit(view=self) - - if not user.is_deleting: - button = discord.ui.Button( - style=discord.ButtonStyle.danger, label="Delete My Activity Data" - ) - button.callback = callback - - else: - button = discord.ui.Button( - style=discord.ButtonStyle.danger, - label="Your account is being deleted", - disabled=True, - ) - - self.add_item(button) diff --git a/payphone/cogs/utility/__init__.py b/payphone/cogs/utility/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/payphone/cogs/utility/cog.py b/payphone/cogs/utility/cog.py deleted file mode 100644 index c111e46..0000000 --- a/payphone/cogs/utility/cog.py +++ /dev/null @@ -1,49 +0,0 @@ -from datetime import datetime, timezone - -import discord -from discord.ext import commands, tasks - -from payphone import bot -from payphone.cogs import base -from payphone.core import config, constants, checks - - -class Utility(base.Cog): - def __init__(self, bot_instance: bot.Payphone): - self.bot = bot_instance - - @commands.command() - @commands.cooldown(1, 60, commands.BucketType.user) - @checks.is_unbanned() - async def suggest(self, ctx: commands.Context, *, suggestion: str) -> None: - """ - Make a suggestion to improve Payphone. - """ - - embed = discord.Embed( - color=constants.EMBED_COLOR, - title="Suggestion", - description=suggestion, - timestamp=datetime.now(timezone.utc), - author=discord.EmbedAuthor( - name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url - ), - footer=discord.EmbedFooter(text=ctx.author.name), - thumbnail=self.bot.user.display_avatar.url, - ) - - await self.bot.get_partial_messageable(config.SUGGESTIONS_CHANNEL_ID).send( - embed=embed - ) - - await ctx.send("✅ Thank you for your suggestion!") - - -def setup(b: bot.Payphone) -> None: - """Load the cog into the bot. - - Args: - b (bot.Payphone): The bot instance to load the cog into. - """ - - b.add_cog(Utility(b)) diff --git a/payphone/core/calls.py b/payphone/core/calls.py new file mode 100644 index 0000000..c812c5d --- /dev/null +++ b/payphone/core/calls.py @@ -0,0 +1,494 @@ +from dataclasses import dataclass + +import orjson + +from payphone.bot import Payphone + + +@dataclass +class Participant: + """A participant in Payphone.""" + + _bot: Payphone + + id: int + name: str + avatar_url: str + + +@dataclass +class Call: + """A call in Payphone.""" + + _bot: Payphone + + id: int + channels: tuple[int, int] + + async def set_message( + self, id: int, channel_id: int, propagation_id: int, author_id: int + ) -> None: + """Sets a message as part of a call. + + Args: + id (int): The ID of the message to set. + channel_id (int): The ID of the channel to set the message in. + propagation_id (int): The ID of the propagation message. + author_id (int): The ID of the author of the message. + """ + + pipe = self._bot.redis.pipeline() + + pipe.hset("calls:messages", id, self.id) + pipe.hset("calls:messages:channels", id, channel_id) + pipe.hset("calls:messages:propagations", id, propagation_id) + pipe.hset("calls:messages:reversed", propagation_id, id) + pipe.hset("calls:messages:authors", id, author_id) + pipe.hset("calls:last-message", self.id, "") + pipe.hexpire("calls:messages", 15 * 60, id) + pipe.hexpire("calls:messages:channels", 15 * 60, id) + pipe.hexpire("calls:messages:propagations", 15 * 60, id) + pipe.hexpire("calls:messages:reversed", 15 * 60, propagation_id) + pipe.hexpire("calls:messages:authors", 15 * 60, id) + pipe.hexpire("calls:last-message", 15 * 60, self.id) + + await pipe.execute() + + async def is_message_from_call(self, id: int) -> bool: + """Checks if a message is from a call. + + Args: + id (int): The ID of the message to check. + + Returns: + bool: True if the message is from the call, False otherwise. + """ + + return bool(await self._bot.redis.hexists("calls:messages", id)) or bool( + await self._bot.redis.hexists("calls:messages:reversed", id) + ) + + async def get_message_propagation(self, id: int) -> tuple[int, int] | None: + """Gets a message as part of a call. + + Args: + id (int): The ID of the message to get. + + Returns: + tuple[int, int]: The channel and propagation ID of the message. + """ + + pipe = self._bot.redis.pipeline() + + pipe.hget("calls:messages:channels", id) + pipe.hget("calls:messages:propagations", id) + + channel_id, propagation_id = await pipe.execute() + + if channel_id is None or propagation_id is None: + return + + propagation_channel_id = ( + self.channels[0] + if int(channel_id) != self.channels[0] + else self.channels[1] + ) + + return int(propagation_channel_id), int(propagation_id) + + async def set_participant( + self, user_id: int, user_name: str, user_avatar_url: str + ) -> Participant: + """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. + + Returns: + Participant: The participant that was set. + """ + + data = orjson.dumps({"name": user_name, "avatar_url": user_avatar_url}) + + 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) + + return Participant( + _bot=self._bot, id=user_id, name=user_name, avatar_url=user_avatar_url + ) + + async def get_participant(self, user_id: int) -> Participant | None: + """Gets a participant in a call. + + Args: + user_id (int): The ID of the user to get. + + Returns: + Participant | None: The participant that was set. + """ + + data = await self._bot.redis.hget(f"calls:{self.id}:participants", user_id) + + if data is None: + return + + data = orjson.loads(data) + + return Participant( + _bot=self._bot, id=user_id, name=data["name"], avatar_url=data["avatar_url"] + ) + + async def get_participants(self) -> list["Participant"]: + """Gets all participants in a call. + + Returns: + list[Participant]: The participants in the call. + """ + + participants = [] + + data = await self._bot.redis.hgetall(f"calls:{self.id}:participants") + + for user_id, data in data.items(): + data = orjson.loads(data) + + participants.append( + Participant( + _bot=self._bot, + id=int(user_id), + name=data["name"], + avatar_url=data["avatar_url"], + ) + ) + + return participants + + +class CallHandler: + """Handles calls in Payphone.""" + + def __init__(self, bot: Payphone) -> None: + """Initializes the CallHandler. + + Args: + bot (Payphone): The bot instance. + """ + + self.bot = bot + + async def set_searching(self, channel_id: int, status: bool) -> None: + """Sets the channel as searching for a call. + + Args: + channel_id (int): The ID of the channel to set as searching. + status (bool): The status to set the channel to. + """ + + if status: + await self.bot.redis.sadd("calls:channels:searching", channel_id) + + else: + await self.bot.redis.srem("calls:channels:searching", channel_id) + + async def connect(self) -> list[tuple[int, int]]: + """Connects two channels together. + + Returns: + list[tuple[int, int]]: A list of channel pairs. + """ + + pairs = [] + + while True: + channels = await self.bot.redis.spop("calls:channels:searching", 2) + + if len(channels) < 2: + if len(channels) == 1: + await self.set_searching(channels[0], True) + + break + + last_calls = await self.bot.redis.hmget("last-calls", *channels) + + break_outer = False + + for channel in channels: + if channel in last_calls: + next_channel = await self.bot.redis.spop("calls:channels:searching") + + if next_channel is not None: + other_channel_id = channels[abs(channels.index(channel) - 1)] + await self.set_searching(other_channel_id, True) + + channels[channels.index(channel)] = next_channel + + else: + for channel in channels: + await self.set_searching(channel, True) + + break_outer = True + break + + if break_outer: + break + + pairs.append(tuple(channels)) + + mapping = {} + reverse_mapping = {} + + for pair in pairs: + last_call_id = await self.bot.redis.incr("calls:calls:count") + + mapping[pair[0]] = last_call_id + mapping[pair[1]] = last_call_id + + reverse_mapping[last_call_id] = f"{pair[0]}:{pair[1]}" + + pipe = self.bot.redis.pipeline(transaction=True) + + if mapping: + pipe.hset("calls:channels:active", mapping=mapping) + pipe.hset("calls:calls:active", mapping=reverse_mapping) + + for pair in pairs: + pipe.hmset("last-calls", {pair[0]: pair[1], pair[1]: pair[0]}) + pipe.hset("calls:last-message", mapping[pair[0]], "") + pipe.hexpire("calls:last-message", 60, mapping[pair[0]]) + + await pipe.execute() + + return pairs + + async def expire(self) -> list[tuple[int, int]]: + """ + Ends all calls that have been inactive for more than 15 seconds. + """ + + active_calls = await self.bot.redis.hgetall("calls:calls:active") + + pairs = [] + + pipe = self.bot.redis.pipeline(transaction=True) + + for call_id, channels in active_calls.items(): + channels = [int(c) for c in channels.split(":")] + + last_message = await self.bot.redis.hget("calls:last-message", call_id) + + if last_message is not None: + continue + + pipe.hdel("calls:channels:active", *channels) + pipe.hdel("calls:calls:active", call_id) + pipe.srem("calls:channels:searching", *channels) + + pairs.append(tuple(channels)) + + await pipe.execute() + + return pairs + + async def is_searching(self, channel_id: int) -> bool: + """Checks if a channel is searching for a call. + + Args: + channel_id (int): The ID of the channel to check. + + Returns: + bool: True if the channel is searching for a call, False otherwise. + """ + + return bool( + await self.bot.redis.sismember("calls:channels:searching", channel_id) + ) + + async def is_on_call(self, channel_id: int) -> bool: + """Checks if a channel is on a call. + + Args: + channel_id (int): The ID of the channel to check. + + Returns: + bool: True if the channel is on a call, False otherwise. + """ + + return await self.bot.redis.hexists("calls:channels:active", channel_id) + + async def get_call(self, channel_id: int) -> Call | None: + """Gets the call that a channel is in. + + Args: + channel_id (int): The ID of the channel to get the call for. + + Returns: + Call | None: The pair of channels that are connected. + """ + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + return + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + return Call(_bot=self.bot, id=call_id, channels=channels) + + async def hangup(self, channel_id: int) -> tuple[int, int] | tuple[int]: + """Ends a call in a channel. + + Args: + channel_id (int): The ID of the channel to end the call in. + + Returns: + tuple[int, int] | tuple[int]: The pair of channels that were connected. + """ + + await self.set_searching(channel_id, False) + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + # Channel is searching. + await self.set_searching(channel_id, False) + return (channel_id,) + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + for channel in channels: + await self.set_searching(channel, False) + + await self.bot.redis.hdel("calls:channels:active", *channels) + await self.bot.redis.hdel("calls:calls:active", call_id) + + return tuple(channels) + + async def skip(self, channel_id: int) -> tuple[int, int]: + """Skips a channel in the call queue. + + Args: + channel_id (int): The ID of the channel to skip. + """ + + await self.set_searching(channel_id, True) + + call_id = await self.bot.redis.hget("calls:channels:active", channel_id) + + if call_id is None: + return + + channels = [ + int(c) + for c in (await self.bot.redis.hget("calls:calls:active", call_id)).split( + ":" + ) + ] + + for channel in channels: + if channel != channel_id: + await self.set_searching(channel, False) + + await self.bot.redis.hdel("calls:channels:active", *channels) + + return tuple(channels) + + async def get_channel_webhook_url(self, channel_id: int) -> str: + """Gets the webhook for a channel. + + Args: + channel_id (int): The ID of the channel to get the webhook for. + + Returns: + str: The URL of the webhook. + """ + + cached_url = await self.bot.redis.hget("calls:channels:webhooks", channel_id) + + if cached_url: + return cached_url + + channel = await self.bot.get_or_fetch_channel(channel_id) + + if channel is None: + return + + for webhook in await channel.webhooks(): + if webhook.name == "Payphone" and webhook.token is not None: + await self.bot.redis.hset( + "calls:channels:webhooks", channel_id, webhook.url + ) + return webhook.url + + webhook = await channel.create_webhook(name="Payphone") + + await self.bot.redis.hset("calls:channels:webhooks", channel_id, webhook.url) + + return webhook.url + + async def get_message(self, message_id: int) -> None: + """Gets a message as part of a call. + + Args: + message_id (int): The ID of the message to get. + + Returns: + None: The channel and propagation ID of the message. + """ + + return await self.bot.redis.hget("calls:messages", message_id) + + async def get_message_author_id(self, id: int) -> int | None: + """Gets the author of a message as part of a call. + + Args: + id (int): The ID of the message to get. + + Returns: + int: The author ID of the message. + """ + + pipe = self.bot.redis.pipeline() + + pipe.hget("calls:messages", id) + pipe.hget("calls:messages:reversed", id) + + call_id, message_id = await pipe.execute() + + if call_id is None and message_id is None: + return + + message_id = int(message_id) if message_id else id + + author_id = await self.bot.redis.hget("calls:messages:authors", id) + + return int(author_id) + + async def get_original_message_id(self, id: int) -> int | None: + """ + Get the original message ID of a message in a call. + + Args: + id (int): The ID of the message to get. + + Returns: + int: The original message ID of the message. + """ + + message_id = await self.bot.redis.hget("calls:messages:reversed", id) + + if message_id: + return int(message_id) + + return None diff --git a/payphone/core/censorship.py b/payphone/core/censorship.py index 99b9594..d98ea42 100644 --- a/payphone/core/censorship.py +++ b/payphone/core/censorship.py @@ -1,532 +1,23 @@ import re -def create_pattern(text: str) -> str: - """Creates regex patterns for words or phrases with possible variations +words = [] - Args: - text (str): The word or phrase +with open("censored.txt", encoding="UTF-8") as f: + for line in f: + words.append(line.strip()) - Returns: - str: The created pattern. - """ - - replacements = [ - ("a", "a|@|4"), - ("c", "c|ç"), - ("e", "e|3"), - ("h", "h|#"), - ("i", "i|1|!"), - ("o", "o|0"), - ("t", "t|7"), - ("u", "u|v|ü"), - ("v", "u|v|ü"), - ("x", "x|><"), - ] - - text = "+".join(list(text)) + "+" - - for r in replacements: - text = text.replace(r[0], f"({r[1]})") - - return f"(?:{text})" - - -BLOCKED_WORDS = [ - create_pattern(word) - for word in [ - "2g1c", - "2 girls 1 cup", - "acrotomophilia", - "alabama hot pocket", - "alaskan pipeline", - "anal", - "anilingus", - "anus", - "apeshit", - "arsehole", - # "ass", - "asshole", - "assmunch", - "auto erotic", - "autoerotic", - "babeland", - "baby batter", - "baby juice", - "ball gag", - "ball gravy", - "ball kicking", - "ball licking", - "ball sack", - "ball sucking", - "bangbros", - "bangbus", - "bareback", - "barely legal", - "barenaked", - "bastard", - "bastardo", - "bastinado", - "bbw", - "bdsm", - "beaner", - "beaners", - "beaver cleaver", - "beaver lips", - "beastiality", - "bestiality", - "big black", - "big breasts", - "big knockers", - "big tits", - "bimbos", - "birdlock", - "bitch", - "bitches", - "black cock", - "blonde action", - "blonde on blonde action", - "blowjob", - "blow job", - "blow your load", - "blue waffle", - "blumpkin", - "bollocks", - "bondage", - "boner", - "boob", - "boobs", - "booty call", - "brown showers", - "brunette action", - "bukkake", - "bulldyke", - "bullet vibe", - "bullshit", - "bung hole", - "bunghole", - "busty", - "butt", - "buttcheeks", - "butthole", - "camel toe", - "camgirl", - "camslut", - "camwhore", - "carpet muncher", - "carpetmuncher", - "chocolate rosebuds", - "cialis", - "circlejerk", - "cleveland steamer", - "clit", - "clitoris", - "clover clamps", - "clusterfuck", - "cock", - "cocks", - "coprolagnia", - "coprophilia", - "cornhole", - "coon", - "coons", - "creampie", - "cum", - "cumming", - "cumshot", - "cumshots", - "cunnilingus", - "cunt", - "darkie", - "date rape", - "daterape", - "deep throat", - "deepthroat", - "dendrophilia", - "dick", - "dildo", - "dingleberry", - "dingleberries", - "dirty pillows", - "dirty sanchez", - "doggie style", - "doggiestyle", - "doggy style", - "doggystyle", - "dog style", - "dolcett", - "domination", - "dominatrix", - "dommes", - "donkey punch", - "double dong", - "double penetration", - "dp action", - "dry hump", - "dvda", - "eat my ass", - "ecchi", - "ejaculation", - "erotic", - "erotism", - "escort", - "eunuch", - "fag", - "faggot", - "fecal", - "felch", - "fellatio", - "feltch", - "female squirting", - "femdom", - "figging", - "fingerbang", - "fingering", - "fisting", - "foot fetish", - "footjob", - "frotting", - "fuck buttons", - # "fuckin", - # "fucking", - "fucktards", - "fudge packer", - "fudgepacker", - "futanari", - "gangbang", - "gang bang", - "gay sex", - "genitals", - "giant cock", - "girl on", - "girl on top", - "girls gone wild", - "goatcx", - "goatse", - "god damn", - "gokkun", - "golden shower", - "goodpoop", - "goo girl", - "goregasm", - "grope", - "group sex", - "g-spot", - "guro", - "hand job", - "handjob", - "hard core", - "hardcore", - "hentai", - "homoerotic", - "honkey", - "hooker", - "horny", - "hot carl", - "hot chick", - "how to kill", - "how to murder", - "huge fat", - "humping", - "incest", - "intercourse", - "jack off", - "jail bait", - "jailbait", - "jelly donut", - "jerk off", - "jigaboo", - "jiggaboo", - "jiggerboo", - "jizz", - "juggs", - "kike", - "kinbaku", - "kinkster", - "kinky", - "knobbing", - "leather restraint", - "leather straight jacket", - "lemon party", - "livesex", - "lolita", - "lovemaking", - "make me come", - "male squirting", - "masturbate", - "masturbating", - "masturbation", - "menage a trois", - "milf", - "missionary position", - "mong", - "motherfucker", - "mound of venus", - "mr hands", - "muff diver", - "muffdiving", - "nambla", - "nawashi", - "negro", - "neonazi", - "nigga", - "nigger", - "nig nog", - "nimphomania", - "nipple", - "nipples", - # "nsfw", - "nsfw images", - "nude", - "nudity", - "nutten", - "nympho", - "nymphomania", - "octopussy", - "omorashi", - "one cup two girls", - "one guy one jar", - "orgasm", - "orgy", - "paedophile", - "paki", - "panties", - "panty", - "pedobear", - "pedophile", - "pegging", - "penis", - "phone sex", - "piece of shit", - "pikey", - "pissing", - "piss pig", - "pisspig", - "playboy", - "pleasure chest", - "pole smoker", - "ponyplay", - "poof", - "poon", - "poontang", - "punany", - "poop chute", - "poopchute", - "porn", - "porno", - "pornography", - "prince albert piercing", - "pthc", - "pubes", - "pussy", - "queaf", - "queef", - "quim", - "raghead", - "raging boner", - "rape", - "raping", - "rapist", - "rectum", - "reverse cowgirl", - "rimjob", - "rimming", - "rosy palm", - "rosy palm and her 5 sisters", - "rusty trombone", - "sadism", - "santorum", - "scat", - "schlong", - "scissoring", - "semen", - "sex", - "sexcam", - "sexo", - "sexy", - "sexual", - "sexually", - "sexuality", - "shaved beaver", - "shaved pussy", - "shemale", - "shibari", - "shitblimp", - "shota", - "shrimping", - "skeet", - "slanteye", - "slut", - "s&m", - "smut", - "snatch", - "snowballing", - "sodomize", - "sodomy", - "spastic", - "spic", - "splooge", - "splooge moose", - "spooge", - "spread legs", - "spunk", - "strap on", - "strapon", - "strappado", - "strip club", - "style doggy", - "suicide girls", - "sultry women", - "swastika", - "swinger", - "tainted love", - "taste my", - "tea bagging", - "threesome", - "throating", - "thumbzilla", - "tied up", - "tight white", - "spank", - "sperm", - "tit", - "vagina", - "vulva", - "whore", - "ballsack", - "blowjob", - "busty", - "clitoris", - "cockring", - "cunt", - "deepthroat", - "dominatrix", - "ejaculation", - "fetish", - "gangbang", - "genital", - "jizzed", - "lube", - "milf", - "nudity", - "pecker", - "pussylick", - "semen", - "shemale", - "slutty", - "threesome", - "titfuck", - "tit", - "tits", - "titties", - "titty", - "tongue in a", - "topless", - "tosser", - "towelhead", - "tranny", - "tribadism", - "tub girl", - "tubgirl", - "tushy", - "twat", - "twink", - "twinkle", - "two girls one cup", - "undressing", - "upskirt", - "urethra play", - "urophilia", - "vagina", - "venus mound", - "viagra", - "vibrator", - "violet wand", - "vorarephilia", - "voyeur", - "voyeurweb", - "voyuer", - "vulva", - "wank", - "wetback", - "wet dream", - "white powder", - "whore", - "worldsex", - "wrapping men", - "wrinkled starfish", - "xxx", - # "yaoi", - "yellow showers", - "yiffy", - "zoophilia", - "🖕", - # "cracker", - "honky", - "kike", - "weed", - "cocaine", - "heroin", - "meth", - "ecstasy", - "lsd", - "marijuana", - "opioid", - # "crack", - "self-harm", - "self harm", - "self-injury", - "self injury", - "self-mutilation", - "self mutilation", - "hurting myself", - "abortion", - "rape", - "molest", - "pedophile", - "incest", - "abuse", - "trafficking", - "domestic violence", - "child abuse", - "neglect", - "victim", - "heathen", - "infidel", - "heretic", - "blasphemer", - "pagan", - "zealot", - "cultist", - "fanatic", - "apostate", - "idolater", - "fatty", - "lardass", - "twig", - "beanpole", - "chubby", - "porker", - "skeletor", - "chunk", - "blubber", - # "skinny", - "niga", - "niger", - "nga", - ] -] WORDS = re.compile(r"\w+") PATTERN = re.compile( - f"^({"|".join(BLOCKED_WORDS + [word + "s" for word in BLOCKED_WORDS])})$", + f"^({"|".join(words + [word + "s" for word in words])})$", re.IGNORECASE, ) URL_PATTERN = re.compile(r"https?://[^\s]+", re.IGNORECASE) -INVITES_PATTERN = re.compile(r"discord.gg/[^\s]+", re.IGNORECASE) +INVITES_PATTERN = re.compile(r"(discord)?.gg/[^\s]+", re.IGNORECASE) -def moderate(message: str) -> str: +def censor(message: str) -> str: """Returns the first censored word or pattern found. Args: diff --git a/payphone/core/checks.py b/payphone/core/checks.py deleted file mode 100644 index 674b6be..0000000 --- a/payphone/core/checks.py +++ /dev/null @@ -1,51 +0,0 @@ -from discord.ext import commands - -from payphone.db import utils -from payphone.core import exceptions, constants - - -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 - - user, _ = await utils.get_user(ctx.author) - - if user.is_moderator: - return True - - raise exceptions.NotAModeratorError() - - return commands.check(predicate) - - -def is_unbanned(): - """Checks that the user is not banned.""" - - async def predicate(ctx: commands.Context) -> bool: - if await ctx.bot.is_banned(ctx.author): - raise exceptions.BannedError() - - return True - - return commands.check(predicate) - - -def is_on_call(): - """Checks that a call is ongoing in the channel.""" - - async def predicate(ctx: commands.Context) -> bool: - channel_id = ctx.channel.id - - is_on_call = await ctx.bot.redis.sismember( - constants.CHANNELS_ONCALL_SET, str(channel_id) - ) - - if not is_on_call: - raise exceptions.NotOnCallError() - - return True - - return commands.check(predicate) diff --git a/payphone/core/config.py b/payphone/core/config.py deleted file mode 100644 index 0a9e1b2..0000000 --- a/payphone/core/config.py +++ /dev/null @@ -1,40 +0,0 @@ -import os - -from dotenv import load_dotenv - -load_dotenv() - - -TOKEN = os.getenv("TOKEN") - -SHARD_ID = int(os.getenv("SHARD_ID", "0")) -SHARD_COUNT = int(os.getenv("SHARD_COUNT", "1")) - -IS_DEBUG = os.environ.get("IS_DEBUG", "false") == "true" - -TORTOISE_ORM = { - "connections": { - "default": { - "engine": "tortoise.backends.asyncpg", - "credentials": { - "host": "localhost" if IS_DEBUG else "postgresql", - "port": "5432", - "user": "payphone", - "password": "payphone", - "database": "payphone", - }, - "minsize": 5, - "maxsize": 100, - }, - }, - "apps": { - "models": { - "models": ["payphone.db.models", "aerich.models"], - "default_connection": "default", - } - }, -} - -SUGGESTIONS_CHANNEL_ID = int(os.getenv("SUGGESTIONS_CHANNEL_ID", "0")) - -SERVER_INVITE = os.getenv("SERVER_INVITE") diff --git a/payphone/core/constants.py b/payphone/core/constants.py index 591cda2..b251dd6 100644 --- a/payphone/core/constants.py +++ b/payphone/core/constants.py @@ -1,228 +1,31 @@ -import enum +import os -from payphone.core import config +try: + import dotenv + dotenv.load_dotenv() -class Mask(enum.IntEnum): - FERRET = 1278176917123760139 if config.IS_DEBUG else 1278173356423909448 - OCTOPUS = 1278176904410697880 if config.IS_DEBUG else 1278173331421397072 - CAT = 1278176891290783824 if config.IS_DEBUG else 1278173317034934282 - MONKEY = 1278176881195094049 if config.IS_DEBUG else 1278173297456185344 - PANDA = 1278176868767629455 if config.IS_DEBUG else 1278173272390762538 - SHEEP = 1278176856910073946 if config.IS_DEBUG else 1278173251914432563 - HORSE = 1278176842116894760 if config.IS_DEBUG else 1278173237582237708 - BEE = 1278176823846506577 if config.IS_DEBUG else 1278173216652787826 - WILD_BOAR = 1278176806498996235 if config.IS_DEBUG else 1278173196658413649 - DUCK = 1278176794591232102 if config.IS_DEBUG else 1278173181483679918 - CHICKEN = 1278176781790216233 if config.IS_DEBUG else 1278173167218589726 - ELEPHANT = 1278176771413512213 if config.IS_DEBUG else 1278173150063886346 - COW = 1278176761565413483 if config.IS_DEBUG else 1278173125024153733 - DEER = 1278176744691597312 if config.IS_DEBUG else 1278173110105014406 - TURKEY = 1278176730619576321 if config.IS_DEBUG else 1278173091796877363 - DOG = 1278176719932751903 if config.IS_DEBUG else 1278173070846201856 - BEAVER = 1278176709530882091 if config.IS_DEBUG else 1278173051485425705 - CAMEL = 1278176699921465394 if config.IS_DEBUG else 1278173035018584086 - KOALA = 1278176687493746709 if config.IS_DEBUG else 1278173021345021952 - HIPPO = 1278176675846295603 if config.IS_DEBUG else 1278173007419932804 - OSTRICH = 1278176661317095494 if config.IS_DEBUG else 1278172994669248523 - RHINO = 1278176650340864000 if config.IS_DEBUG else 1278172980848885832 - SQUIRREL = 1278176637627924570 if config.IS_DEBUG else 1278172965590274088 - CHAMELEON = 1278176625392877648 if config.IS_DEBUG else 1278172953305022496 - FROG = 1278176614512857088 if config.IS_DEBUG else 1278172937043710094 - SKUNK = 1278176604509442079 if config.IS_DEBUG else 1278172923495976981 - WALRUS = 1278176554723311678 if config.IS_DEBUG else 1278172912368619603 - HEDGEHOG = 1278176541750067252 if config.IS_DEBUG else 1278172899911405678 - PIG = 1278176531415564332 if config.IS_DEBUG else 1278172885239861338 - HEN = 1278176518643646485 if config.IS_DEBUG else 1278172871302184994 - TURTLE = 1278176507428343891 if config.IS_DEBUG else 1278172858199314455 - MOUSE = 1278175957047578696 if config.IS_DEBUG else 1278172846442414081 - RABBIT = 1278175944007487489 if config.IS_DEBUG else 1278172831921999945 - GOAT = 1278175933915856938 if config.IS_DEBUG else 1278172815421472808 - LION = 1278175922964664382 if config.IS_DEBUG else 1278172803534684235 - WOLF = 1278175902047535165 if config.IS_DEBUG else 1278172791052570687 - SNAKE = 1278175869331968061 if config.IS_DEBUG else 1278172776259260547 - LLAMA = 1278175850008674325 if config.IS_DEBUG else 1278172764913799168 - CRAB = 1278175835634925620 if config.IS_DEBUG else 1278172752322232330 - OWL = 1278175822947029152 if config.IS_DEBUG else 1278172738309193749 - BAT = 1278175813572886550 if config.IS_DEBUG else 1278172726426865724 - ZEBRA = 1278175799962505216 if config.IS_DEBUG else 1278172715114565753 - FOX = 1278175788818104474 if config.IS_DEBUG else 1278172702955274292 - GORILLA = 1278175775891259456 if config.IS_DEBUG else 1278172687797194824 - SLOTH = 1278175765661356175 if config.IS_DEBUG else 1278172676837343242 - PUFFER_FISH = 1278175753497743360 if config.IS_DEBUG else 1278172665093558294 - BEAR = 1278175741569142867 if config.IS_DEBUG else 1278172653076746301 - GIRAFFE = 1278175732601983006 if config.IS_DEBUG else 1278172640296702073 - PENGUIN = 1278175721956573236 if config.IS_DEBUG else 1278172627629899808 - PARROT = 1278175706798489642 if config.IS_DEBUG else 1278172600702337074 +except ImportError: + print("dotenv is not installed. No .env file was loaded.") +IS_DEBUG = os.getenv("IS_DEBUG", "false").lower() == "true" -class CatMask(enum.IntEnum): - DINOSAUR = 1278255435652010014 if config.IS_DEBUG else 1278254725833297965 - SHLEEPY_CAT = 1278255475066146816 if config.IS_DEBUG else 1278254779767984181 - COZY_CAT = 1278255420124696663 if config.IS_DEBUG else 1278254704324771893 - COQUETTE_CAT = 1278255408254816300 if config.IS_DEBUG else 1278254684200636428 - PANIC_CAT = 1278255451993145345 if config.IS_DEBUG else 1278254760700674098 +TOKEN = os.getenv("TOKEN") +if TOKEN is None: + raise RuntimeError( + "No token provided. Make sure to set the TOKEN environment variable." + ) -MASKS_LIST = list(Mask) -CAT_MASKS_LIST = list(CatMask) +SHARD_ID = int(os.getenv("SHARD_ID", "0")) +SHARD_COUNT = int(os.getenv("SHARD_COUNT", "1")) -TIPS = [ - "You can report calls using `{prefix}report`.", - "Help us sharing Payphone to make it more active!", - "You can check your stats with `{prefix}me`.", - "Emojis will only show if they're from a server Payphone's in.", - "You cannot send links.", - "[Vote for Payphone on top.gg]() to help us reach more users!", - "[Write a review]() to tell everyone your opinion about Payphone!", - "Calls will be automatically hung up after 15 minutes if nobody talks.", - "You can change Payphone's prefix with `{prefix}config prefix`.", - "Do you have any suggestions? Use `{prefix}suggest`!", - "You can use `{prefix}skip` to directly search for a new call.", - "The links from `{prefix}history` will take you to that call.", - "Want to stay hidden? Use `{prefix}mask on`!", - "Did you know you can check your mask with `{prefix}mask`?", - "Do you like Payphone? Consider [making a donation]() to help us grow Payphone (and keep it running)!", - "Using `{prefix}friendreq` is the best way to ask the other party to add you.", - "Enable and disable channels using `{prefix}config enable` and `{prefix}config disable`!", - "There's a secret easter egg command. Try to find it! (Hint: cat)", - "Make sure to read Payphone's [Code of Conduct](). Let's keep Payphone safe and fun for everyone!", - f"Join [Payphone's official server](<{config.SERVER_INVITE}>) to participate in polls for new features!", - "How high are you on the leaderboard? Use `{prefix}me`, `{prefix}leaderboard`, and `{prefix}leaderboard servers` to check it out!", -] - -MSGS_PHONE_CALLING = [ - "Dialing now! Please wait...", - "Trying to connect you...", - "Making the call, hang tight!", - "Searching for someone to answer...", - "Your call is being placed...", - "Connecting your call now...", - "Calling! One moment please...", - "Attempting to reach your contact...", - "Establishing the connection...", - "Your call is on the way!", -] - -MSGS_PHONE_ANSWERED = [ - "Call connected! You're live!", - "The call was answered.", - "Someone picked up the call!", - "Connected! Start talking.", - "You're now connected!", - "The call was successfully answered.", - "Call picked up! You're connected.", - "You're now live on the call!", - "Call answered. Go ahead!", - "Connection successful! You're live.", -] - -MSGS_PHONE_HANGUP_ME = [ - "Call ended. You hung up.", - "You've disconnected the call.", - "You ended the call.", - "Call terminated. You hung up.", - "You have hung up the call.", - "You ended the conversation.", - "You've finished the call.", - "Call ended by you.", - "You disconnected the call.", - "You have ended the call.", -] - -MSGS_PHONE_HANGUP_YOU = [ - "The other party has hung up.", - "Call ended by the other party.", - "The other person disconnected.", - "The call was terminated by the other party.", - "The other person has hung up.", - "Conversation ended by the other party.", - "The call has been finished by the other party.", - "The other party ended the call.", - "Disconnected by the other party.", - "The other person ended the call.", -] - -MSGS_PHONE_NO_ANSWER = [ - "No answer. Maybe try again later?", - "It looks like nobody picked up.", - "The call went unanswered.", - "No response. Try calling again later.", - "No one is available to answer.", - "The call wasn't answered.", - "Nobody answered the call.", - "The call went unanswered. Try again soon!", - "No answer at the moment.", - "The call wasn't picked up.", -] - -MSGS_PHONE_NOT_IN_CALL = [ - "It looks like you're not in a call right now.", - "No active call at the moment.", - "You're not connected to any call.", - "There's no call to hang up right now.", - "You're not currently on a call.", - "No call in progress.", - "You're not in an active call.", - "There isn't a call to end right now.", - "You're not on a call.", - "No ongoing call to disconnect.", -] - -CHANNELS_SEARCH_QUEUE = "channels:searching" -CHANNELS_ONCALL_SET = "channels:on-call" -MESSAGES_QUEUE = "messages" -MESSAGE_CONTENTS_QUEUE = "message-contents" -MESSAGES_PROPAGATION_MAP = "messages-propagation" - -CALL_MESSAGES_TEMPLATE = "m:%s" -"""`%s`: Call ID. - -The key template for call messages hash. -""" - -CALL_MESSAGE_CONTENTS_TEMPLATE = "mc:%s" -"""`%s`: Call ID. - -The key template for call message contents hash. -""" - -CALL_MESSAGE_CONTENTS_HASH_TEMPLATE = "%s-%s" -"""`%s-%s`: Call ID - Iteration. - -The key template for call message contents hash keys. -""" - -CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE = "mfp:%s" -"""`%s`: Call ID. - -A map from propagation IDs to message IDs. -""" - -CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE = "mci:%s" -"""`%s`: Call ID. - -A map of messages to amount of iterations of their message contents. -""" - -CALL_MESSAGE_REPLIES_TEMPLATE = "mr:%s" -"""`%s`: Call ID. - -A map of message IDs to a `,`-delimited list of message IDs that reply to the message. -""" - -CALL_IS_ACTIVE = "cis:%s" -"""`%s`: Call ID. - -An expiring key that indicates whether the call is still active or not. This key will exist while -the call is still active.""" - -CALL_IDS_TO_FLUSH = "call-ids-to-flush" -"""A set of IDs of calls to flush to the DB.""" - -BAN_TEMPLATE = "b:%s" -PREFIX_TEMPLATE = "p:%s" - -EMBED_COLOR = 0xE2D9D0 +REDIS_HOST = os.getenv("REDIS_HOST", "localhost") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) INVISIBLE_CHARACTER = "\u200B" +BRAILE_CHARACTER = "\u2800" + +PRIMARY_COLOR = 0xE2D9D0 +SUPPORTER_COLOR = 0xAB9EFF +MODERATOR_COLOR = 0xFF8787 diff --git a/payphone/core/converters.py b/payphone/core/converters.py deleted file mode 100644 index aff16f0..0000000 --- a/payphone/core/converters.py +++ /dev/null @@ -1,112 +0,0 @@ -from datetime import timedelta - -from discord.ext import commands -from discord.ext.commands import converter - -from tortoise import expressions - -import orjson - -from payphone.db import models -from payphone.core import exceptions, constants - - -class Timedelta(converter.Converter): - """Converts a string like "1d 2h" to a `datetime.timedelta` object.""" - - async def convert(self, _ctx: commands.Context, argument: str) -> timedelta: - multipliers = { - "s": 1, - "m": 60, - "h": 60 * 60, - "d": 60 * 60 * 24, - "w": 60 * 60 * 24 * 7, - "M": 60 * 60 * 24 * 30, - "y": 60 * 60 * 24 * 365, - } - - if argument[-1] in multipliers: - try: - unit = multipliers[argument[-1]] - quantity = float(argument[:-1]) - - return timedelta(seconds=unit * quantity) - - except Exception as e: - raise exceptions.InvalidTimedeltaError( - f'"{argument}" is not a valid time delta.' - ) from e - - raise exceptions.InvalidTimedeltaError( - f'"{argument}" is not a valid time delta.' - ) - - -class DiscordUserID(converter.Converter): - """Converts a URL, ID, or reply into a Discord User ID.""" - - async def convert(self, ctx: commands.Context, argument: str) -> int: - if argument.lower() == "me": - return ctx.author.id - - if argument.isdecimal() and len(argument) in range(17, 20): - return int(argument) - - if len(argument) > 17 and argument[2:-1].isdecimal(): - return int(argument[2:-1]) - - if argument.startswith("https://discord.com/users/") or argument.startswith( - "discord://-/users/" - ): - return int(argument.split("?", 1)[0].rsplit("/", 1)[1]) - - if argument.lower() in ["reply", "."]: - if ctx.message.reference is not None: - ref = ctx.message.reference - - msg = ref.cached_message or await ctx.channel.fetch_message( - ref.message_id - ) - - if msg.author.bot: - row = await models.Message.get_or_none( - expressions.Q(id=msg.id, propagation_id=msg.id, join_type="OR") - ) - - if row is not None: - return row.user_id - - channel = await models.Channel.get_or_none(id=ctx.channel.id) - - if channel is None: - raise converter.BadArgument() - - original_message_id = await ctx.bot.redis.hget( - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE - % channel.call_id, - ref.message_id, - ) - - if original_message_id is None: - raise converter.BadArgument() - - original_message_json = await ctx.bot.redis.hget( - constants.CALL_MESSAGES_TEMPLATE % channel.call_id, - int(original_message_id), - ) - - if original_message_json is None: - raise converter.BadArgument() - - original_message_dict = orjson.loads(original_message_json) - - return original_message_dict["user_id"] - - return msg.author.id - - user = await models.User.get_or_none(username=argument) - - if user is not None: - return user.id - - raise converter.BadArgument() diff --git a/payphone/core/exceptions.py b/payphone/core/exceptions.py deleted file mode 100644 index 3864505..0000000 --- a/payphone/core/exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -from discord.ext.commands import errors - - -class ChannelDisabledError(errors.CheckFailure): - """Raised when the channel being run the command in is disabled.""" - - -class NotAModeratorError(errors.CheckFailure): - """Raised when a non-moderator tries to run a moderator-only command.""" - - -class InvalidTimedeltaError(Exception): - """Raised when the passed timedelta string cannot be parsed by the converter.""" - - -class BannedError(errors.CheckFailure): - """Raised when the user running the command is banned.""" - - -class NotOnCallError(errors.CheckFailure): - """Raised when the command is ran in a channel with no ongoing calls.""" diff --git a/payphone/core/formatting.py b/payphone/core/formatting.py index 4d7a717..e69de29 100644 --- a/payphone/core/formatting.py +++ b/payphone/core/formatting.py @@ -1,271 +0,0 @@ -from datetime import timedelta - -import re - -import discord - -from payphone.db import models -from payphone.core import constants - - -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 clean( - message: discord.Message, call_rss: list[models.CallUserChannelRelationship] -) -> str: - """Cleans the message. - - Args: - message (discord.Message): The message to clean. - call_rss (list[models.CallUserChannelRelationship]): The call's relationships. They must - have pre-selected their `profile` relation. - - Returns: - str: The cleaned message content. - """ - - cleaned_message = message.content - - if message.mentions: - for call_rs in call_rss: - cleaned_message = cleaned_message.replace( - f"<@{call_rs.user_id}>", - ( - f"@**{call_rs.display_name}**" - if message.channel.id == call_rs.channel_id - else f"@**{call_rs.profile.name}**" - ), - ) - - for user in message.mentions: - cleaned_message = cleaned_message.replace(user.mention, "@*Not in call*") - - for channel in message.channel_mentions: - cleaned_message = cleaned_message.replace( - f"<#{channel.id}>", f"#**{channel.name}**" - ) - - for role in message.role_mentions: - cleaned_message = cleaned_message.replace(f"<@&{role.id}>", f"@**{role.name}**") - - cleaned_message = cleaned_message.replace( - "@everyone", f"@{constants.INVISIBLE_CHARACTER}everyone" - ).replace("@here", f"@{constants.INVISIBLE_CHARACTER}here") - - return cleaned_message - - -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 deleted file mode 100644 index 271e47a..0000000 --- a/payphone/core/help_command.py +++ /dev/null @@ -1,150 +0,0 @@ -from typing import List, Dict - -import discord -from discord.ext import commands - -from payphone.cogs import base -from payphone.core import formatting, config, constants, typing_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=config.SERVER_INVITE, - ), - timeout=None, - ) - - -class HelpCommand(commands.HelpCommand): - async def send_bot_help( - self, mapping: Dict[base.Cog | None, List[commands.Command]] - ) -> None: - bot_commands: List[commands.Command] = [] - - for cog, cog_commands in mapping.items(): - if cog is not None and not cog.show_in_help: - continue - - bot_commands += cog_commands - - 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.EMBED_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 typing_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.EMBED_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/logging.py b/payphone/core/logging.py index ae117f7..d08f8c8 100644 --- a/payphone/core/logging.py +++ b/payphone/core/logging.py @@ -34,5 +34,12 @@ def setup(process_name: str): logging.basicConfig(level=logging.DEBUG, handlers=[_get_handler(process_name)]) - logging.getLogger("asyncio").addHandler(_get_handler(process_name, "asyncio")) - logging.getLogger("discord").addHandler(_get_handler(process_name, "discord")) + discord_logger = logging.getLogger("discord") + discord_logger.handlers.clear() + discord_logger.addHandler(_get_handler(process_name, "discord")) + discord_logger.propagate = False + + asyncio_logger = logging.getLogger("asyncio") + asyncio_logger.handlers.clear() + asyncio_logger.addHandler(_get_handler(process_name, "asyncio")) + asyncio_logger.propagate = False diff --git a/payphone/core/messages.py b/payphone/core/messages.py new file mode 100644 index 0000000..0d67a28 --- /dev/null +++ b/payphone/core/messages.py @@ -0,0 +1,88 @@ +TIPS = [ + "You can report calls using `Apps > Report Message`.", + "Help us sharing Payphone to make it more active!", + # "You can check your stats with `{prefix}me`.", + "Emojis will only show if they're from a server Payphone's in.", + "You cannot send links.", + "[Vote for Payphone on top.gg]() to help us reach more users!", + "[Write a review]() to tell everyone your opinion about Payphone!", + "Calls will be automatically hung up after 15 minutes if nobody talks.", + "You can change Payphone's prefix with `{prefix}config prefix`.", + "Do you have any suggestions? Use `{prefix}suggest`!", + "You can use `{prefix}skip` to directly search for a new call.", + # "The links from `{prefix}history` will take you to that call.", + "Want to stay hidden? Use `{prefix}mask on`!", + "Did you know you can check your mask with `{prefix}mask`?", + "Do you like Payphone? Consider [making a donation]() to help us grow Payphone (and keep it running)!", + "Using `{prefix}friendreq` is the best way to ask the other party to add you.", + "Enable and disable channels using `{prefix}config enable` and `{prefix}config disable`!", + "There's a secret easter egg command. Try to find it! (Hint: cat)", + "Make sure to read Payphone's [Code of Conduct](). Let's keep Payphone safe and fun for everyone!", + "Join [Payphone's official server]() to participate in polls for new features!", + # "How high are you on the leaderboard? Use `{prefix}me`, `{prefix}leaderboard`, and `{prefix}leaderboard servers` to check it out!", +] + +CALLING = [ + "Dialing now! Please wait...", + "Trying to connect you...", + "Making the call, hang tight!", + "Searching for someone to answer...", + "Your call is being placed...", + "Connecting your call now...", + "Calling! One moment please...", + "Attempting to reach your contact...", + "Establishing the connection...", + "Your call is on the way!", +] + +ANSWERED = [ + "Call connected! You're live!", + "The call was answered.", + "Someone picked up the call!", + "Connected! Start talking.", + "You're now connected!", + "The call was successfully answered.", + "Call picked up! You're connected.", + "You're now live on the call!", + "Call answered. Go ahead!", + "Connection successful! You're live.", +] + +HANGUP = [ + "Call ended. You hung up.", + "You've disconnected the call.", + "You ended the call.", + "Call terminated. You hung up.", + "You have hung up the call.", + "You ended the conversation.", + "You've finished the call.", + "Call ended by you.", + "You disconnected the call.", + "You have ended the call.", +] + +OTHER_PARTY_HANGUP = [ + "The other party has hung up.", + "Call ended by the other party.", + "The other person disconnected.", + "The call was terminated by the other party.", + "The other person has hung up.", + "Conversation ended by the other party.", + "The call has been finished by the other party.", + "The other party ended the call.", + "Disconnected by the other party.", + "The other person ended the call.", +] + +NO_ANSWER = [ + "No answer. Maybe try again later?", + "It looks like nobody picked up.", + "The call went unanswered.", + "No response. Try calling again later.", + "No one is available to answer.", + "The call wasn't answered.", + "Nobody answered the call.", + "The call went unanswered. Try again soon!", + "No answer at the moment.", + "The call wasn't picked up.", +] diff --git a/payphone/core/typing_utils.py b/payphone/core/typing_utils.py deleted file mode 100644 index 693893e..0000000 --- a/payphone/core/typing_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import typing - -from discord.ext import commands - - -def is_optional(type_hint) -> bool: - """Checks if the type_hint is optional or not. - - Args: - type_hint (typing.Type): The type hint. - - Returns: - bool: Whether the type hint is optional or not. - """ - - origin = typing.get_origin(type_hint) - - if origin is typing.Union: - args = typing.get_args(type_hint) - return type(None) in args - - if isinstance(type_hint, commands.Greedy): - return True - - return False diff --git a/payphone/core/utils.py b/payphone/core/utils.py deleted file mode 100644 index dd2bffd..0000000 --- a/payphone/core/utils.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Callable, Iterable, TypeVar, Generator - - -T = TypeVar("T") -U = TypeVar("U") - - -def deduplicate(i: Iterable[T], key: Callable[[T], U] = lambda i: i) -> list[T]: - """Deduplicates an Iterable. - - Args: - i (Iterable[T]): The iterable with items to deduplicate. - key (Callable[[T], U], optional): The key by which to deduplicate. Defaults to - `lambda i: i`. - - Returns: - list[T]: A list with the deduplicated items. - """ - - found_keys = set() - - result = [] - - for item in i: - k = key(item) - if k not in found_keys: - result.append(item) - found_keys.add(k) - - return result - - -def chunks(i: Iterable[T], size: int) -> Generator[list[T], None, None]: - """Chunk an iterable. - - Args: - i (Iterable[T]): The iterable. - size (int): The size of each chunk. - - Returns: - Generator[list[T], None, None]: The iterable's chunks. - """ - - if size <= 0: - raise ValueError("Chunks size can't be lower than 1.") - - chunk = [] - - for item in i: - chunk.append(item) - - if len(chunk) == size: - yield chunk - chunk = [] - - if len(chunk) > 0: - yield chunk diff --git a/payphone/db/migrations/models/0_20241016124605_init.py b/payphone/db/migrations/models/0_20241016124605_init.py deleted file mode 100644 index 0abe092..0000000 --- a/payphone/db/migrations/models/0_20241016124605_init.py +++ /dev/null @@ -1,104 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE TABLE IF NOT EXISTS "call" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "ended_at" TIMESTAMPTZ -); -CREATE TABLE IF NOT EXISTS "server" ( - "id" BIGSERIAL NOT NULL PRIMARY KEY, - "prefix" VARCHAR(3) NOT NULL DEFAULT 'p.' -); -CREATE TABLE IF NOT EXISTS "channel" ( - "id" BIGSERIAL NOT NULL PRIMARY KEY, - "enabled" BOOL, - "webhook_id" BIGINT, - "webhook_url" TEXT, - "call_id" INT REFERENCES "call" ("id") ON DELETE CASCADE, - "server_id" BIGINT NOT NULL REFERENCES "server" ("id") ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS "user" ( - "id" BIGSERIAL NOT NULL PRIMARY KEY, - "username" VARCHAR(32) NOT NULL, - "is_masked" BOOL NOT NULL DEFAULT False, - "is_moderator" BOOL NOT NULL DEFAULT False -); -CREATE TABLE IF NOT EXISTS "profile" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "avatar_url" TEXT NOT NULL, - "name" VARCHAR(32) NOT NULL, - "color" INT, - "server_id" BIGINT REFERENCES "server" ("id") ON DELETE CASCADE, - "user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_profile_user_id_903480" UNIQUE ("user_id", "server_id") -); -CREATE TABLE IF NOT EXISTS "calluserchannelrelationship" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "is_masked" BOOL NOT NULL, - "mask" BIGINT NOT NULL, - "call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE, - "channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - "profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE, - "user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_callusercha_call_id_fad9b8" UNIQUE ("call_id", "user_id", "channel_id") -); -CREATE TABLE IF NOT EXISTS "message" ( - "id" BIGSERIAL NOT NULL PRIMARY KEY, - "mask" BIGINT, - "was_deleted" BOOL NOT NULL DEFAULT False, - "propagation_id" BIGINT, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE, - "channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - "profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE, - "propagation_channel_id" BIGINT REFERENCES "channel" ("id") ON DELETE CASCADE, - "replying_to_id" BIGINT REFERENCES "message" ("id") ON DELETE SET NULL, - "user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_message_propaga_f0803c" ON "message" ("propagation_id"); -CREATE TABLE IF NOT EXISTS "messagecontent" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "content" VARCHAR(4096) NOT NULL, - "was_propagated" BOOL NOT NULL DEFAULT True, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "message_id" BIGINT NOT NULL REFERENCES "message" ("id") ON DELETE CASCADE -); -CREATE TABLE IF NOT EXISTS "report" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "reason" VARCHAR(500) NOT NULL, - "priority" INT NOT NULL, - "status" SMALLINT NOT NULL DEFAULT 2, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "reported_by_id" BIGINT REFERENCES "user" ("id") ON DELETE CASCADE, - "call_id" INT NOT NULL UNIQUE REFERENCES "call" ("id") ON DELETE CASCADE -); -COMMENT ON COLUMN "report"."status" IS 'ACCEPTED: 0\nDECLINED: 1\nON_REVIEW: 2'; -CREATE TABLE IF NOT EXISTS "ban" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "notification_message_id" BIGINT, - "reason" VARCHAR(500) NOT NULL, - "appeal" VARCHAR(500), - "status" SMALLINT NOT NULL DEFAULT 0, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expires_at" TIMESTAMPTZ, - "banned_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - "report_id" INT REFERENCES "report" ("id") ON DELETE SET NULL, - "user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE NO ACTION -); -CREATE INDEX IF NOT EXISTS "idx_ban_notific_ea7b58" ON "ban" ("notification_message_id"); -CREATE INDEX IF NOT EXISTS "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at"); -COMMENT ON COLUMN "ban"."status" IS 'VALID: 0\nON_REVIEW: 1\nAPPEAL_ACCEPTED: 2\nAPPEAL_DECLINED: 3'; -CREATE TABLE IF NOT EXISTS "aerich" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "version" VARCHAR(255) NOT NULL, - "app" VARCHAR(100) NOT NULL, - "content" JSONB NOT NULL -);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - """ diff --git a/payphone/db/migrations/models/0_20241112230205_init.py b/payphone/db/migrations/models/0_20241112230205_init.py new file mode 100644 index 0000000..8d4517f --- /dev/null +++ b/payphone/db/migrations/models/0_20241112230205_init.py @@ -0,0 +1,55 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "channel" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "server_id" BIGINT NOT NULL, + "is_enabled" BOOL NOT NULL DEFAULT True +); +CREATE INDEX IF NOT EXISTS "idx_channel_server__d5ded1" ON "channel" ("server_id"); +CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled"); +COMMENT ON TABLE "channel" IS 'A channel.'; +CREATE TABLE IF NOT EXISTS "user" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "is_masked" BOOL NOT NULL DEFAULT False, + "is_moderator" BOOL NOT NULL DEFAULT False, + "is_supporter_until" TIMESTAMPTZ +); +COMMENT ON TABLE "user" IS 'A user.'; +CREATE TABLE IF NOT EXISTS "report" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "message_id" BIGINT NOT NULL UNIQUE, + "message_content" TEXT NOT NULL, + "channel_id" BIGINT NOT NULL, + "user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "resolved" BOOL NOT NULL DEFAULT False, + "resolved_at" TIMESTAMPTZ, + "resolved_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_report_resolve_32f036" ON "report" ("resolved"); +COMMENT ON TABLE "report" IS 'A report of a message.'; +CREATE TABLE IF NOT EXISTS "ban" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ, + "created_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "expired_by_id" BIGINT REFERENCES "user" ("id") ON DELETE CASCADE, + "report_id" INT REFERENCES "report" ("id") ON DELETE CASCADE +); +CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at"); +COMMENT ON TABLE "ban" IS 'A ban.'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ diff --git a/payphone/db/migrations/models/10_20241018234951_update.py b/payphone/db/migrations/models/10_20241018234951_update.py deleted file mode 100644 index 3ad169b..0000000 --- a/payphone/db/migrations/models/10_20241018234951_update.py +++ /dev/null @@ -1,15 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "leaderboard_calls_count" INT NOT NULL DEFAULT 0; - ALTER TABLE "server" ADD "leaderboard_position" INT NOT NULL DEFAULT 0; - CREATE INDEX "idx_server_leaderb_47c354" ON "server" ("leaderboard_position");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_server_leaderb_47c354"; - ALTER TABLE "server" DROP COLUMN "leaderboard_calls_count"; - ALTER TABLE "server" DROP COLUMN "leaderboard_position";""" diff --git a/payphone/db/migrations/models/11_20241019004248_update.py b/payphone/db/migrations/models/11_20241019004248_update.py deleted file mode 100644 index 03e9a27..0000000 --- a/payphone/db/migrations/models/11_20241019004248_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "vanity_code" VARCHAR(32);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" DROP COLUMN "vanity_code";""" diff --git a/payphone/db/migrations/models/12_20241019213937_update.py b/payphone/db/migrations/models/12_20241019213937_update.py deleted file mode 100644 index c42ce82..0000000 --- a/payphone/db/migrations/models/12_20241019213937_update.py +++ /dev/null @@ -1,13 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" DROP COLUMN "appeal"; - ALTER TABLE "ban" DROP COLUMN "status";""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ADD "appeal" VARCHAR(500); - ALTER TABLE "ban" ADD "status" SMALLINT NOT NULL DEFAULT 0;""" diff --git a/payphone/db/migrations/models/13_20241021153438_update.py b/payphone/db/migrations/models/13_20241021153438_update.py deleted file mode 100644 index 761daec..0000000 --- a/payphone/db/migrations/models/13_20241021153438_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "found_easter_eggs" SMALLINT NOT NULL DEFAULT 0;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "found_easter_eggs";""" diff --git a/payphone/db/migrations/models/14_20241023143325_update.py b/payphone/db/migrations/models/14_20241023143325_update.py deleted file mode 100644 index 643e7d5..0000000 --- a/payphone/db/migrations/models/14_20241023143325_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" ALTER COLUMN "priority" TYPE DOUBLE PRECISION USING "priority"::DOUBLE PRECISION;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" ALTER COLUMN "priority" TYPE INT USING "priority"::INT;""" diff --git a/payphone/db/migrations/models/15_20241025225628_update.py b/payphone/db/migrations/models/15_20241025225628_update.py deleted file mode 100644 index ce37ea4..0000000 --- a/payphone/db/migrations/models/15_20241025225628_update.py +++ /dev/null @@ -1,19 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "ban" ADD "unbanned_by_id" BIGINT; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - ALTER TABLE "ban" ADD CONSTRAINT "fk_ban_user_e41857cf" FOREIGN KEY ("unbanned_by_id") REFERENCES "user" ("id") ON DELETE CASCADE; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - ALTER TABLE "ban" DROP CONSTRAINT "fk_ban_user_e41857cf"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "ban" DROP COLUMN "unbanned_by_id"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");""" diff --git a/payphone/db/migrations/models/16_20241027161324_update.py b/payphone/db/migrations/models/16_20241027161324_update.py deleted file mode 100644 index 124e8a3..0000000 --- a/payphone/db/migrations/models/16_20241027161324_update.py +++ /dev/null @@ -1,21 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "report" ADD "reviewed_by_id" BIGINT; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");; - ALTER TABLE "report" ADD CONSTRAINT "fk_report_user_67e68360" FOREIGN KEY ("reviewed_by_id") REFERENCES "user" ("id") ON DELETE SET NULL;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "report" DROP CONSTRAINT "fk_report_user_67e68360"; - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "report" DROP COLUMN "reviewed_by_id"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" diff --git a/payphone/db/migrations/models/17_20241102231436_update.py b/payphone/db/migrations/models/17_20241102231436_update.py deleted file mode 100644 index 42550e7..0000000 --- a/payphone/db/migrations/models/17_20241102231436_update.py +++ /dev/null @@ -1,21 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "user" ADD "is_deleting" BOOL NOT NULL DEFAULT False; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");; - CREATE INDEX "idx_user_is_dele_2b4cfd" ON "user" ("is_deleting");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_report_status_d6b157"; - DROP INDEX "idx_user_is_dele_2b4cfd"; - DROP INDEX "idx_ban_user_id_37a9cf"; - ALTER TABLE "user" DROP COLUMN "is_deleting"; - CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; - CREATE INDEX "idx_report_status_d6b157" ON "report" ("status", "priority");;""" diff --git a/payphone/db/migrations/models/1_20241017013650_update.py b/payphone/db/migrations/models/1_20241017013650_update.py deleted file mode 100644 index 33e9edc..0000000 --- a/payphone/db/migrations/models/1_20241017013650_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ALTER COLUMN "banned_by_id" DROP NOT NULL;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ALTER COLUMN "banned_by_id" SET NOT NULL;""" diff --git a/payphone/db/migrations/models/1_20241112230215_update.py b/payphone/db/migrations/models/1_20241112230215_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/1_20241112230215_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/2_20241017014235_update.py b/payphone/db/migrations/models/2_20241017014235_update.py deleted file mode 100644 index 81f0dd0..0000000 --- a/payphone/db/migrations/models/2_20241017014235_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" DROP CONSTRAINT "ban_user_id_fkey";""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "ban" ADD CONSTRAINT "ban_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE NO ACTION;""" diff --git a/payphone/db/migrations/models/2_20241112230633_update.py b/payphone/db/migrations/models/2_20241112230633_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/2_20241112230633_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/3_20241017020958_update.py b/payphone/db/migrations/models/3_20241017020958_update.py deleted file mode 100644 index 7737e8f..0000000 --- a/payphone/db/migrations/models/3_20241017020958_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ALTER COLUMN "prefix" TYPE VARCHAR(5) USING "prefix"::VARCHAR(5);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ALTER COLUMN "prefix" TYPE VARCHAR(3) USING "prefix"::VARCHAR(3);""" diff --git a/payphone/db/migrations/models/3_20241112232746_update.py b/payphone/db/migrations/models/3_20241112232746_update.py new file mode 100644 index 0000000..6878fde --- /dev/null +++ b/payphone/db/migrations/models/3_20241112232746_update.py @@ -0,0 +1,17 @@ +from tortoise import BaseDBAsyncClient + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/4_20241018014134_update.py b/payphone/db/migrations/models/4_20241018014134_update.py deleted file mode 100644 index 38e43b9..0000000 --- a/payphone/db/migrations/models/4_20241018014134_update.py +++ /dev/null @@ -1,17 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "calluserchannelrelationship" ADD "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP; - ALTER TABLE "user" ADD "leaderboard_position" INT; - CREATE INDEX "idx_callusercha_created_e72939" ON "calluserchannelrelationship" ("created_at"); - CREATE INDEX "idx_user_leaderb_1dcee5" ON "user" ("leaderboard_position");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_callusercha_created_e72939"; - DROP INDEX "idx_user_leaderb_1dcee5"; - ALTER TABLE "user" DROP COLUMN "leaderboard_position"; - ALTER TABLE "calluserchannelrelationship" DROP COLUMN "created_at";""" diff --git a/payphone/db/migrations/models/4_20241112232813_update.py b/payphone/db/migrations/models/4_20241112232813_update.py new file mode 100644 index 0000000..6616544 --- /dev/null +++ b/payphone/db/migrations/models/4_20241112232813_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 "report" DROP COLUMN "resolved_by_id"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" ADD "resolved_by_id" BIGINT; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/5_20241018014615_update.py b/payphone/db/migrations/models/5_20241018014615_update.py deleted file mode 100644 index 7c58be8..0000000 --- a/payphone/db/migrations/models/5_20241018014615_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "joined_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "joined_at";""" diff --git a/payphone/db/migrations/models/5_20241112232936_update.py b/payphone/db/migrations/models/5_20241112232936_update.py new file mode 100644 index 0000000..e1ee5c8 --- /dev/null +++ b/payphone/db/migrations/models/5_20241112232936_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 "resolved_by_id" BIGINT; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");; + ALTER TABLE "report" ADD CONSTRAINT "fk_report_user_e65f1760" FOREIGN KEY ("resolved_by_id") REFERENCES "user" ("id") ON DELETE CASCADE;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP INDEX "idx_channel_server__d940e4"; + ALTER TABLE "report" DROP CONSTRAINT "fk_report_user_e65f1760"; + DROP INDEX "idx_ban_user_id_37a9cf"; + ALTER TABLE "report" DROP COLUMN "resolved_by_id"; + CREATE INDEX "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");; + CREATE INDEX "idx_channel_server__d940e4" ON "channel" ("server_id", "is_enabled");;""" diff --git a/payphone/db/migrations/models/6_20241018031928_update.py b/payphone/db/migrations/models/6_20241018031928_update.py deleted file mode 100644 index b66c9cd..0000000 --- a/payphone/db/migrations/models/6_20241018031928_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" ADD "leaderboard_calls_count" INT;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "user" DROP COLUMN "leaderboard_calls_count";""" diff --git a/payphone/db/migrations/models/7_20241018043647_update.py b/payphone/db/migrations/models/7_20241018043647_update.py deleted file mode 100644 index b313887..0000000 --- a/payphone/db/migrations/models/7_20241018043647_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE INDEX "idx_ban_created_490013" ON "ban" ("created_at");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_ban_created_490013";""" diff --git a/payphone/db/migrations/models/8_20241018132301_update.py b/payphone/db/migrations/models/8_20241018132301_update.py deleted file mode 100644 index 8509a17..0000000 --- a/payphone/db/migrations/models/8_20241018132301_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE INDEX "idx_user_usernam_9987ab" ON "user" ("username");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX "idx_user_usernam_9987ab";""" diff --git a/payphone/db/migrations/models/9_20241018234807_update.py b/payphone/db/migrations/models/9_20241018234807_update.py deleted file mode 100644 index cd4e4f6..0000000 --- a/payphone/db/migrations/models/9_20241018234807_update.py +++ /dev/null @@ -1,11 +0,0 @@ -from tortoise import BaseDBAsyncClient - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" ADD "name" VARCHAR(100);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "server" DROP COLUMN "name";""" diff --git a/payphone/db/models.py b/payphone/db/models.py index e87acda..54d8c8f 100644 --- a/payphone/db/models.py +++ b/payphone/db/models.py @@ -1,406 +1,103 @@ -from datetime import datetime +from datetime import datetime, timezone import enum -from tortoise import models, fields, indexes - -import discord -from discord import webhook - -import aiohttp - -from payphone.core import config, constants -from payphone.cogs.easter import EasterEggCommand - - -class User(models.Model): - id: int = fields.BigIntField(primary_key=True) - username: str = fields.CharField(max_length=32, db_index=True) - is_masked: bool = fields.BooleanField(default=False) - is_moderator: bool = fields.BooleanField(default=False) - is_deleting: bool = fields.BooleanField(default=False, db_index=True) - found_easter_eggs: int = fields.IntEnumField(EasterEggCommand, default=0) - profiles: fields.BackwardFKRelation["Profile"] - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - leaderboard_position: int | None = fields.IntField( - default=None, null=True, db_index=True - ) - leaderboard_calls_count: int | None = fields.IntField(default=None, null=True) - joined_at: datetime = fields.DatetimeField(auto_now_add=True) - - -class Call(models.Model): - started_at = fields.DatetimeField(auto_now_add=True, db_index=True) - ended_at = fields.DatetimeField(null=True, default=None) - - channels: fields.BackwardFKRelation["Channel"] - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - report: fields.BackwardOneToOneRelation["Report"] - messages: fields.BackwardFKRelation["Message"] - - -class Profile(models.Model): - user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE - ) - user_id: int - server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField( - "models.Server", - related_name="profiles", - null=True, - on_delete=fields.OnDelete.CASCADE, - ) - avatar_url: str = fields.TextField() - name: str = fields.CharField(max_length=32) - color: int | None = fields.IntField(null=True, default=None) - - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - - @property - def jump_url(self) -> str: - """The URL that'll open the user in Discord. - - Returns: - str: The URL. - """ - - return f"discord://-/users/{self.user_id}" - - @property - def link(self) -> str: - """A markdown link that opens the user in Discord. - - Returns: - str: The markdown link. - """ - - return f"[{self.name}]({self.jump_url})" - - @property - def embed_author(self) -> discord.EmbedAuthor: - """Returns this profile as an EmbedAuthor object. - - Returns: - discord.EmbedAuthor: The EmbedAuthor object. - """ - - return discord.EmbedAuthor(name=self.name, icon_url=self.avatar_url) - - class Meta: - unique_together = [("user_id", "server_id")] - - -class Server(models.Model): - id: int = fields.BigIntField(primary_key=True) - name: str = fields.CharField(max_length=100, null=True, default=None) - prefix: str = fields.CharField(max_length=5, default="p.") - leaderboard_position: int = fields.IntField(default=0, db_index=True) - leaderboard_calls_count: int = fields.IntField(default=0) - vanity_code: str = fields.CharField(max_length=32, null=True, default=None) - channels: fields.BackwardFKRelation["Channel"] - profiles: fields.BackwardFKRelation["Profile"] - - -class Channel(models.Model): - id = fields.BigIntField(primary_key=True) - server: fields.ForeignKeyRelation[Server] = fields.ForeignKeyField( - "models.Server", related_name="channels", on_delete=fields.OnDelete.CASCADE - ) - call: fields.ForeignKeyNullableRelation["Call"] = fields.ForeignKeyField( - "models.Call", null=True, related_name="channels" - ) - enabled: bool | None = fields.BooleanField(null=True, default=None) - webhook_id: int | None = fields.BigIntField(null=True) - webhook_url: str | None = fields.TextField(null=True) - - call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"] - messages: fields.BackwardFKRelation["Message"] - propagated_messages: fields.BackwardFKRelation["Message"] - - def webhook(self, session: aiohttp.ClientSession) -> webhook.Webhook | None: - """Initializes the channel's webhook instance. - - Args: - session (aiohttp.ClientSession): An initialized aiohttp client session. - - Returns: - webhook.Webhook | None: The webhook, or None if not initialized. - """ - - if self.webhook_url is None: - return None - - return webhook.Webhook.from_url( - self.webhook_url, session=session, bot_token=config.TOKEN - ) - - class Meta: - index = [("server_id", "enabled")] - - -class CallUserChannelRelationship(models.Model): - call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField( - "models.Call", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField( - "models.Profile", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField( - "models.Channel", related_name="call_rss", on_delete=fields.OnDelete.CASCADE - ) - is_masked: bool = fields.BooleanField() - mask: int = fields.BigIntField() - created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True) - - @property - def mask_name(self) -> str: - """Returns the mask's name. - - Returns: - str: The mask's name. - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return mask.name.replace("_", " ").lower().title() - - @property - def display_name(self) -> str: - """Returns the displayed name. - - Returns: - str: The displayed name. - """ - - if self.is_masked: - return self.mask_name - - return self.profile.name - - @property - def display_avatar(self) -> str: - """Returns the displayed avatar URL. - - Returns: - str: The displayed avatar URL. - """ - - if self.is_masked: - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png" - - return self.profile.avatar_url - - @property - def mask_emoji(self) -> str: - """Returns the mask's emoji. - - Returns: - str: The mask's emoji (<:name:id>). - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - mask_name = mask.name.lower().replace("_", "") - - return f"<:{mask_name}:{self.mask}>" - - class Meta: - unique_together = [("call_id", "user_id", "channel_id")] - - -class Message(models.Model): - id = fields.BigIntField(primary_key=True) - call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField( - "models.Call", related_name="messages", on_delete=fields.OnDelete.CASCADE - ) - call_id: int - channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField( - "models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE - ) - channel_id: int - user = fields.ForeignKeyRelation("models.User", on_delete=fields.OnDelete.CASCADE) - user_id: int - profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField( - "models.Profile", on_delete=fields.OnDelete.CASCADE - ) - profile_id: int - replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField( - "models.Message", - related_name="replies", - on_delete=fields.OnDelete.SET_NULL, - null=True, - default=None, - ) - mask = fields.BigIntField(null=True) - was_deleted = fields.BooleanField(default=False) - propagation_id = fields.BigIntField(null=True, db_index=True) - propagation_channel: fields.ForeignKeyNullableRelation["Channel"] = ( - fields.ForeignKeyField( - "models.Channel", related_name="propagated_messages", null=True - ) - ) - propagation_channel_id: int - created_at = fields.DatetimeField(auto_now_add=True) - contents: fields.BackwardFKRelation["MessageContent"] - - @property - def mask_name(self) -> str: - """Returns the mask's name. - - Returns: - str: The mask's name. - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return mask.name.replace("_", " ").lower().title() - - @property - def display_name(self) -> str: - """Returns the displayed name. - - Returns: - str: The displayed name. - """ - - if self.mask is not None: - return self.mask_name - - return self.profile.name - - @property - def display_avatar(self) -> str: - """Returns the displayed avatar URL. - - Returns: - str: The displayed avatar URL. - """ - - if self.mask is not None: - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png" - - return self.profile.avatar_url - - @property - def mask_emoji(self) -> str: - """Returns the mask's emoji. - - Returns: - str: The mask's emoji (<:name:id>). - """ - - try: - mask = constants.Mask(self.mask) - - except ValueError: - mask = constants.CatMask(self.mask) - - mask_name = mask.name.lower().replace("_", "") - - return f"<:{mask_name}:{self.mask}>" - - def jump_url(self, is_this_channel: bool = True) -> str | None: - """Returns the jump URL for this message. - - Args: - is_this_channel (bool, optional): Whether the message sent in this channel or not. - Defaults to True. - - Returns: - str | None: The URL, or None if the message was not propagated. - """ - - if not is_this_channel: - return f"https://discord.com/channels/{self.channel.server_id}/{self.channel.id}/{self.id}" - - if self.propagation_id is not None: - return f"https://discord.com/channels/{self.propagation_channel.server_id}/{self.propagation_channel.id}/{self.propagation_id}" - - return None - - -class MessageContent(models.Model): - message = fields.ForeignKeyField( - "models.Message", related_name="contents", on_delete=fields.OnDelete.CASCADE - ) - message_id: int - content = fields.CharField(max_length=4096) - was_propagated = fields.BooleanField(default=True) - created_at = fields.DatetimeField(auto_now_add=True) - - -class ReportStatus(enum.IntEnum): - ACCEPTED = 0 - DECLINED = 1 - ON_REVIEW = 2 +from tortoise import models, fields class Report(models.Model): - call: fields.OneToOneRelation["Call"] = fields.OneToOneField( - "models.Call", related_name="report" - ) - reported_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", null=True, on_delete=fields.OnDelete.CASCADE - ) - reviewed_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", null=True, on_delete=fields.OnDelete.SET_NULL, related_name="reports_reviewed" - ) - reason: str = fields.CharField(max_length=500) - priority: float = fields.FloatField() - status: ReportStatus = fields.IntEnumField( - ReportStatus, default=ReportStatus.ON_REVIEW, db_index=True - ) - created_at: datetime = fields.DatetimeField(auto_now_add=True) + """ + A report of a message. + """ - class Meta: - indexes = [indexes.Index(fields=("status", "priority"))] + message_id = fields.BigIntField(unique=True) + message_content = fields.TextField() + channel_id = fields.BigIntField() + user_id = fields.BigIntField() + created_at = fields.DatetimeField(auto_now_add=True) + resolved = fields.BooleanField(default=False, index=True) + resolved_at = fields.DatetimeField(null=True) + resolved_by = fields.ForeignKeyField("models.User", null=True) + + +class UserFlag(enum.IntFlag): + """ + Flags for a user. + """ + + NONE = 0 + MODERATOR = 1 + SUPPORTER = 2 + + +class User(models.Model): + """ + A user. + """ + + id = fields.BigIntField(pk=True) + + is_masked = fields.BooleanField(default=False) + is_moderator = fields.BooleanField(default=False) + is_supporter_until = fields.DatetimeField(null=True) + + @property + def flags(self) -> UserFlag: + """ + The flags of the user. + """ + + flags = UserFlag.NONE + + if self.is_moderator: + flags |= UserFlag.MODERATOR + + if ( + self.is_supporter_until is not None + and self.is_supporter_until > datetime.now(timezone.utc) + ): + flags |= UserFlag.SUPPORTER + + return flags class Ban(models.Model): - user_id: int = fields.BigIntField() - report: fields.ForeignKeyNullableRelation["Report"] = fields.ForeignKeyField( - "models.Report", - related_name="bans", - null=True, - on_delete=fields.OnDelete.SET_NULL, - ) - notification_message_id: int = fields.BigIntField(null=True, db_index=True) - reason: str = fields.CharField(max_length=500) - banned_by: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="bans_created", null=True - ) - unbanned_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField( - "models.User", related_name="bans_removed", null=True, default=None - ) - created_at: datetime = fields.DatetimeField(auto_now_add=True, db_index=True) - expires_at: datetime | None = fields.DatetimeField(null=True) + """ + A ban. + """ + + user_id = fields.BigIntField() + report = fields.ForeignKeyField("models.Report", null=True) + created_at = fields.DatetimeField(auto_now_add=True) + expires_at = fields.DatetimeField(null=True) + created_by = fields.ForeignKeyField("models.User", related_name="created_bans") + expired_by = fields.ForeignKeyField("models.User", related_name="bans", null=True) class Meta: - indexes = [indexes.Index(fields=("user_id", "expires_at"))] + """ + Meta information. + """ + + indexes = [ + models.Index(fields=["user_id", "expires_at"]), + ] + + +class Channel(models.Model): + """ + A channel. + """ + + id = fields.BigIntField(pk=True) + server_id = fields.BigIntField(index=True) + + is_enabled = fields.BooleanField(default=True) + + class Meta: + """ + Meta information. + """ + + indexes = [ + models.Index(fields=["server_id", "is_enabled"]), + ] diff --git a/payphone/db/setup.py b/payphone/db/setup.py index 40bad08..143f0d3 100644 --- a/payphone/db/setup.py +++ b/payphone/db/setup.py @@ -1,9 +1,33 @@ import tortoise -from payphone.core import config +from payphone.core import constants + + +CONFIG = { + "connections": { + "default": { + "engine": "tortoise.backends.asyncpg", + "credentials": { + "host": "localhost" if constants.IS_DEBUG else "postgresql", + "port": "5432", + "user": "payphone", + "password": "payphone", + "database": "payphone", + }, + "minsize": 5, + "maxsize": 100, + }, + }, + "apps": { + "models": { + "models": ["payphone.db.models", "aerich.models"], + "default_connection": "default", + } + }, +} async def init() -> None: """Initialize the database and it's connections.""" - await tortoise.Tortoise.init(config.TORTOISE_ORM, use_tz=True) + await tortoise.Tortoise.init(CONFIG, use_tz=True) diff --git a/payphone/db/utils.py b/payphone/db/utils.py deleted file mode 100644 index 36ff59a..0000000 --- a/payphone/db/utils.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Tuple - -import discord - -from payphone.db import models - - -async def get_channel(channel: discord.TextChannel) -> models.Channel: - """Returns a channel from the database, creating or updating it if it already exists. - - Args: - channel (discord.TextChannel): The channel to fetch. - - Returns: - models.Channel: The channel row. - """ - - row, _ = await models.Channel.update_or_create( - id=channel.id, server=channel.guild.id - ) - - channel_name = channel.name.replace("discord", "d").replace("wumpus", "w") - webhook_name = f"Payphone #{channel_name}" - - if row.webhook_id is None: - webhook = await channel.create_webhook( - name=webhook_name, reason="First call started in channel." - ) - - row.webhook_id = webhook.id - row.webhook_url = webhook.url - - await row.save(update_fields=["webhook_id", "webhook_url"]) - - else: - found = False - - for webhook in await channel.webhooks(): - if webhook.id != row.webhook_id: - if webhook.token is None: - await webhook.delete(reason="Webhook is unaccessible by Payphone.") - break - - else: - found = True - - if not found: - webhook = await channel.create_webhook( - name=webhook_name, reason="Previous webhook was deleted." - ) - - row.webhook_id = webhook.id - row.webhook_url = webhook.url - - await row.save(update_fields=["webhook_id", "webhook_url"]) - - return row - - -async def get_user( - user: discord.User | discord.Member, -) -> Tuple[models.User, models.Profile]: - """Returns a user from the database, creating or updating it if it already exists. - - Args: - user (discord.User | discord.Member): The user or member to fetch. - - Returns: - Tuple[models.User, models.Profile]: The user and profile. - """ - - user_row, _ = await models.User.update_or_create( - {"username": user.name}, id=user.id - ) - - profile_row, _ = await models.Profile.update_or_create( - { - "avatar_url": user.display_avatar.url, - "name": user.display_name or user.name, - }, - user=user_row, - server_id=user.guild.id if isinstance(user, discord.Member) else None, - ) - - return (user_row, profile_row) diff --git a/payphone/tasks/__init__.py b/payphone/tasks/__init__.py new file mode 100644 index 0000000..a5fec44 --- /dev/null +++ b/payphone/tasks/__init__.py @@ -0,0 +1 @@ +from payphone.tasks import connect, expire \ No newline at end of file diff --git a/payphone/tasks/connect.py b/payphone/tasks/connect.py new file mode 100644 index 0000000..ad095d7 --- /dev/null +++ b/payphone/tasks/connect.py @@ -0,0 +1,32 @@ +import asyncio + +import random + +from payphone.bot import Payphone +from payphone.core import constants, messages, logging + + +async def main(): + logging.setup("Connect") + + bot = Payphone() + + await bot.login(constants.TOKEN) + + while True: + pairs = await bot.call_handler.connect() + + message = random.choice(messages.ANSWERED) + + await asyncio.gather( + *[ + bot.get_partial_messageable(channel).send(f"☎️ {message}") + for pair in pairs + for channel in pair + ] + ) + + if pairs: + logging.logger.info("Connected %s calls.", len(pairs)) + + await asyncio.sleep(1) diff --git a/payphone/tasks/expire.py b/payphone/tasks/expire.py new file mode 100644 index 0000000..2cb97b5 --- /dev/null +++ b/payphone/tasks/expire.py @@ -0,0 +1,30 @@ +import asyncio + +from payphone.bot import Payphone +from payphone.core import constants, logging + + +async def main(): + logging.setup("Expire") + + bot = Payphone() + + await bot.login(constants.TOKEN) + + while True: + pairs = await bot.call_handler.expire() + + await asyncio.gather( + *[ + bot.get_partial_messageable(channel).send( + "☎️ The call was hung up because nobody responded." + ) + for pair in pairs + for channel in pair + ] + ) + + if pairs: + logging.logger.info("Expired %s calls.", len(pairs)) + + await asyncio.sleep(1) diff --git a/procs/__init__.py b/procs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/procs/flush_messages.py b/procs/flush_messages.py deleted file mode 100644 index 896f59e..0000000 --- a/procs/flush_messages.py +++ /dev/null @@ -1,134 +0,0 @@ -from payphone.core import logging - - -log = logging.logger - - -async def flush(): - import orjson - - import asyncpg.exceptions - - import redis - - from payphone.db import models - - from payphone.core import constants, utils, config - - redis = redis.Redis("redis://localhost" if config.IS_DEBUG else "redis://redis") - - pipe = redis.pipeline() - - pipe.smembers(constants.CALL_IDS_TO_FLUSH) - pipe.delete(constants.CALL_IDS_TO_FLUSH) - - call_ids, _ = await pipe.execute() - - # Redis returns bytes. - call_ids = [int(call_id) for call_id in call_ids] - - if len(call_ids) == 0: - log.debug("Skipping flushing. No calls to flush.") - return - - log.info("Flushing messages for %s calls.", len(call_ids)) - - pipe = redis.pipeline() - - for call_id in call_ids: - pipe.hgetall(constants.CALL_MESSAGES_TEMPLATE % call_id) - pipe.hgetall(constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id) - - for call_id, (messages, message_contents) in zip( - call_ids, utils.chunks(await pipe.execute(), 2) - ): - message_rows: list[models.Message] = [] - - for message_json in messages.values(): - message_rows.append(models.Message(**orjson.loads(message_json))) - - message_content_rows: list[models.MessageContent] = [] - - for message_content_json in message_contents.values(): - message_content_rows.append( - models.MessageContent(**orjson.loads(message_content_json)) - ) - - message_rows.sort(key=lambda m: m.id) - message_content_rows.sort(key=lambda mc: mc.message_id) - - pipe = redis.pipeline() - - pipe.delete( - constants.CALL_MESSAGE_CONTENT_ITERATIONS_TEMPLATE % call_id, - constants.CALL_MESSAGE_CONTENTS_TEMPLATE % call_id, - constants.CALL_MESSAGE_FROM_PROPAGATION_TEMPLATE % call_id, - constants.CALL_MESSAGES_TEMPLATE % call_id, - constants.CALL_MESSAGE_REPLIES_TEMPLATE % call_id, - constants.CALL_IS_ACTIVE % call_id, - ) - - await pipe.execute() - - for _ in range(5): - try: - await models.Message.bulk_create(message_rows, batch_size=20) - break - - except ( - asyncpg.exceptions.PostgresError, - asyncpg.exceptions.InterfaceError, - ): - log.exception( - "[Call ID: %s] An error occured trying to flush messages for call ID.", - call_id, - ) - continue - - for _ in range(5): - try: - await models.MessageContent.bulk_create( - message_content_rows, batch_size=20 - ) - break - - except ( - asyncpg.exceptions.PostgresError, - asyncpg.exceptions.InterfaceError, - ): - log.exception( - "[Call ID: %s] An error occured trying to flush message contents.", - call_id, - ) - continue - - log.debug( - "[Call ID: %s] Flushed %s messages and %s message contents.", - call_id, - len(messages), - len(message_contents), - ) - - log.info("Finished flushing messages.") - - -async def loop() -> None: - """Loops in the background flushing messages to the DB.""" - - import asyncio - - from payphone.db import setup - from payphone.core import logging - - await setup.init() - - logging.setup("Flushing") - - try: - while True: - await asyncio.sleep(30) - - await flush() - - except (KeyboardInterrupt, asyncio.exceptions.CancelledError): - log.exception("") diff --git a/pyproject.toml b/pyproject.toml index dd06381..b935517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,27 @@ [project] -name = "payphone" -version = "0.15.0" +name = "payphone-2" +version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [ "aerich==0.7.1", "aiohttp>=3.10.10", "click>=8.1.7", "colorthief>=0.2.1", "hiredis>=3.0.0", "httpx>=0.27.2", "orjson>=3.10.7", "py-cord>=2.6.1", "python-dotenv>=1.0.1", "rich>=13.9.3", "tortoise-orm[asyncpg]>=0.21.7",] +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", +] + +[tool] [tool.aerich] -tortoise_orm = "payphone.core.config.TORTOISE_ORM" +tortoise_orm = "payphone.db.setup.CONFIG" location = "./payphone/db/migrations" src_folder = "." - -[tool.uv] -dev-dependencies = [ "toml>=0.10.2",] diff --git a/redis/__init__.py b/redis/__init__.py deleted file mode 100644 index e604da3..0000000 --- a/redis/__init__.py +++ /dev/null @@ -1,826 +0,0 @@ -from typing import TypeAlias, Awaitable, AsyncGenerator - -from urllib import parse - -from datetime import datetime, timedelta - -import asyncio - -import hiredis - - -Resp3Repr: TypeAlias = str | int | bytes | bytearray | float | bool | None - - -class _Ellipsis: - """Represents an incomplete response of Redis.""" - - -def _encode( - arg: ( - Resp3Repr - | list[Resp3Repr] - | tuple[Resp3Repr] - | dict[Resp3Repr, Resp3Repr] - | set[Resp3Repr] - ) -) -> bytes: - """Encode a value into its RESP3 representation. - - This function supports str (converted to bytes), int, bytes, bytearray, float, bool, and None - as basic types. It also supports lists (arrays), dicts (maps), and sets. - - Args: - arg (Resp3Repr | list[Resp3Repr] | tuple[Resp3Repr] | dict[Resp3Repr, Resp3Repr] | set[Resp3Repr]): - The argument to encode. - - Raises: - ValueError: The value provided is invalid or not encodable. - - Returns: - bytes: The encoded representation of the value. - """ - - if isinstance(arg, (str, int)): - arg = str(arg).encode() - - if isinstance(arg, (bytes, bytearray)): - return f"${len(arg)}\r\n".encode() + bytes(arg) + b"\r\n" - - elif isinstance(arg, int): - return f":{arg}\r\n".encode() - - elif isinstance(arg, type(None)): - return b"_\r\n" - - elif isinstance(arg, float): - result = f",{arg}\r\n".encode() - - elif isinstance(arg, bool): - if arg: - return b"#t\r\n" - - return b"#f\r\n" - - elif isinstance(arg, (list, tuple)): - return f"*{len(arg)}\r\n".encode() + b"".join([_encode(i) for i in arg]) - - elif isinstance(arg, dict): - result = f"%{len(arg.keys())}\r\n".encode() - - for key, value in arg.items(): - result += _encode(key) + _encode(value) - - return result - - elif isinstance(arg, set): - result = f"~{len(arg)}\r\n".encode() - - for item in arg: - result += _encode(item) - - return result - - raise ValueError(f"Value of type {type(arg)} is not encodable into RESP3.") - - -def _get_secs(time: int | timedelta) -> int: - """Converts an integer or timedelta into an amount of seconds. - - Args: - time (int | timedelta): The number or timedelta to convert. - - Returns: - int: The amount of seconds. - """ - - if isinstance(time, timedelta): - return int(time.total_seconds()) - - return time - - -def _get_millis(time: int | timedelta) -> int: - """Converts an integer or timedelta into an amount of milliseconds. - - Args: - time (int | timedelta): The number or timedelta to convert. - - Returns: - int: The amount of milliseconds. - """ - - if isinstance(time, timedelta): - return int(time.total_seconds() * 1000) - - return time - - -def _get_timestamp_secs(dtime: datetime | int) -> int: - """Converts a datetime or timestamp into it's seconds timestamp. - - Args: - dtime (datetime | int): The datetime object to convert or an integer value which will be - returned as-is. - - Returns: - int: The seconds timestamp. - """ - - if isinstance(dtime, datetime): - return int(dtime.timestamp()) - - return dtime - - -def _get_timestamp_millis(dtime: datetime | int) -> int: - """Converts a datetime or timestamp into it's milliseconds timestamp. - - Args: - dtime (datetime | int): The datetime object to convert or an integer value which will be - returned as-is. - - Returns: - int: The milliseconds timestamp. - """ - - if isinstance(dtime, datetime): - return int(dtime.timestamp() * 1000) - - return dtime - - -class RedisError(Exception): - """Raised whenever an error is raised from this library.""" - - -class FatalRedisError(RedisError): - """Raised whenever an error occurs that does not allow the connection to stay alive.""" - - -class Runnable: - """The base class for all Redis commands. - - This class is inherited by Connection, Pipeline, and Transaction to implement their own - functionality. - """ - - _client: "Redis" - - def __init__(self, client: "Redis") -> None: - """Initializes the runner with the client's configuration. - - Args: - client (Redis): The client that initialized the runner. - """ - - self._client = client - - def command(self, *args: tuple[Resp3Repr]) -> Resp3Repr | Awaitable[Resp3Repr]: - """Runs a command taking it as a list of arguments. - - For example: - ``` - await Runnable.command("SET", "key", "value") - ``` - - This method is not implemented by default, so you need a Connection, a Pipeline, or a - Transaction to use it. - - Returns: - Resp3Repr | Awaitable[Resp3Repr]: The value(s) returned by the execution of the - command. - """ - - raise NotImplementedError( - "This method needs to be implemented by a subclass of Runner." - ) - - def ping(self, arg: str = "PONG") -> Resp3Repr: - """Returns PONG if no argument is provided, otherwise return a copy of the argument as a - bulk. - - Args: - arg (str): The argument to return by the command. - - Returns: - Resp3Repr: The response of the command. - """ - - return self.command("PING", str(arg)) - - -class RunnableGenericMixin: - """Mixin containing all Redis generic commands.""" - - def delete(self, *keys: tuple[str]) -> None: - """Deletes one or more keys.""" - - return self.command("DEL", *[str(key) for key in keys]) - - def exists(self, *keys: tuple[str]) -> int: - """Returns if key exists. - - The user should be aware that if the same existing key is mentioned in the arguments - multiple times, it will be counted multiple times. So if somekey exists, `EXISTS somekey - somekey` will return 2. - - Args: - *keys (tuple[str]): The keys to check for existance. - - Returns: - int: The amount of keys that exist. - """ - - return self.command("EXISTS", *[str(key) for key in keys]) - - -class RunnableHashMixin: - """Mixin containing all Redis hash commands.""" - - def hget(self, key: str, field: str) -> str | None: - """Returns the value associated with field in the hash stored at key. - - Args: - key (str): The key of the hash. - field (str): The field of the hash to get. - - Returns: - str | None: The value of the field, or None if it does not exist. - """ - - return self.command("HGET", str(key), str(field)) - - def hgetall(self, key: str) -> dict[Resp3Repr, Resp3Repr]: - """Returns all fields and values of the hash stored at key. - - Args: - key (str): The key to get the fields and values from. - - Returns: - dict[Resp3Repr, Resp3Repr]: A dict containing all the fields mapped to their respective - values. - """ - - return self.command("HGETALL", str(key)) - - def hincrby(self, key: str, field: str, increment: int = 1) -> int: - """Increments the number stored at field in the hash stored at key by increment. - - If key does not exist, a new key holding a hash is created. If field does not exist the - value is set to 0 before the operation is performed. The range of values supported by - HINCRBY is limited to 64 bit signed integers. - - Args: - key (str): The key of the hash whose field to increment. - field (str): The field to increment. - increment (int, optional): The amount to increment by. Defaults to 1. - - Returns: - int: The amount returned - """ - - return self.command("HINCRBY", str(key), str(field), str(increment)) - - def hset(self, key: str, mapping: dict[Resp3Repr, Resp3Repr]) -> int: - """Sets the specified fields to their respective values in the hash stored at key. - - This command overwrites the values of specified fields that exist in the hash. If key - doesn't exist, a new key holding a hash is created. - - Args: - key (str): The key to set the values for. - mapping (dict[Resp3Repr, Resp3Repr]): The fields mapped to their values. - - Returns: - int: The number of fields that were added. - """ - - items = [] - - for k, v in mapping.items(): - items.append(k) - items.append(v) - - return self.command("HSET", str(key), *items) - - -class RunnableSetMixin: - """Mixin containing all Redis set commands.""" - - def sadd(self, key: str, *values: tuple[str]) -> int: - """Adds the specified members to the set stored at key. - - Specified members that are already a member of this set are ignored. If key does not exist, - a new set is created before adding the specified members. An error is raised when the value - stored at key is not a set. - - Args: - key (str): The key to add the values to. - *values (tuple[str]): The values to add to the set. - - Returns: - int: The amount of new values that were added. - """ - - return self.command("SADD", str(key), *values) - - def scard(self, key: str) -> int: - """Returns the set cardinality (number of elements) of the set stored at key. - - Args: - key (str): The key of the set to get the cardinality from. - - Returns: - int: The number of elements, or 0 if it does not exist. - """ - - return self.command("SCARD", str(key)) - - def smembers(self, key: str) -> set[str]: - """Returns all the members of the set value stored at key. - - Args: - key (str): The key of the set to get the members from. - - Returns: - set[str]: The members of the set. - """ - - return self.command("SMEMBERS", str(key)) - - def sismember(self, key: str, value: str) -> bool: - """Returns whether the value is a member of the set. - - Args: - key (str): The key of the set. - value (str): The value to check whether it is a member of the set. - - Returns: - bool: Whether the value is a member of the set. - """ - - return self.command("SISMEMBER", str(key), str(value)) - - def spop(self, key: str, count: int = 1) -> str | list[str]: - """Removes and returns one or more random members from the set value store at key.""" - - return self.command("SPOP", str(key), count) - - def srem(self, key: str, *values: tuple[str]) -> int: - """Removes the specified members from the set stored at key. - - Specified members that are not a member of this set are ignored. If key does not exist, - it is treated as an empty set and this command returns 0. An error is returned when the - value stored at key is not a set. - - Args: - key (str): The key of the set. - *values (tuple[str]): The values to remove from the set. - - Returns: - int: The amount of values that were removed from the set. - """ - - return self.command("SREM", str(key), *values) - - -class RunnableStringMixin: - """Mixin containing all Redis string commands.""" - - def append(self, key: str, value: str) -> int: - """Appends a string to an existing key, or creates a new one if it does not exist. - - If key already exists and is a string, this command appends the value at the end of the - string. If key does not exist it is created and set as an empty string, so APPEND will be - similar to SET in this special case. - - Args: - key (str): The key to append to. - value (str): The string to append. - - Returns: - int: The new length of the value. - """ - - return self.command("APPEND", str(key), str(value)) - - def decr(self, key: str) -> int: - """Decrements the number stored at key by one. - - If the key does not exist, it is set to 0 before performing the operation. An error is - returned if the key contains a value of the wrong type or contains a string that can not be - represented as integer. This operation is limited to 64 bit signed integers. - - Args: - key (str): The key to decrement. - - Returns: - int: The decremented value. - """ - - return self.command("DECR", str(key)) - - def decrby(self, key: str, decrement: int) -> int: - """Reduces the value stored at the specified key by the specified decrement. - - If the key does not exist, it is initialized with a value of 0 before performing the - operation. If the key's value is not of the correct type or cannot be represented as an - integer, an error is returned. This operation is limited to 64-bit signed integers. - - Args: - key (str): The key to decrement. - decrement (int): The amount to decrement by. - - Returns: - int: The decremented value. - """ - - return self.command("DECRBY", str(key), decrement) - - def get(self, key: str) -> str | None: - """Returns the string value of a key. - - If the key does not exist None is returned. An error is returned if the value stored at key - is not a string, because GET only handles string values. - - Args: - key (str): The key to get the value from. - - Returns: - str | None: The value of the key, of None if it does not exist. - """ - - return self.command("GET", str(key)) - - def getdel(self, key: str) -> str | None: - """Returns the value of key and delete the key. - - This command is similar to GET, except for the fact that it also deletes the key on success - (if and only if the key's value type is a string). - - Args: - key (str): The key to get and delete. - - Returns: - str | None: The value of the key. - """ - - return self.command("GETDEL", str(key)) - - def set( - self, - key: str, - value: str, - nx: bool = False, - xx: bool = False, - get: bool = False, - ex: timedelta | int | None = None, - px: timedelta | int | None = None, - exat: datetime | int | None = None, - pxat: datetime | int | None = None, - keepttl: bool = False, - ) -> str | None: - """Set key to hold the string value. - - If key already holds a value, it is overwritten, regardless of its type. Any previous time - to live associated with the key is discarded on successful SET operation. - - Args: - key (str): The key to set. - value (str): The value to set to the specified key. - nx (bool, optional): Only set the key if it does not already exist. Defaults to False. - xx (bool, optional): Only set the key if it already exists. Defaults to False. - get (bool, optional): Return the old string stored at key, or None if key did not - exist. An error is returned and SET aborted if the value stored at key is not a - string. Defaults to False. - ex (timedelta | int | None, optional): Set the specified expire time, in seconds (a positive - integer). Defaults to None. - px (timedelta | int | None, optional): Set the specified expire time, in milliseconds (a positive - integer). Defaults to None. - exat (datetime | int | None, optional): Set the specified time at which the key will - expire, in seconds (a positive integer) or a datetime object. Defaults to None. - pxat (datetime | int | None, optional): Set the specified time at which the key will - expire, in milliseconds (a positive integer) or a datetime object. Defaults to None. - keepttl (bool, optional): Retain the time to live associated with the key. Defaults to - False. - - Returns: - str | None: The old key in case `get` was set, or None. - """ - - if nx and xx: - raise ValueError("Only one of XX or NX can be set, but not both.") - - if len([i for i in (ex, px, exat, pxat, keepttl) if i]) > 1: - raise ValueError( - "Only one of EX, PX, EXAT, PXAT, or KEEPTTL can be set, but not multiple of them." - ) - - command = ["SET", str(key), str(value)] - - if nx: - command += ["NX"] - - elif xx: - command += ["XX"] - - if get: - command += ["GET"] - - if ex: - command += ["EX", str(_get_secs(ex))] - - elif px: - command += ["PX", str(_get_millis(px))] - - elif exat: - command += ["EXAT", str(_get_timestamp_secs(exat))] - - elif pxat: - command += ["PXAT", str(_get_timestamp_millis(pxat))] - - elif keepttl: - command += ["KEEPTTL"] - - return self.command(*command) - - -class FullRunnable( - Runnable, - RunnableGenericMixin, - RunnableHashMixin, - RunnableSetMixin, - RunnableStringMixin, -): - """A runnable containing all the command mixins.""" - - -class Connection(FullRunnable): - _reader: asyncio.StreamReader - _writer: asyncio.StreamWriter - _parser: hiredis.Reader - _lock: asyncio.Lock - - def __init__( - self, - client: "Redis", - reader: asyncio.StreamReader, - writer: asyncio.StreamWriter, - parser: hiredis.Reader = hiredis.Reader(notEnoughData=_Ellipsis), - ) -> None: - """Initializes the connection object. - - Args: - client (Redis): The client that initialized the connection. - reader (asyncio.StreamReader): The socket stream reader. - writer (asyncio.StreamWriter): The socket stream writer. - parser (hiredis.Reader, optional): The RESP3 parser. Defaults to a basic implementation - if not set. - """ - - self._client = client - self._reader = reader - self._writer = writer - self._parser = parser - self._lock = asyncio.Lock() - - super().__init__(client) - - def release(self) -> None: - """Returns the connection back to the pool.""" - - self._client._pool.append(self) - - def destroy(self) -> None: - """Closes the connection and frees it's space in the client's pool.""" - - self._client._open_connections -= 1 - - self._writer.close() - - async def __aenter__(self) -> "Connection": - return self - - async def __aexit__(self, *args) -> None: - self.release() - - async def command(self, *args: list[Resp3Repr]) -> Resp3Repr: - """Executes a command and returns it's response. - - Raises: - hiredis.ReplyError: Any errors returned by Redis. - """ - - args = list(args) - - command = _encode(args) - - async def run(): - async with self._lock: - self._writer.write(command) - await self._writer.drain() - - while True: - self._parser.feed(await self._reader.readuntil(b"\r\n")) - - result = self._parser.gets() - - if result == _Ellipsis: - continue - - elif isinstance(result, hiredis.ReplyError): - raise result - - return result - - try: - return await asyncio.wait_for(run(), self._client._command_timeout) - - except ( - hiredis.ProtocolError, - asyncio.TimeoutError, - ConnectionResetError, - ) as exc: - self.destroy() - - raise FatalRedisError() from exc - - async def hello(self, username: str = None, password: str = None) -> dict: - """Sends the initial HELLO command to the server. - - Args: - username (str, optional): The username to authenticate with. Defaults to None, or - "default" if `password` is set. - password (str, optional): The password to authenticate with. Defaults to None. - - Returns: - dict: The metadata map returned by the server. - """ - - command = ["HELLO", "3"] - - if password is not None: - command += ["AUTH", username or "default", password] - - return await self.command(*command) - - -class Pipeline(FullRunnable): - """A Redis pipeline.""" - - _commands: list[tuple[Resp3Repr]] - - def __init__(self, client: "Redis") -> None: - self._commands = [] - - super().__init__(client) - - def command(self, *args: tuple[Resp3Repr]) -> None: - self._commands.append(args) - - async def execute(self): - """Executes the pipeline. - - Returns: - tuple[Resp3Repr]: The results returned by the execution of the commands. - """ - - command = b"".join([_encode(comm) for comm in self._commands]) - - async with await self._client.connection() as conn: - - async def run(): - async with conn._lock: - conn._writer.write(command) - await conn._writer.drain() - - results = [] - - while True: - conn._parser.feed(await conn._reader.readuntil(b"\r\n")) - - result = conn._parser.gets() - - if result == _Ellipsis: - continue - - elif isinstance(result, hiredis.ReplyError): - raise RedisError( - "An error was returned from the Redis server." - ) from result - - results.append(result) - - if len(results) == len(self._commands): - return results - - try: - return tuple( - await asyncio.wait_for(run(), self._client._command_timeout) - ) - - except ( - hiredis.ProtocolError, - asyncio.TimeoutError, - ConnectionResetError, - ) as exc: - conn.destroy() - - raise FatalRedisError() from exc - - -class Redis(FullRunnable): - _pool: list[Connection] = [] - _open_connections: int = 0 - - def __init__( - self, - url: str = "", - max_connections: int = 100, - connection_timeout: int = 3, - command_timeout: int = 3, - encoding: str = "utf-8", - ) -> None: - """Initializes the client class. - - Args: - url (str): The redis URL to connect to. Defaults to "redis://localhost:6379" - max_connections (int, optional): The maximum amount of connections that can be opened. - Defaults to 100. - connection_timeout (int, optional): The amount of seconds to wait for a connection to - establish before raising a TimeoutError. Defaults to 3. - command_timeout (int, optional): The amount of seconds to wait for a command to execute - before raising a TimeoutError. Defaults to 3. - encoding (str, optional): The encoding with which to decode Redis response values. - """ - - self._client = self - - self._lock = asyncio.Lock() - self._max_connections = max_connections - self._connection_timeout = connection_timeout - self._command_timeout = command_timeout - self._encoding = encoding - - parsed_url = parse.urlparse(url) - - self._host = parsed_url.hostname or "localhost" - self._port = parsed_url.port or 6379 - self._username = parsed_url.username - self._password = parsed_url.password - - async def connection(self) -> Connection: - """Returns an available connection or creates a new one if none are available and the pool - has space remaining. - - Raises: - ConnectionError: If the pool has reached it's max size and no connections are available. - """ - - async with self._lock: - try: - connection = self._pool.pop() - return connection - - except IndexError as exc: - if self._open_connections >= self._max_connections: - raise ConnectionError( - "The pool has reached it's limit of connections and no connections are " - "available at the moment." - ) from exc - - reader, writer = await asyncio.wait_for( - asyncio.open_connection(host=self._host, port=self._port), - self._connection_timeout, - ) - - connection = Connection( - client=self, - reader=reader, - writer=writer, - parser=hiredis.Reader( - notEnoughData=_Ellipsis, encoding=self._encoding - ), - ) - - await connection.hello(username=self._username, password=self._password) - - self._open_connections += 1 - - return connection - - async def command(self, *args: tuple[Resp3Repr]) -> Awaitable[Resp3Repr]: - """Executes a command with a free connection from the pool. - - Returns: - Awaitable[Resp3Repr]: The command's response. - """ - - async with await self.connection() as conn: - return await conn.command(*args) - - def pipeline(self) -> Pipeline: - """Creates a new pipeline.""" - - return Pipeline(self) diff --git a/scripts/deploy.py b/scripts/deploy.py deleted file mode 100644 index 0e03007..0000000 --- a/scripts/deploy.py +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 6362056..0000000 --- a/scripts/update.py +++ /dev/null @@ -1,33 +0,0 @@ -import os -import sys - -try: - import toml - -except ImportError: - print( - "You need to install `toml` to use this file. Run `pip install toml` and try again." - ) - sys.exit() - -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/tasks.py b/tasks.py index afb5041..dbd3974 100644 --- a/tasks.py +++ b/tasks.py @@ -1,107 +1,26 @@ -from datetime import datetime, timezone, timedelta - import asyncio -import functools +import typer -import click - -from procs import flush_messages - -from payphone.db import setup, models -from payphone.bot import bot -from payphone.core import logging, config, constants +from payphone import tasks -logging.setup("Tasks") - -log = logging.logger +app = typer.Typer(name="Tasks") -def coroutine(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - return asyncio.run(f(*args, **kwargs)) +@app.command() +def connect() -> None: + """Connect to other people.""" - return wrapper + asyncio.run(tasks.connect.main()) -@click.group() -@coroutine -async def main(): - await setup.init() +@app.command() +def expire(): + """Expire calls.""" - -@main.command() -@coroutine -async def flush(): - """Flush all messages in Redis to the database. Runs every 5 seconds.""" - - while True: - await flush_messages.flush() - await asyncio.sleep(5) - - -@main.command() -@coroutine -async def delete_accounts(): - """Delete all accounts that were scheduled for deletion. Runs every 1 hour.""" - - while True: - total = await models.User.filter(is_deleting=True).delete() - - log.info("Deleted %s accounts scheduled for deletion.", total) - - await asyncio.sleep(60 * 60) - - -@main.command() -@coroutine -async def hangup_inactive_calls(): - """Hangs up inactive calls.""" - - await bot.login(config.TOKEN) - - await bot.redis.ping() - - while True: - await asyncio.sleep(5) - - for call in await models.Call.filter(ended_at=None).prefetch_related( - "channels" - ): - if call.started_at > datetime.now(timezone.utc) - timedelta(minutes=1): - continue - - if ( - await bot.redis.exists( - constants.CALL_MESSAGES_TEMPLATE % call.id, - constants.CALL_IS_ACTIVE % call.id, - ) - ) != 2: - call.ended_at = datetime.now(timezone.utc) - await call.save() - - log.info("Hung up call %s.", call.id) - - try: - channels = [ - await bot.get_or_fetch_channel(channel.id) - for channel in call.channels - ] - - for channel in channels: - try: - await channel.send( - "☎️ The call was hung up because nobody replied." - ) - - finally: - pass - - finally: - continue + asyncio.run(tasks.expire.main()) if __name__ == "__main__": - main() + app() diff --git a/uv.lock b/uv.lock index 44f5853..e867467 100644 --- a/uv.lock +++ b/uv.lock @@ -109,19 +109,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] -[[package]] -name = "anyio" -version = "4.6.2.post1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, -] - [[package]] name = "asyncpg" version = "0.30.0" @@ -156,12 +143,17 @@ wheels = [ ] [[package]] -name = "certifi" -version = "2024.8.30" +name = "ciso8601" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/bc/cf42c1b0042f91c90a6b00244f63b6fb137af15e43e29f07bb72cf955be8/ciso8601-2.3.1.tar.gz", hash = "sha256:3212c7ffe5d8080270548b5f2692ffd2039683b6628a8d2ad456122cc5793c4c", size = 31225 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, + { url = "https://files.pythonhosted.org/packages/85/3d/f6496a260ba6e58135fb3dd1108799f6dd9cadf634372e020bfbf0d27fea/ciso8601-2.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f39bb5936debf21c52e5d52b89f26857c303da80c43a72883946096a6ef5e561", size = 24211 }, + { url = "https://files.pythonhosted.org/packages/c4/a9/24ffa848a5878a50009d6177826c36b60e2e8807e4d54ee94817e790897b/ciso8601-2.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:21cf83ca945bb26ecd95364ae2c9ed0276378e5fe35ce1b64d4c6d5b33038ea3", size = 15591 }, + { url = "https://files.pythonhosted.org/packages/ea/74/77fd6e67a2a3489a1ac449570142e2f4137289be25027e235d4688470d56/ciso8601-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:013410263cba46748d2de29e9894341ae41223356cde7970478c32bd0984d10c", size = 15643 }, + { url = "https://files.pythonhosted.org/packages/5b/bd/fef5524974e3ba376e16df35e4197152edffdf7ac9d5d99bc173a9fcf256/ciso8601-2.3.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b26935687ef1837b56997d8c61f1d789e698be58b261410e629eda9c89812141", size = 39805 }, + { url = "https://files.pythonhosted.org/packages/50/1c/d1cb5b2d2173abfc4d5a068981acddff763cce318e896ec87c140412c72d/ciso8601-2.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0d980a2a88030d4d8b2434623c250866a75b4979d289eba69bec445c51ace99f", size = 48335 }, + { url = "https://files.pythonhosted.org/packages/76/ef/5ef06ccb6f6c023573634119f93df237687e3f4263e2f307b51a7208d103/ciso8601-2.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:87721de54e008fb1c4c3978553b05a9c417aa25b76ddf5702d6f7e8d9b109288", size = 49449 }, ] [[package]] @@ -185,18 +177,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] -[[package]] -name = "colorthief" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/b2/b55b741f7a7d1299d23e1c635f00f6c57ea4d2e9b76d09e1fc5ea3ca9921/colorthief-0.2.1.tar.gz", hash = "sha256:079cb0c95bdd669c4643e2f7494de13b0b6029d5cdbe2d74d5d3c3386bd57221", size = 6164 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/18/be03b7058e65f9df479b14e7af4e73945ce311e07aaad45cf2536e14791a/colorthief-0.2.1-py2.py3-none-any.whl", hash = "sha256:b04fc8ce5cf9c888768745e29cb19b7b688d5711af6fba26e8057debabec56b9", size = 6134 }, -] - [[package]] name = "dictdiffer" version = "0.9.0" @@ -245,15 +225,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, ] -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, -] - [[package]] name = "hiredis" version = "3.0.0" @@ -277,35 +248,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/67/f50b45071bb8652fa9a28a84ee470a02042fb7a096a16f3c08842f2a5c2b/hiredis-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e584fe5f4e6681d8762982be055f1534e0170f6308a7a90f58d737bab12ff6a8", size = 21971 }, ] -[[package]] -name = "httpcore" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, -] - -[[package]] -name = "httpx" -version = "0.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, - { name = "sniffio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, -] - [[package]] name = "idna" version = "3.10" @@ -410,83 +352,39 @@ wheels = [ ] [[package]] -name = "payphone" -version = "0.14.0" +name = "payphone-2" +version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aerich" }, { name = "aiohttp" }, - { name = "click" }, - { name = "colorthief" }, - { name = "hiredis" }, - { name = "httpx" }, { name = "orjson" }, { name = "py-cord" }, - { name = "python-dotenv" }, + { name = "redis", extra = ["hiredis"] }, { name = "rich" }, - { name = "tortoise-orm", extra = ["asyncpg"] }, + { name = "tortoise-orm", extra = ["accel", "asyncpg"] }, + { name = "typer" }, ] [package.dev-dependencies] dev = [ - { name = "toml" }, + { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ { name = "aerich", specifier = "==0.7.1" }, { name = "aiohttp", specifier = ">=3.10.10" }, - { name = "click", specifier = ">=8.1.7" }, - { name = "colorthief", specifier = ">=0.2.1" }, - { name = "hiredis", specifier = ">=3.0.0" }, - { name = "httpx", specifier = ">=0.27.2" }, - { name = "orjson", specifier = ">=3.10.7" }, + { name = "orjson", specifier = ">=3.10.11" }, { name = "py-cord", specifier = ">=2.6.1" }, - { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "rich", specifier = ">=13.9.3" }, - { name = "tortoise-orm", extras = ["asyncpg"], specifier = ">=0.21.7" }, + { name = "redis", extras = ["hiredis"], specifier = ">=5.2.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "tortoise-orm", extras = ["accel", "asyncpg"], specifier = ">=0.21.7" }, + { name = "typer", specifier = ">=0.13.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "toml", specifier = ">=0.10.2" }] - -[[package]] -name = "pillow" -version = "11.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/26/0d95c04c868f6bdb0c447e3ee2de5564411845e36a858cfd63766bc7b563/pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739", size = 46737780 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/a3/26e606ff0b2daaf120543e537311fa3ae2eb6bf061490e4fea51771540be/pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923", size = 3147642 }, - { url = "https://files.pythonhosted.org/packages/4f/d5/1caabedd8863526a6cfa44ee7a833bd97f945dc1d56824d6d76e11731939/pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903", size = 2978999 }, - { url = "https://files.pythonhosted.org/packages/d9/ff/5a45000826a1aa1ac6874b3ec5a856474821a1b59d838c4f6ce2ee518fe9/pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4", size = 4196794 }, - { url = "https://files.pythonhosted.org/packages/9d/21/84c9f287d17180f26263b5f5c8fb201de0f88b1afddf8a2597a5c9fe787f/pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f", size = 4300762 }, - { url = "https://files.pythonhosted.org/packages/84/39/63fb87cd07cc541438b448b1fed467c4d687ad18aa786a7f8e67b255d1aa/pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9", size = 4210468 }, - { url = "https://files.pythonhosted.org/packages/7f/42/6e0f2c2d5c60f499aa29be14f860dd4539de322cd8fb84ee01553493fb4d/pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7", size = 4381824 }, - { url = "https://files.pythonhosted.org/packages/31/69/1ef0fb9d2f8d2d114db982b78ca4eeb9db9a29f7477821e160b8c1253f67/pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6", size = 4296436 }, - { url = "https://files.pythonhosted.org/packages/44/ea/dad2818c675c44f6012289a7c4f46068c548768bc6c7f4e8c4ae5bbbc811/pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc", size = 4429714 }, - { url = "https://files.pythonhosted.org/packages/af/3a/da80224a6eb15bba7a0dcb2346e2b686bb9bf98378c0b4353cd88e62b171/pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6", size = 2249631 }, - { url = "https://files.pythonhosted.org/packages/57/97/73f756c338c1d86bb802ee88c3cab015ad7ce4b838f8a24f16b676b1ac7c/pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47", size = 2567533 }, - { url = "https://files.pythonhosted.org/packages/0b/30/2b61876e2722374558b871dfbfcbe4e406626d63f4f6ed92e9c8e24cac37/pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25", size = 2254890 }, - { url = "https://files.pythonhosted.org/packages/63/24/e2e15e392d00fcf4215907465d8ec2a2f23bcec1481a8ebe4ae760459995/pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699", size = 3147300 }, - { url = "https://files.pythonhosted.org/packages/43/72/92ad4afaa2afc233dc44184adff289c2e77e8cd916b3ddb72ac69495bda3/pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38", size = 2978742 }, - { url = "https://files.pythonhosted.org/packages/9e/da/c8d69c5bc85d72a8523fe862f05ababdc52c0a755cfe3d362656bb86552b/pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2", size = 4194349 }, - { url = "https://files.pythonhosted.org/packages/cd/e8/686d0caeed6b998351d57796496a70185376ed9c8ec7d99e1d19ad591fc6/pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2", size = 4298714 }, - { url = "https://files.pythonhosted.org/packages/ec/da/430015cec620d622f06854be67fd2f6721f52fc17fca8ac34b32e2d60739/pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527", size = 4208514 }, - { url = "https://files.pythonhosted.org/packages/44/ae/7e4f6662a9b1cb5f92b9cc9cab8321c381ffbee309210940e57432a4063a/pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa", size = 4380055 }, - { url = "https://files.pythonhosted.org/packages/74/d5/1a807779ac8a0eeed57f2b92a3c32ea1b696e6140c15bd42eaf908a261cd/pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f", size = 4296751 }, - { url = "https://files.pythonhosted.org/packages/38/8c/5fa3385163ee7080bc13026d59656267daaaaf3c728c233d530e2c2757c8/pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb", size = 4430378 }, - { url = "https://files.pythonhosted.org/packages/ca/1d/ad9c14811133977ff87035bf426875b93097fb50af747793f013979facdb/pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798", size = 2249588 }, - { url = "https://files.pythonhosted.org/packages/fb/01/3755ba287dac715e6afdb333cb1f6d69740a7475220b4637b5ce3d78cec2/pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de", size = 2567509 }, - { url = "https://files.pythonhosted.org/packages/c0/98/2c7d727079b6be1aba82d195767d35fcc2d32204c7a5820f822df5330152/pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84", size = 2254791 }, - { url = "https://files.pythonhosted.org/packages/eb/38/998b04cc6f474e78b563716b20eecf42a2fa16a84589d23c8898e64b0ffd/pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b", size = 3150854 }, - { url = "https://files.pythonhosted.org/packages/13/8e/be23a96292113c6cb26b2aa3c8b3681ec62b44ed5c2bd0b258bd59503d3c/pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003", size = 2982369 }, - { url = "https://files.pythonhosted.org/packages/97/8a/3db4eaabb7a2ae8203cd3a332a005e4aba00067fc514aaaf3e9721be31f1/pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2", size = 4333703 }, - { url = "https://files.pythonhosted.org/packages/28/ac/629ffc84ff67b9228fe87a97272ab125bbd4dc462745f35f192d37b822f1/pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a", size = 4412550 }, - { url = "https://files.pythonhosted.org/packages/d6/07/a505921d36bb2df6868806eaf56ef58699c16c388e378b0dcdb6e5b2fb36/pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8", size = 4461038 }, - { url = "https://files.pythonhosted.org/packages/d6/b9/fb620dd47fc7cc9678af8f8bd8c772034ca4977237049287e99dda360b66/pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8", size = 2253197 }, - { url = "https://files.pythonhosted.org/packages/df/86/25dde85c06c89d7fc5db17940f07aae0a56ac69aa9ccb5eb0f09798862a8/pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904", size = 2572169 }, - { url = "https://files.pythonhosted.org/packages/51/85/9c33f2517add612e17f3381aee7c4072779130c634921a756c97bc29fb49/pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3", size = 2256828 }, -] +dev = [{ name = "python-dotenv", specifier = ">=1.0.1" }] [[package]] name = "propcache" @@ -626,6 +524,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] +[[package]] +name = "redis" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/17/2f4a87ffa4cd93714cf52edfa3ea94589e9de65f71e9f99cbcfa84347a53/redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", size = 4607878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/f5/ffa560ecc4bafbf25f7961c3d6f50d627a90186352e27e7d0ba5b1f6d87d/redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897", size = 261428 }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + [[package]] name = "rich" version = "13.9.4" @@ -640,21 +552,12 @@ wheels = [ ] [[package]] -name = "sniffio" -version = "1.3.1" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[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 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -683,10 +586,30 @@ wheels = [ ] [package.optional-dependencies] +accel = [ + { name = "ciso8601", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, + { name = "orjson" }, + { name = "uvloop", marker = "implementation_name == 'cpython' and sys_platform != 'win32'" }, +] asyncpg = [ { name = "asyncpg" }, ] +[[package]] +name = "typer" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/87/9eb07fdfa14e22ec7658b5b1147836d22df3848a22c85a4e18ed272303a5/typer-0.13.0.tar.gz", hash = "sha256:f1c7198347939361eec90139ffa0fd8b3df3a2259d5852a0f7400e476d95985c", size = 97572 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/7e/c8bfa8cbcd3ea1d25d2beb359b5c5a3f4339a7e2e5d9e3ef3e29ba3ab3b9/typer-0.13.0-py3-none-any.whl", hash = "sha256:d85fe0b777b2517cc99c8055ed735452f2659cd45e451507c76f48ce5c1d00e2", size = 44194 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -696,6 +619,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284 }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349 }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089 }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770 }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321 }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + [[package]] name = "yarl" version = "1.17.1"