Compare commits
229 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
25dd9d38eb
|
|||
|
66529c70e9
|
|||
|
1433e9dfd3
|
|||
|
11e9ac12b3
|
|||
|
d0b4c9cc55
|
|||
|
752d902335
|
|||
|
7f9263689e
|
|||
|
8519ab6bdb
|
|||
|
f7ef3d1aa5
|
|||
|
7a0befd13d
|
|||
|
19999577ee
|
|||
|
0e13891d3c
|
|||
|
0897d5b6b4
|
|||
|
fecf97abf5
|
|||
|
a6fc19ff86
|
|||
|
7b18078225
|
|||
|
fa20da9189
|
|||
|
b995b22e11
|
|||
|
2f3d489ca2
|
|||
|
55cd13157a
|
|||
|
867d7c5926
|
|||
|
927941e1c2
|
|||
|
9d8208f693
|
|||
|
f79e5a31a4
|
|||
|
7b3450aed0
|
|||
|
7aec27dc12
|
|||
|
2aaf929c53
|
|||
|
cd925128d4
|
|||
|
7cdbe00432
|
|||
|
1d8fa8d6b7
|
|||
| 4187bb3492 | |||
|
c4e9073496
|
|||
| a2d0806669 | |||
| 9ae477369f | |||
| c3b12ad48b | |||
| bc1720495a | |||
| f068422aa0 | |||
| 54e32455d8 | |||
| 7dbfa3280e | |||
| 49e5a07c5f | |||
|
2fa658a174
|
|||
| 48744f2cd8 | |||
| a0dda1b47e | |||
|
b56865b275
|
|||
|
d35aa4167a
|
|||
| 4b31c9943a | |||
|
b1c47fbc2b
|
|||
|
da93b0ce87
|
|||
| 415cd771d6 | |||
|
bff0deef22
|
|||
|
c1bdf30c77
|
|||
|
65bbb3b693
|
|||
|
73c79d4d98
|
|||
|
bd9f3ced76
|
|||
|
e028add504
|
|||
|
9f708eea8b
|
|||
|
7c6f93a86c
|
|||
|
543ce4e71b
|
|||
|
1dc2cc70de
|
|||
|
b690d61bfb
|
|||
|
8bf0be0611
|
|||
|
3801992de7
|
|||
|
5f34c0f9a5
|
|||
|
7c4c1dfd04
|
|||
|
af80df540a
|
|||
|
e3241f3020
|
|||
|
0da9c49c37
|
|||
|
f8156e11e2
|
|||
|
6fc7f80b9f
|
|||
|
bb19662cf4
|
|||
|
0c2421326a
|
|||
|
b8eb945d53
|
|||
|
6993deb39c
|
|||
|
3351dc73c0
|
|||
|
064aaee7c7
|
|||
|
3197cc317a
|
|||
|
56af31a9f9
|
|||
|
8bfdd2479a
|
|||
|
48be2de7b3
|
|||
|
37c2ce8604
|
|||
|
88fa19c6f2
|
|||
|
7fdc0c38e5
|
|||
|
dc6223ab87
|
|||
|
3a877631cf
|
|||
|
5acddf3e94
|
|||
|
1153a9441f
|
|||
|
63c815e748
|
|||
|
8af1cfd689
|
|||
|
2936705be0
|
|||
|
8d10901467
|
|||
|
6ef1e4be38
|
|||
|
5b5d6a9d88
|
|||
|
47f770528a
|
|||
|
57e0a0e085
|
|||
|
f30617b943
|
|||
|
88f5921ea0
|
|||
|
12beb78131
|
|||
|
8f8b76df2c
|
|||
|
7e3bb55bab
|
|||
|
48f24c3a6b
|
|||
|
d804d6eb75
|
|||
|
4cdb8fbd26
|
|||
|
6f38ecb33d
|
|||
|
cec35f10d7
|
|||
|
7ef4372730
|
|||
|
337c86d35f
|
|||
|
fe7d11092c
|
|||
|
352f8c97ec
|
|||
|
558b12bdbd
|
|||
|
6279bc4952
|
|||
|
f61fa886d1
|
|||
|
378473e453
|
|||
|
e0b2575d32
|
|||
|
0c2467209d
|
|||
|
4be95428b5
|
|||
|
de2b04ca12
|
|||
|
037e493bcc
|
|||
|
89307d8d0c
|
|||
|
7565a643aa
|
|||
|
d4474421e5
|
|||
|
2684d9358e
|
|||
|
a37827761b
|
|||
|
a553124e33
|
|||
|
cded34cb8a
|
|||
|
71730362ef
|
|||
|
46edf5ea14
|
|||
|
4ab7fb0630
|
|||
| 9e10cf4fa4 | |||
| 7b15480c30 | |||
|
996fe387df
|
|||
|
fed2e0df07
|
|||
|
a109566738
|
|||
|
cbdfee63e4
|
|||
| 54bfef981d | |||
| 1d8c29e73f | |||
| 4b4b9f5b0d | |||
|
d08ea6240e
|
|||
|
ce86b95163
|
|||
|
296ef50a53
|
|||
|
d5dc438601
|
|||
|
62ee26b20f
|
|||
|
27ab68f6c5
|
|||
|
32f19ee16b
|
|||
| b2fe8c516d | |||
|
df6ed8ac11
|
|||
| 123f7e8e4f | |||
| 08435f3dbb | |||
| 2cbe2a07e1 | |||
| 9f99a2d507 | |||
| 187abbbbb4 | |||
| ab67e610d4 | |||
|
c6f971b39e
|
|||
|
fcb09303ec
|
|||
|
1c8365b11f
|
|||
|
bf6ca24eed
|
|||
| 65b0e30c75 | |||
|
8e2003b7df
|
|||
|
|
3ffea8b46b | ||
| f3bb1ff79a | |||
| 8883c8eda8 | |||
|
654034491a
|
|||
|
222a618591
|
|||
|
a1bfbb537a
|
|||
| e0e307e35f | |||
| e0564e150c | |||
|
4b401e878b
|
|||
|
4ad79f1445
|
|||
| ffcfbbfc3b | |||
| 8154394539 | |||
| e9ac435b40 | |||
| a5f18e9a4e | |||
| faa0537c35 | |||
| 3794ad5aae | |||
| f952aa8c9d | |||
| 42293719e4 | |||
| 7f05cd79d9 | |||
| 8035610111 | |||
| 145357f487 | |||
| c9a3943bca | |||
| 2b017c02d6 | |||
| d632201f65 | |||
| 3b3f39a8f6 | |||
| dd1ce61cd1 | |||
| a4a95a61e2 | |||
| 9b4df44564 | |||
| 247c670b2e | |||
| 00d6418c88 | |||
| a559f4c319 | |||
| 91ad1baafa | |||
| 8832ba89e4 | |||
| 7102ba5922 | |||
| c679af095d | |||
| 6f644b5236 | |||
| c85140ee8b | |||
| 63ac55d831 | |||
| a2ebfe5867 | |||
| 76074a46b8 | |||
| 3d1d7e2701 | |||
| 46bb3db995 | |||
| b6fb7e51b4 | |||
| f91ed1fba4 | |||
| dc63cbb563 | |||
| 87c3a3cea2 | |||
| 80b2f47403 | |||
| 9708fd6c2f | |||
| 1bc84f0fcb | |||
| beb542b834 | |||
| 8f599c776a | |||
| a352da2f3e | |||
| 2a7f582dd8 | |||
| 1e09ea7ec6 | |||
| c6e048177e | |||
| 331184a7fd | |||
| 00642835bd | |||
| bbe72f2fdf | |||
| c7c46060e8 | |||
| da969dad58 | |||
| dd368733d4 | |||
| 6bfc329666 | |||
| edfd739023 | |||
|
e2c05f3bf6
|
|||
|
fe4dcc4a92
|
|||
|
d5691c2bbb
|
|||
| 40376d2e6d | |||
| 507c9dc9ed | |||
| 4d178bc3f2 | |||
|
|
00f907c09c | ||
|
|
45573c48ae | ||
|
57a5b5b4b3
|
29
.gitea/workflows/build-docker-release.yml
Normal file
29
.gitea/workflows/build-docker-release.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: build-docker-release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
|
||||
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }},${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:latest
|
||||
secrets: |
|
||||
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
31
.gitea/workflows/build-docker.yml
Normal file
31
.gitea/workflows/build-docker.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: build-docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- staging
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
|
||||
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }}
|
||||
secrets: |
|
||||
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||
32
.gitea/workflows/safety-check.yml
Normal file
32
.gitea/workflows/safety-check.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: safety-check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
tags-ignore:
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Run safety check
|
||||
uses: pyupio/safety-action@v1
|
||||
with:
|
||||
api-key: ${{ secrets.SAFETY_API_KEY }}
|
||||
43
.gitea/workflows/test-uv.yml
Normal file
43
.gitea/workflows/test-uv.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: test-uv
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
tags-ignore:
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
jobs:
|
||||
test:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.11", "3.12", "3.13" ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: true
|
||||
env:
|
||||
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras --dev
|
||||
- name: Test with tox
|
||||
run: uv run tox
|
||||
- name: Build
|
||||
run: uv build
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Artifacts
|
||||
path: dist/*
|
||||
44
.gitea/workflows/test.yml
Normal file
44
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
tags-ignore:
|
||||
- v*
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
- dev
|
||||
jobs:
|
||||
test:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ "3.11", "3.12", "3.13" ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: './requirements/*'
|
||||
env:
|
||||
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox tox-gh-actions build
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
- name: Build
|
||||
run: python -m build
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Artifacts
|
||||
path: dist/*
|
||||
@@ -6,6 +6,12 @@
|
||||
"baseBranches": [
|
||||
"dev"
|
||||
],
|
||||
"pip_requirements": {
|
||||
"fileMatch": [
|
||||
"requirements/.*\\.txt$"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
# WARNING: Docker deployment is not officially supported (yet)
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Git is required because of git-based dependencies
|
||||
RUN apt update && apt install -y git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked && uv pip install -e .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
SHELL ["/bin/sh", "-c"]
|
||||
ENTRYPOINT uv run javelina-cli migrate && uv run uvicorn javelina.asgi:app --host 0.0.0.0 --port 8000
|
||||
35
Makefile
Normal file
35
Makefile
Normal file
@@ -0,0 +1,35 @@
|
||||
.PHONY: setup setup-uv update update-uv dev-setup dev-setup-uv dev-update dev-update-uv
|
||||
|
||||
setup:
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install .
|
||||
|
||||
setup-uv:
|
||||
uv venv
|
||||
uv sync
|
||||
|
||||
update:
|
||||
.venv/bin/pip install --upgrade .
|
||||
|
||||
update-uv:
|
||||
uv sync
|
||||
|
||||
build-docker:
|
||||
sudo docker build -t javelina .
|
||||
|
||||
dev-setup:
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install ."[dev]"
|
||||
|
||||
dev-setup-uv:
|
||||
uv venv
|
||||
uv sync --extra dev
|
||||
|
||||
dev-update:
|
||||
.venv/bin/pip install --upgrade ."[dev]"
|
||||
|
||||
dev-update-uv:
|
||||
uv sync --extra dev
|
||||
|
||||
dev-build-docker:
|
||||
sudo docker build -t javelina-dev .
|
||||
87
README.md
87
README.md
@@ -14,3 +14,90 @@
|
||||
<img alt="Discord" src="https://img.shields.io/discord/981251696208531466">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Installing the bot
|
||||
|
||||
<details open>
|
||||
|
||||
<summary>Using pip</summary>
|
||||
|
||||
```shell
|
||||
# Create a virtual environment
|
||||
python -m venv .venv
|
||||
|
||||
# Active the virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install the bot's package
|
||||
pip install --index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple/ --extra-index-url https://pypi.org/simple javelina
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using pipx</summary>
|
||||
|
||||
```shell
|
||||
# TODO
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Initializing the bot
|
||||
|
||||
<details open>
|
||||
|
||||
<summary>Using pip</summary>
|
||||
|
||||
```shell
|
||||
# Activate the virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Trigger the initialization
|
||||
javelina-cli init
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using pipx</summary>
|
||||
|
||||
```shell
|
||||
# TODO
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Starting the bot
|
||||
|
||||
<details open>
|
||||
|
||||
<summary>Using pip</summary>
|
||||
|
||||
```shell
|
||||
# Activate the virtual environment
|
||||
source .venv/bin/activate
|
||||
|
||||
# Start the application
|
||||
uvicorn javelina.asgi:app
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
<summary>Using pipx</summary>
|
||||
|
||||
```shell
|
||||
# TODO
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Running in Docker [Experimental]
|
||||
|
||||
```shell
|
||||
docker run -d --name javelina -p 8000:8000 -v /path/to/config.json:/app/config.json:ro git.end-play.xyz/javelina/javelina:latest
|
||||
```
|
||||
@@ -1,3 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,8 +0,0 @@
|
||||
from pathlib import Path
|
||||
from api.app import app
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
|
||||
@app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse(Path("api/assets/favicon.ico"))
|
||||
@@ -1,45 +0,0 @@
|
||||
from typing import Any, Union
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from discord import User
|
||||
from libbot.pycord.classes import PycordBot as LibPycordBot
|
||||
|
||||
from classes.pycorduser import PycordUser
|
||||
from modules.tracking.dhl import update_tracks_dhl
|
||||
|
||||
|
||||
class PycordBot(LibPycordBot):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.client_session = ClientSession()
|
||||
|
||||
# Scheduler job for DHL parcel tracking
|
||||
self.scheduler.add_job(
|
||||
update_tracks_dhl,
|
||||
trigger="cron",
|
||||
hour=self.config["modules"]["tracking"]["fetch_hours"],
|
||||
args=[self, self.client_session],
|
||||
)
|
||||
|
||||
async def find_user(self, user: Union[int, User]) -> PycordUser:
|
||||
"""Find User by it's ID or User object.
|
||||
|
||||
### Args:
|
||||
* user (`Union[int, User]`): ID or User object to extract ID from.
|
||||
|
||||
### Returns:
|
||||
* `PycordUser`: User in database representation.
|
||||
"""
|
||||
|
||||
return (
|
||||
await PycordUser.find(user)
|
||||
if isinstance(user, int)
|
||||
else await PycordUser.find(user.id)
|
||||
)
|
||||
|
||||
async def close(self, *args: Any, **kwargs: Any) -> None:
|
||||
await self.client_session.close()
|
||||
self.scheduler.shutdown()
|
||||
|
||||
await super().close(*args, **kwargs)
|
||||
@@ -1,42 +0,0 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from modules.database import col_users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PycordUser:
|
||||
"""Dataclass of DB entry of a user"""
|
||||
|
||||
__slots__ = ("_id", "id")
|
||||
|
||||
_id: ObjectId
|
||||
id: int
|
||||
|
||||
@classmethod
|
||||
async def find(cls, id: int):
|
||||
"""Find user in database and create new record if user does not exist.
|
||||
|
||||
### Args:
|
||||
* id (`int`): User's Discord ID
|
||||
|
||||
### Raises:
|
||||
* `RuntimeError`: Raised when user entry after insertion could not be found.
|
||||
|
||||
### Returns:
|
||||
* `PycordUser`: User with its database data.
|
||||
"""
|
||||
db_entry = await col_users.find_one({"id": id})
|
||||
|
||||
if db_entry is None:
|
||||
inserted = await col_users.insert_one({"id": id})
|
||||
db_entry = await col_users.find_one({"_id": inserted.inserted_id})
|
||||
|
||||
if db_entry is None:
|
||||
raise RuntimeError("Could not find inserted user entry.")
|
||||
|
||||
return cls(**db_entry)
|
||||
38
main.py
38
main.py
@@ -1,38 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from os import getpid
|
||||
|
||||
from libbot import sync
|
||||
|
||||
from classes.pycordbot import PycordBot
|
||||
from modules.extensions_loader import dynamic_import_from_src
|
||||
from modules.scheduler import scheduler
|
||||
|
||||
# Import required for uvicorn
|
||||
from api.app import app
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if sync.config_get("debug") else logging.INFO,
|
||||
format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
|
||||
datefmt="[%X]",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
bot = PycordBot(scheduler=scheduler)
|
||||
|
||||
bot.load_extension("cogs")
|
||||
|
||||
# Import API modules
|
||||
dynamic_import_from_src("api.extensions", star_import=True)
|
||||
|
||||
try:
|
||||
await bot.start(sync.config_get("bot_token", "bot"))
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Forcefully shutting down with PID %s...", getpid())
|
||||
await bot.close()
|
||||
|
||||
|
||||
asyncio.create_task(main())
|
||||
@@ -1,4 +0,0 @@
|
||||
from modules.migrator import migrate_database
|
||||
|
||||
|
||||
migrate_database()
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Module that provides all database collections"""
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
|
||||
from libbot.sync import config_get
|
||||
|
||||
db_config: Mapping[str, Any] = config_get("database")
|
||||
|
||||
if db_config["user"] is not None and db_config["password"] is not None:
|
||||
con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format(
|
||||
db_config["user"],
|
||||
db_config["password"],
|
||||
db_config["host"],
|
||||
db_config["port"],
|
||||
db_config["name"],
|
||||
)
|
||||
else:
|
||||
con_string = "mongodb://{0}:{1}/{2}".format(
|
||||
db_config["host"], db_config["port"], db_config["name"]
|
||||
)
|
||||
|
||||
db_client = AsyncClient(con_string)
|
||||
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
|
||||
|
||||
col_users: AsyncCollection = db.get_collection("users")
|
||||
col_warnings: AsyncCollection = db.get_collection("warnings")
|
||||
col_checkouts: AsyncCollection = db.get_collection("checkouts")
|
||||
col_trackings: AsyncCollection = db.get_collection("trackings")
|
||||
col_authorized: AsyncCollection = db.get_collection("authorized")
|
||||
col_transactions: AsyncCollection = db.get_collection("transactions")
|
||||
@@ -1,76 +0,0 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
import logging
|
||||
from os import getcwd, walk
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import List, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Import functions
|
||||
# Took from https://stackoverflow.com/a/57892961
|
||||
def get_py_files(src: Union[str, Path]) -> List[str]:
|
||||
cwd = getcwd() # Current Working directory
|
||||
py_files = []
|
||||
|
||||
for root, dirs, files in walk(src):
|
||||
py_files.extend(
|
||||
Path(f"{cwd}/{root}/{file}") for file in files if file.endswith(".py")
|
||||
)
|
||||
|
||||
return py_files
|
||||
|
||||
|
||||
def dynamic_import(module_name: str, py_path: str) -> Union[ModuleType, None]:
|
||||
try:
|
||||
module_spec = spec_from_file_location(module_name, py_path)
|
||||
|
||||
if module_spec is None:
|
||||
raise RuntimeError(
|
||||
f"Module spec from module name {module_name} and path {py_path} is None"
|
||||
)
|
||||
|
||||
module = module_from_spec(module_spec)
|
||||
|
||||
if module_spec.loader is None:
|
||||
logger.warning(
|
||||
"Could not load extension %s due to spec loader being None.",
|
||||
module_name,
|
||||
)
|
||||
return
|
||||
|
||||
module_spec.loader.exec_module(module)
|
||||
|
||||
return module
|
||||
except SyntaxError:
|
||||
logger.warning(
|
||||
"Could not load extension %s due to invalid syntax. Check logs/errors.log for details.",
|
||||
module_name,
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Could not load extension %s due to %s", module_name, exc)
|
||||
return
|
||||
|
||||
|
||||
def dynamic_import_from_src(src: Union[str, Path], star_import=False) -> None:
|
||||
my_py_files = get_py_files(src)
|
||||
|
||||
for py_file in my_py_files:
|
||||
module_name = Path(py_file).stem
|
||||
|
||||
logger.debug("Importing %s extension...", module_name)
|
||||
|
||||
imported_module = dynamic_import(module_name, py_file)
|
||||
|
||||
if imported_module != None:
|
||||
if star_import:
|
||||
for obj in dir(imported_module):
|
||||
globals()[obj] = imported_module.__dict__[obj]
|
||||
else:
|
||||
globals()[module_name] = imported_module
|
||||
|
||||
logger.info("Successfully loaded %s extension", module_name)
|
||||
|
||||
return
|
||||
64
pyproject.toml
Normal file
64
pyproject.toml
Normal file
@@ -0,0 +1,64 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=77.0.3,<80.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "javelina"
|
||||
dynamic = ["version", "dependencies", "optional-dependencies"]
|
||||
description = "Discord bot that manages the server and provides an additional RESTful API"
|
||||
readme = "README.md"
|
||||
license = "AGPL-3.0"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
authors = [
|
||||
{ name = "Profitroll", email = "profitroll@end-play.xyz" }
|
||||
]
|
||||
|
||||
maintainers = [
|
||||
{ name = "Profitroll", email = "profitroll@end-play.xyz" }
|
||||
]
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
javelina-cli = "javelina.cli:main"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "javelina.__version__" }
|
||||
dependencies = { file = "requirements/_.txt" }
|
||||
|
||||
[tool.setuptools.dynamic.optional-dependencies]
|
||||
dev = { file = "requirements/dev.txt" }
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
javelina = ["resources/*", "locale/*"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 96
|
||||
target-version = ["py311", "py312", "py313"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.mypy]
|
||||
namespace_packages = true
|
||||
install_types = true
|
||||
strict = true
|
||||
show_error_codes = true
|
||||
|
||||
[tool.pylint]
|
||||
disable = ["line-too-long"]
|
||||
|
||||
[tool.pylint.main]
|
||||
extension-pkg-whitelist = ["ujson"]
|
||||
py-version = 3.11
|
||||
@@ -1,12 +0,0 @@
|
||||
aiohttp>=3.6.0
|
||||
apscheduler~=3.10.4
|
||||
colorthief==0.2.1
|
||||
deepl==1.16.1
|
||||
fastapi[all]~=0.109.1
|
||||
mongodb-migrations==1.3.0
|
||||
pynacl~=1.5.0
|
||||
pyrmv==0.3.5
|
||||
pytz~=2024.1
|
||||
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
|
||||
async_pymongo==0.1.4
|
||||
libbot[speed,pycord]==3.0.0
|
||||
17
requirements/_.txt
Normal file
17
requirements/_.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
aiohttp>=3.11.18
|
||||
apscheduler~=3.11.2
|
||||
eventapi @ git+https://git.end-play.xyz/Javelina/eventapi.git@master
|
||||
fastapi[all]~=0.128.0,<0.129.0
|
||||
fastapi_discord @ git+https://github.com/Tert0/fastapi-discord@master
|
||||
libbot[speed,pycord,cache]==4.4.1
|
||||
mongodb-migrations==1.3.1
|
||||
pynacl~=1.6.0
|
||||
pytz~=2025.1
|
||||
tempora~=5.8.1
|
||||
typer~=0.24.0
|
||||
|
||||
# Temporarily disabled because
|
||||
# these are still unused for now
|
||||
# colorthief==0.2.1
|
||||
# deepl==1.22.0
|
||||
# pyrmv==0.5.0
|
||||
5
requirements/dev.txt
Normal file
5
requirements/dev.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
black==26.1.0
|
||||
isort==8.0.0
|
||||
mypy==1.19.1
|
||||
pylint==4.0.5
|
||||
tox==4.44.0
|
||||
8
src/javelina/__init__.py
Normal file
8
src/javelina/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import cli
|
||||
from ._bot import app, main
|
||||
|
||||
__author__ = "Profitroll"
|
||||
__version__ = "0.1.0"
|
||||
__maintainer__ = "Profitroll"
|
||||
__email__ = "profitroll@end-play.xyz"
|
||||
__status__ = "Development"
|
||||
97
src/javelina/_bot.py
Normal file
97
src/javelina/_bot.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import contextlib
|
||||
import logging.config
|
||||
import sys
|
||||
from asyncio import get_event_loop
|
||||
from logging import Logger
|
||||
from os import getpid, makedirs
|
||||
from pathlib import Path
|
||||
from sys import exit
|
||||
|
||||
from discord import LoginFailure
|
||||
from eventapi import EventApi
|
||||
from libbot.utils import config_get
|
||||
|
||||
# Import required for uvicorn
|
||||
from .api.app import app # noqa
|
||||
from .classes.pycord_bot import PycordBot
|
||||
from .integrations.seven_tv import EventHandler7TV
|
||||
from .modules.scheduler import scheduler
|
||||
from .modules.utils import get_logger, get_logging_config
|
||||
|
||||
makedirs(Path("logs/"), exist_ok=True)
|
||||
|
||||
logging.config.dictConfig(get_logging_config())
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
# Try to import the module that improves performance
|
||||
# and ignore errors when module is not installed
|
||||
with contextlib.suppress(ImportError):
|
||||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
bot: PycordBot = PycordBot(scheduler=scheduler)
|
||||
|
||||
if config_get("enabled", "modules", "7tv"):
|
||||
event_handler: EventHandler7TV = EventHandler7TV(bot, app)
|
||||
|
||||
try:
|
||||
bot.load_cogs()
|
||||
bot.load_integration_cogs()
|
||||
bot.load_plugins()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"An unexpected error has occurred while loading cogs: %s", exc, exc_info=exc
|
||||
)
|
||||
exit(1)
|
||||
|
||||
app.set_bot(bot)
|
||||
|
||||
if bot.is_integration_enabled("7tv") and bot.cogs.get("Cog7TV") is not None:
|
||||
bot.cogs["Cog7TV"].set_event_handler(event_handler)
|
||||
bot.cogs["Cog7TV"].set_event_api(EventApi(callback=event_handler.handle_event))
|
||||
|
||||
async def shutdown_cog_7tv() -> None:
|
||||
if bot.is_integration_enabled("7tv") and (
|
||||
bot.cogs.get("Cog7TV") is not None
|
||||
and bot.cogs["Cog7TV"].emotes_event_api is not None
|
||||
and not bot.cogs["Cog7TV"].emotes_event_api.closed
|
||||
):
|
||||
logger.debug("Closing the 7TV integration connection...")
|
||||
await bot.cogs["Cog7TV"].emotes_event_api.close()
|
||||
logger.debug("7TV integration closed.")
|
||||
|
||||
async def close_bot() -> None:
|
||||
if not bot.is_closed():
|
||||
logger.debug("Closing the bot...")
|
||||
await bot.close()
|
||||
logger.debug("Bot closed.")
|
||||
|
||||
try:
|
||||
await bot.start(config_get("bot_token", "bot"))
|
||||
except LoginFailure as exc:
|
||||
logger.error("Provided bot token is invalid: %s", exc)
|
||||
await shutdown_cog_7tv()
|
||||
sys.exit(1)
|
||||
# FIXME This block will not be reached because uvicorn handles SIGTERM instead
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Shutting down with PID %s...", getpid())
|
||||
except Exception as exc:
|
||||
logger.error("An unhandled exception was raised: %s", exc, exc_info=exc)
|
||||
await shutdown_cog_7tv()
|
||||
await close_bot()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await shutdown_cog_7tv()
|
||||
await close_bot()
|
||||
|
||||
logger.debug("Shutdown complete.")
|
||||
|
||||
|
||||
# Should not be called anymore I guess...
|
||||
if __name__ == "__main__":
|
||||
event_loop = get_event_loop()
|
||||
event_loop.run_until_complete(main())
|
||||
54
src/javelina/api/app.py
Normal file
54
src/javelina/api/app.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from logging import Logger
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from fastapi_discord import DiscordOAuthClient, RateLimited, Unauthorized
|
||||
from fastapi_discord.exceptions import ClientSessionNotInitialized
|
||||
from libbot.utils import config_get
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ..classes.fastapi import FastAPI
|
||||
from ..modules.utils import get_logger
|
||||
from ..modules.utils.router_loader import run_router_setups
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
discord_oauth: DiscordOAuthClient = DiscordOAuthClient(
|
||||
config_get("client_id", "api", "oauth"),
|
||||
config_get("client_secret", "api", "oauth"),
|
||||
urljoin(config_get("public_url", "api"), "/v1/callback"),
|
||||
("identify", "guilds"),
|
||||
)
|
||||
|
||||
# TODO Add an integration for the contact information
|
||||
app: FastAPI = FastAPI(
|
||||
title="Javelina",
|
||||
version="0.0.1",
|
||||
)
|
||||
|
||||
run_router_setups(app, "javelina.api.routers")
|
||||
|
||||
|
||||
# TODO Replace this with a FastAPI lifespan
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await discord_oauth.init()
|
||||
|
||||
|
||||
@app.exception_handler(Unauthorized)
|
||||
async def unauthorized_error_handler(_, __) -> JSONResponse:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
|
||||
@app.exception_handler(RateLimited)
|
||||
async def rate_limit_error_handler(_, exc: RateLimited) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"error": "RateLimited", "retry": exc.retry_after, "message": exc.message},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(ClientSessionNotInitialized)
|
||||
async def client_session_error_handler(_, exc: ClientSessionNotInitialized) -> JSONResponse:
|
||||
logger.error("Client session was not initialized: %s", exc, exc_info=exc)
|
||||
|
||||
return JSONResponse({"error": "Internal Error"}, status_code=500)
|
||||
1
src/javelina/api/routers/__init__.py
Normal file
1
src/javelina/api/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import admin, auth, health, user
|
||||
1
src/javelina/api/routers/admin/__init__.py
Normal file
1
src/javelina/api/routers/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import guilds, users, wallets
|
||||
42
src/javelina/api/routers/admin/guilds.py
Normal file
42
src/javelina/api/routers/admin/guilds.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
from ....classes import PycordGuild
|
||||
from ....classes.errors import GuildNotFoundError
|
||||
from ....classes.fastapi import FastAPI
|
||||
from ....modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(prefix="/v1/admin/guilds", tags=["Admin - Guilds"])
|
||||
|
||||
|
||||
# TODO Implement this method
|
||||
@router_v1.get("/", response_class=JSONResponse)
|
||||
async def get_guilds_v1() -> JSONResponse:
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented")
|
||||
|
||||
|
||||
@router_v1.get("/{guild_id}", response_class=JSONResponse)
|
||||
async def get_guild_v1(request: Request, guild_id: int) -> JSONResponse:
|
||||
try:
|
||||
guild: PycordGuild = await PycordGuild.from_id(
|
||||
guild_id, allow_creation=False, cache=request.app.bot.cache
|
||||
)
|
||||
except GuildNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Guild not found"
|
||||
) from exc
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
|
||||
) from exc
|
||||
|
||||
return JSONResponse(guild.to_dict(json_compatible=True))
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
34
src/javelina/api/routers/admin/users.py
Normal file
34
src/javelina/api/routers/admin/users.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
from ....classes import PycordUser
|
||||
from ....classes.errors import UserNotFoundError
|
||||
from ....classes.fastapi import FastAPI
|
||||
from ....modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(
|
||||
prefix="/v1/admin/guilds/{guild_id}/users", tags=["Admin - Users"]
|
||||
)
|
||||
|
||||
|
||||
@router_v1.get("/{user_id}", response_class=JSONResponse)
|
||||
async def get_guild_user_v1(request: Request, guild_id: int, user_id: int) -> JSONResponse:
|
||||
try:
|
||||
user: PycordUser = await PycordUser.from_id(
|
||||
user_id, guild_id, allow_creation=False, cache=request.app.bot.cache
|
||||
)
|
||||
except UserNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
) from exc
|
||||
|
||||
return JSONResponse(user.to_dict(json_compatible=True))
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
34
src/javelina/api/routers/admin/wallets.py
Normal file
34
src/javelina/api/routers/admin/wallets.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
from ....classes.errors import WalletNotFoundError
|
||||
from ....classes.fastapi import FastAPI
|
||||
from ....classes.wallet import Wallet
|
||||
from ....modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(
|
||||
prefix="/v1/admin/guilds/{guild_id}/wallets", tags=["Admin - Wallets"]
|
||||
)
|
||||
|
||||
|
||||
@router_v1.get("/{user_id}", response_class=JSONResponse)
|
||||
async def get_guild_wallet_v1(request: Request, guild_id: int, user_id: int) -> JSONResponse:
|
||||
try:
|
||||
wallet: Wallet = await Wallet.from_id(
|
||||
user_id, guild_id, allow_creation=False, cache=request.app.bot.cache
|
||||
)
|
||||
except WalletNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Wallet not found"
|
||||
) from exc
|
||||
|
||||
return JSONResponse(wallet.to_dict(json_compatible=True))
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
27
src/javelina/api/routers/auth.py
Normal file
27
src/javelina/api/routers/auth.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import APIRouter
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ...classes.fastapi import FastAPI
|
||||
from ...modules.utils import get_logger
|
||||
from ..app import discord_oauth
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["Auth"])
|
||||
|
||||
|
||||
@router_v1.get("/login", response_class=JSONResponse)
|
||||
async def login() -> JSONResponse:
|
||||
return JSONResponse({"url": discord_oauth.oauth_login_url})
|
||||
|
||||
|
||||
@router_v1.get("/callback", response_class=JSONResponse)
|
||||
async def callback(code: str) -> JSONResponse:
|
||||
token, refresh_token = await discord_oauth.get_access_token(code)
|
||||
return JSONResponse({"access_token": token, "refresh_token": refresh_token})
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
37
src/javelina/api/routers/health.py
Normal file
37
src/javelina/api/routers/health.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime, timedelta
|
||||
from logging import Logger
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ...classes import ApplicationHealth
|
||||
from ...classes.fastapi import FastAPI
|
||||
from ...modules.database import db
|
||||
from ...modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["User"])
|
||||
|
||||
|
||||
@router_v1.get("/status", response_class=JSONResponse)
|
||||
async def get_status_v1() -> JSONResponse:
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
@router_v1.get("/health", response_class=JSONResponse)
|
||||
async def get_health_v1(request: Request, detailed: Optional[bool] = False) -> JSONResponse:
|
||||
if request.app.status is None or request.app.status.get_last_update() < (
|
||||
datetime.now(tz=ZoneInfo("UTC")) - timedelta(seconds=30)
|
||||
):
|
||||
request.app.update_status(await ApplicationHealth.from_data(request.app, db))
|
||||
|
||||
health: ApplicationHealth = request.app.status
|
||||
|
||||
return JSONResponse(health.to_json(detailed=detailed if detailed is not None else False))
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
27
src/javelina/api/routers/user.py
Normal file
27
src/javelina/api/routers/user.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from logging import Logger
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi_discord import User
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from ...classes.fastapi import FastAPI
|
||||
from ...modules.utils import get_logger
|
||||
from ..app import discord_oauth
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["User"])
|
||||
|
||||
|
||||
@router_v1.get(
|
||||
"/me",
|
||||
dependencies=[Depends(discord_oauth.requires_authorization)],
|
||||
response_model=User,
|
||||
response_class=JSONResponse,
|
||||
)
|
||||
async def get_me_v1(user: User = Depends(discord_oauth.user)) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def setup(app: FastAPI) -> None:
|
||||
app.include_router(router_v1)
|
||||
5
src/javelina/asgi.py
Normal file
5
src/javelina/asgi.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
from ._bot import app, main # noqa
|
||||
|
||||
asyncio.create_task(main())
|
||||
11
src/javelina/classes/__init__.py
Normal file
11
src/javelina/classes/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .application_health import ApplicationHealth
|
||||
from .consent import Consent
|
||||
from .guild_rules import GuildRules
|
||||
from .pycord_guild import PycordGuild
|
||||
from .pycord_guild_emote import PycordGuildEmote
|
||||
from .pycord_user import PycordUser
|
||||
from .scheduled_action import ScheduledAction
|
||||
from .service_status import ServiceStatus
|
||||
|
||||
# from .pycord_guild_colors import PycordGuildColors
|
||||
# from .wallet import Wallet
|
||||
1
src/javelina/classes/abstract/__init__.py
Normal file
1
src/javelina/classes/abstract/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .cacheable import Cacheable
|
||||
81
src/javelina/classes/abstract/cacheable.py
Normal file
81
src/javelina/classes/abstract/cacheable.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo.asynchronous.collection import AsyncCollection
|
||||
|
||||
|
||||
class Cacheable(ABC):
|
||||
"""Abstract class for cacheable"""
|
||||
|
||||
__short_name__: str
|
||||
__collection__: ClassVar[AsyncCollection]
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def from_id(cls, *args: Any, cache: Optional[Cache] = None, **kwargs: Any) -> Any:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _get_cache_key(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _update_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_defaults(**kwargs: Any) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def reset(
|
||||
self,
|
||||
*args: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def purge(self, cache: Optional[Cache] = None) -> None:
|
||||
pass
|
||||
157
src/javelina/classes/application_health.py
Normal file
157
src/javelina/classes/application_health.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from libbot.cache.classes import Cache
|
||||
from libbot.pycord.classes import PycordBot
|
||||
from pymongo.asynchronous.database import AsyncDatabase
|
||||
from pymongo.errors import ConnectionFailure
|
||||
|
||||
from ..classes.fastapi import FastAPI
|
||||
from ..classes.service_status import ServiceStatus
|
||||
from ..enums import HealthStatus
|
||||
from ..modules.database import db_client
|
||||
from ..modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplicationHealth:
|
||||
_last_update: datetime
|
||||
|
||||
api: ServiceStatus
|
||||
bot: ServiceStatus
|
||||
cache: ServiceStatus
|
||||
database: ServiceStatus
|
||||
|
||||
@classmethod
|
||||
async def from_data(cls, app: FastAPI, database: AsyncDatabase) -> "ApplicationHealth":
|
||||
database_health: ServiceStatus = await ApplicationHealth.get_database_health(database)
|
||||
cache_health: ServiceStatus = ApplicationHealth.get_cache_health(app.bot.cache)
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"bot": ApplicationHealth.get_bot_health(app.bot, cache_health, database_health),
|
||||
"cache": cache_health,
|
||||
"database": database_health,
|
||||
}
|
||||
|
||||
data["api"] = ApplicationHealth.get_api_health(
|
||||
data["bot"], data["cache"], database_health
|
||||
)
|
||||
|
||||
data["_last_update"] = datetime.now(tz=ZoneInfo("UTC"))
|
||||
|
||||
return cls(**data)
|
||||
|
||||
def update(self, app: FastAPI, database: AsyncDatabase) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
# TODO Fix the message
|
||||
@staticmethod
|
||||
def get_bot_health(
|
||||
bot: PycordBot, cache_status: ServiceStatus, database_status: ServiceStatus
|
||||
) -> ServiceStatus:
|
||||
if not bot.is_ready():
|
||||
return ServiceStatus(HealthStatus.FAILED, "discord connection has failed")
|
||||
|
||||
if database_status.status != HealthStatus.OPERATIONAL:
|
||||
match database_status.status:
|
||||
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
|
||||
return ServiceStatus(HealthStatus.FAILED, "database connection has failed")
|
||||
case HealthStatus.DEGRADED:
|
||||
return ServiceStatus(
|
||||
HealthStatus.DEGRADED, "database connection is degraded"
|
||||
)
|
||||
|
||||
if cache_status.status not in [HealthStatus.UNKNOWN, HealthStatus.OPERATIONAL]:
|
||||
match cache_status.status:
|
||||
case HealthStatus.FAILED:
|
||||
return ServiceStatus(HealthStatus.DEGRADED, "cache connection has failed")
|
||||
case HealthStatus.DEGRADED:
|
||||
return ServiceStatus(HealthStatus.DEGRADED, "cache is degraded")
|
||||
|
||||
return ServiceStatus(HealthStatus.OPERATIONAL, None)
|
||||
|
||||
# TODO Fix the message
|
||||
# TODO Implement this method
|
||||
@staticmethod
|
||||
def get_cache_health(cache: Cache) -> ServiceStatus:
|
||||
return ServiceStatus(
|
||||
HealthStatus.UNKNOWN,
|
||||
None,
|
||||
)
|
||||
|
||||
# TODO Fix the message
|
||||
@staticmethod
|
||||
async def get_database_health(database: AsyncDatabase) -> ServiceStatus:
|
||||
try:
|
||||
await db_client.admin.command("ping")
|
||||
except ConnectionFailure as exc:
|
||||
return ServiceStatus(HealthStatus.FAILED, str(exc))
|
||||
|
||||
return ServiceStatus(
|
||||
HealthStatus.OPERATIONAL,
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_api_health(
|
||||
bot_status: ServiceStatus,
|
||||
cache_status: ServiceStatus,
|
||||
database_status: ServiceStatus,
|
||||
) -> ServiceStatus:
|
||||
if database_status.status != HealthStatus.OPERATIONAL:
|
||||
match database_status.status:
|
||||
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
|
||||
return ServiceStatus(
|
||||
HealthStatus.FAILED,
|
||||
"database connection has failed",
|
||||
)
|
||||
case HealthStatus.DEGRADED:
|
||||
return ServiceStatus(
|
||||
HealthStatus.DEGRADED,
|
||||
"database connection is degraded",
|
||||
)
|
||||
|
||||
if bot_status.status != HealthStatus.OPERATIONAL:
|
||||
match bot_status.status:
|
||||
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
|
||||
return ServiceStatus(
|
||||
HealthStatus.DEGRADED,
|
||||
"bot integration has failed",
|
||||
)
|
||||
case HealthStatus.DEGRADED:
|
||||
return ServiceStatus(
|
||||
HealthStatus.DEGRADED,
|
||||
"bot integration is degraded",
|
||||
)
|
||||
|
||||
if cache_status.status not in [HealthStatus.OPERATIONAL, HealthStatus.UNKNOWN]:
|
||||
match cache_status.status:
|
||||
case HealthStatus.FAILED:
|
||||
return ServiceStatus(HealthStatus.DEGRADED, "cache connection has failed")
|
||||
case HealthStatus.DEGRADED:
|
||||
return ServiceStatus(HealthStatus.DEGRADED, "cache is degraded")
|
||||
|
||||
return ServiceStatus(
|
||||
HealthStatus.OPERATIONAL,
|
||||
None,
|
||||
)
|
||||
|
||||
def get_last_update(self) -> datetime:
|
||||
return self._last_update
|
||||
|
||||
def to_json(self, detailed: Optional[bool] = False) -> Dict[str, Dict[str, str | None]]:
|
||||
output: Dict[str, Any] = {
|
||||
"api": self.api.to_json(detailed),
|
||||
"bot": self.bot.to_json(detailed),
|
||||
}
|
||||
|
||||
if detailed:
|
||||
output["cache"] = self.cache.to_json(detailed)
|
||||
output["database"] = self.database.to_json(detailed)
|
||||
|
||||
return output
|
||||
1
src/javelina/classes/base/__init__.py
Normal file
1
src/javelina/classes/base/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .base_cacheable import BaseCacheable
|
||||
110
src/javelina/classes/base/base_cacheable.py
Normal file
110
src/javelina/classes/base/base_cacheable.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from abc import ABC
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
|
||||
from ...classes.abstract import Cacheable
|
||||
from ...modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseCacheable(Cacheable, ABC):
|
||||
"""Base implementation of Cacheable used by all cachable classes."""
|
||||
|
||||
_id: ObjectId
|
||||
|
||||
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
|
||||
for key, value in kwargs.items():
|
||||
if not hasattr(self, key):
|
||||
raise AttributeError()
|
||||
|
||||
setattr(self, key, value)
|
||||
|
||||
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs})
|
||||
|
||||
self._update_cache(cache)
|
||||
|
||||
logger.info("Set attributes of %s to %s", self._id, kwargs)
|
||||
|
||||
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
|
||||
attributes: Dict[str, Any] = {}
|
||||
|
||||
for key in args:
|
||||
if not hasattr(self, key):
|
||||
raise AttributeError()
|
||||
|
||||
default_value: Any = self.get_default_value(key)
|
||||
|
||||
setattr(self, key, default_value)
|
||||
|
||||
attributes[key] = default_value
|
||||
|
||||
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes})
|
||||
|
||||
self._update_cache(cache)
|
||||
|
||||
logger.info("Reset attributes %s of %s to default values", args, self._id)
|
||||
|
||||
def _update_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
if cache is None:
|
||||
return
|
||||
|
||||
object_dict: Dict[str, Any] = self.to_dict(json_compatible=True)
|
||||
|
||||
if object_dict is not None:
|
||||
cache.set_json(self._get_cache_key(), object_dict)
|
||||
else:
|
||||
self._delete_cache(cache)
|
||||
|
||||
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
if cache is None:
|
||||
return
|
||||
|
||||
cache.delete(self._get_cache_key())
|
||||
|
||||
async def update(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Update attribute(s) on the object and save the updated entry into the database.
|
||||
|
||||
Args:
|
||||
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
|
||||
**kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update.
|
||||
|
||||
Raises:
|
||||
AttributeError: Provided attribute does not exist in the class.
|
||||
"""
|
||||
await self._set(cache=cache, **kwargs)
|
||||
|
||||
async def reset(
|
||||
self,
|
||||
*args: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
"""Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database.
|
||||
|
||||
Args:
|
||||
*args (str): List of attributes to remove.
|
||||
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
|
||||
|
||||
Raises:
|
||||
AttributeError: Provided attribute does not exist in the class.
|
||||
"""
|
||||
await self._remove(*args, cache=cache)
|
||||
|
||||
async def purge(self, cache: Optional[Cache] = None) -> None:
|
||||
"""Completely remove object data from database. Currently only removes the record from a respective collection.
|
||||
|
||||
Args:
|
||||
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
|
||||
"""
|
||||
await self.__collection__.delete_one({"_id": self._id})
|
||||
|
||||
self._delete_cache(cache)
|
||||
|
||||
logger.info("Purged %s from the database", self._id)
|
||||
247
src/javelina/classes/consent.py
Normal file
247
src/javelina/classes/consent.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo import DESCENDING
|
||||
from pymongo.results import InsertOneResult, UpdateResult
|
||||
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..enums import ConsentScope
|
||||
from ..modules.database import col_consents
|
||||
from ..modules.utils import restore_from_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class Consent(BaseCacheable):
|
||||
"""Dataclass of DB entry of a consent entry"""
|
||||
|
||||
__slots__ = (
|
||||
"_id",
|
||||
"user_id",
|
||||
"guild_id",
|
||||
"scope",
|
||||
"consent_date",
|
||||
"expiration_date",
|
||||
"withdrawal_date",
|
||||
)
|
||||
__short_name__ = "consent"
|
||||
__collection__ = col_consents
|
||||
|
||||
_id: ObjectId
|
||||
user_id: int
|
||||
guild_id: int
|
||||
scope: ConsentScope
|
||||
consent_date: datetime
|
||||
expiration_date: datetime | None
|
||||
withdrawal_date: datetime | None
|
||||
|
||||
@staticmethod
|
||||
def get_cache_key(user_id: int, guild_id: int, scope: ConsentScope) -> str:
|
||||
return f"{Consent.__short_name__}_{user_id}_{guild_id}_{scope.value}"
|
||||
|
||||
# TODO Implement this method
|
||||
@classmethod
|
||||
async def from_id(cls, id: ObjectId, cache: Optional[Cache] = None, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError()
|
||||
|
||||
# TODO Add documentation
|
||||
@classmethod
|
||||
async def from_combination(
|
||||
cls,
|
||||
user_id: int,
|
||||
guild_id: int,
|
||||
scope: ConsentScope,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> Any:
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__,
|
||||
Consent.get_cache_key(user_id, guild_id, scope),
|
||||
cache=cache,
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
|
||||
{"user_id": user_id, "guild_id": guild_id, "scope": scope.value},
|
||||
sort={"expiration_date": DESCENDING},
|
||||
)
|
||||
|
||||
if db_entry is None:
|
||||
# TODO Implement a unique exception
|
||||
# raise ConsentNotFoundError(user_id, guild_id, scope)
|
||||
raise RuntimeError(
|
||||
f"Could not find a consent of user {user_id} from {guild_id} for scope '{scope.value}'"
|
||||
)
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(
|
||||
Consent.get_cache_key(user_id, guild_id, scope),
|
||||
cls._entry_to_cache(db_entry),
|
||||
)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, db_entry: Dict[str, Any]) -> "Consent":
|
||||
db_entry["scope"] = ConsentScope(db_entry["scope"])
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
# TODO Add documentation
|
||||
@classmethod
|
||||
async def give(
|
||||
cls,
|
||||
user_id: int,
|
||||
guild_id: int,
|
||||
scope: ConsentScope,
|
||||
expiration_date: Optional[datetime] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> Any:
|
||||
await cls.withdraw_scope_consents(user_id, guild_id, scope)
|
||||
|
||||
db_entry = Consent.get_defaults(user_id, guild_id, scope)
|
||||
|
||||
db_entry["scope"] = scope.value
|
||||
db_entry["expiration_date"] = expiration_date
|
||||
|
||||
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
|
||||
|
||||
db_entry["scope"] = scope
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(
|
||||
Consent.get_cache_key(user_id, guild_id, scope),
|
||||
cls._entry_to_cache(db_entry),
|
||||
)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
@staticmethod
|
||||
async def withdraw_scope_consents(
|
||||
user_id: int, guild_id: int, scope: ConsentScope
|
||||
) -> UpdateResult:
|
||||
"""Look up consents of a user in a guild with a specified scope and withdraw them.
|
||||
|
||||
Args:
|
||||
user_id (int): Discord ID of a user.
|
||||
guild_id (int): Discord ID of a guild.
|
||||
scope (:obj:ConsentScope): Scope to look for.
|
||||
|
||||
Returns:
|
||||
UpdateResult: Result object of all affected consents.
|
||||
"""
|
||||
return await Consent.__collection__.update_many(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"guild_id": guild_id,
|
||||
"scope": scope.value,
|
||||
"withdrawal_date": None,
|
||||
"$or": [
|
||||
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
{"expiration_date": None},
|
||||
],
|
||||
},
|
||||
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return self.get_cache_key(self.user_id, self.guild_id, self.scope)
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cache_entry: Dict[str, Any] = db_entry.copy()
|
||||
|
||||
cache_entry["_id"] = str(cache_entry["_id"])
|
||||
cache_entry["scope"] = cache_entry["scope"].value
|
||||
cache_entry["consent_date"] = cache_entry["consent_date"].isoformat()
|
||||
cache_entry["expiration_date"] = (
|
||||
None
|
||||
if cache_entry["expiration_date"] is None
|
||||
else cache_entry["expiration_date"].isoformat()
|
||||
)
|
||||
cache_entry["withdrawal_date"] = (
|
||||
None
|
||||
if cache_entry["withdrawal_date"] is None
|
||||
else cache_entry["withdrawal_date"].isoformat()
|
||||
)
|
||||
|
||||
return cache_entry
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_entry: Dict[str, Any] = cache_entry.copy()
|
||||
|
||||
db_entry["_id"] = ObjectId(db_entry["_id"])
|
||||
cache_entry["scope"] = ConsentScope(cache_entry["scope"])
|
||||
cache_entry["consent_date"] = datetime.fromisoformat(cache_entry["consent_date"])
|
||||
cache_entry["expiration_date"] = (
|
||||
None
|
||||
if cache_entry["expiration_date"] is None
|
||||
else datetime.fromisoformat(cache_entry["expiration_date"])
|
||||
)
|
||||
cache_entry["withdrawal_date"] = (
|
||||
None
|
||||
if cache_entry["withdrawal_date"] is None
|
||||
else datetime.fromisoformat(cache_entry["withdrawal_date"])
|
||||
)
|
||||
|
||||
return db_entry
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert Consent object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of Consent
|
||||
"""
|
||||
return {
|
||||
"_id": self._id if not json_compatible else str(self._id),
|
||||
"user_id": self.user_id,
|
||||
"guild_id": self.guild_id,
|
||||
"scope": self.scope if not json_compatible else self.scope.value,
|
||||
"consent_date": self.consent_date.isoformat(),
|
||||
"expiration_date": (
|
||||
None if self.expiration_date is None else self.expiration_date.isoformat()
|
||||
),
|
||||
"withdrawal_date": (
|
||||
None if self.withdrawal_date is None else self.withdrawal_date.isoformat()
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_defaults(
|
||||
user_id: Optional[int] = None,
|
||||
guild_id: Optional[int] = None,
|
||||
scope: Optional[ConsentScope] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"guild_id": guild_id,
|
||||
"scope": scope,
|
||||
"consent_date": datetime.now(tz=ZoneInfo("UTC")),
|
||||
"expiration_date": None,
|
||||
"withdrawal_date": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in Consent.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in Consent")
|
||||
|
||||
return Consent.get_defaults()[key]
|
||||
|
||||
async def withdraw(self, cache: Optional[Cache] = None) -> None:
|
||||
"""Withdraw consent now (in UTC timezone).
|
||||
|
||||
Args:
|
||||
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
|
||||
"""
|
||||
await self.update(cache=cache, withdrawal_date=datetime.now(tz=ZoneInfo("UTC")))
|
||||
108
src/javelina/classes/custom_channel.py
Normal file
108
src/javelina/classes/custom_channel.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..modules.database import col_custom_channels
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomChannel(BaseCacheable):
|
||||
"""Dataclass of DB entry of a custom channel"""
|
||||
|
||||
__slots__ = (
|
||||
"_id",
|
||||
"owner_id",
|
||||
"guild_id",
|
||||
"channel_id",
|
||||
"allow_comments",
|
||||
"allow_reactions",
|
||||
"created",
|
||||
"deleted",
|
||||
)
|
||||
__short_name__ = "channel"
|
||||
__collection__ = col_custom_channels
|
||||
|
||||
_id: ObjectId
|
||||
owner_id: int
|
||||
guild_id: int
|
||||
channel_id: int
|
||||
allow_comments: bool
|
||||
allow_reactions: bool
|
||||
created: datetime
|
||||
deleted: datetime | None
|
||||
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls,
|
||||
user_id: int,
|
||||
guild_id: int,
|
||||
channel_id: Optional[int] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "CustomChannel":
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert PycordGuild object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of PycordGuild
|
||||
"""
|
||||
return {
|
||||
"_id": self._id if not json_compatible else str(self._id),
|
||||
"id": self.id,
|
||||
"locale": self.locale,
|
||||
}
|
||||
|
||||
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
|
||||
await super()._set(cache, **kwargs)
|
||||
|
||||
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
|
||||
await super()._remove(*args, cache=cache)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.id}"
|
||||
|
||||
def _update_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._update_cache(cache)
|
||||
|
||||
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._delete_cache(cache)
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cache_entry: Dict[str, Any] = db_entry.copy()
|
||||
|
||||
cache_entry["_id"] = str(cache_entry["_id"])
|
||||
|
||||
return cache_entry
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_entry: Dict[str, Any] = cache_entry.copy()
|
||||
|
||||
db_entry["_id"] = ObjectId(db_entry["_id"])
|
||||
|
||||
return db_entry
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": guild_id,
|
||||
"locale": None,
|
||||
}
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in CustomChannel.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in CustomChannel")
|
||||
|
||||
return CustomChannel.get_defaults()[key]
|
||||
14
src/javelina/classes/custom_role.py
Normal file
14
src/javelina/classes/custom_role.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomRole:
|
||||
_id: ObjectId
|
||||
role_id: int
|
||||
role_color: int
|
||||
owner_id: int
|
||||
created: datetime
|
||||
deleted: datetime | None
|
||||
8
src/javelina/classes/errors/__init__.py
Normal file
8
src/javelina/classes/errors/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .pycord_guild import GuildNotFoundError
|
||||
from .pycord_user import UserNotFoundError
|
||||
from .wallet import (
|
||||
WalletBalanceLimitExceeded,
|
||||
WalletInsufficientFunds,
|
||||
WalletNotFoundError,
|
||||
WalletOverdraftLimitExceeded,
|
||||
)
|
||||
7
src/javelina/classes/errors/pycord_guild.py
Normal file
7
src/javelina/classes/errors/pycord_guild.py
Normal file
@@ -0,0 +1,7 @@
|
||||
class GuildNotFoundError(Exception):
|
||||
"""PycordGuild could not find guild with such an ID in the database"""
|
||||
|
||||
def __init__(self, guild_id: int) -> None:
|
||||
self.guild_id: int = guild_id
|
||||
|
||||
super().__init__(f"Guild with id {self.guild_id} was not found")
|
||||
8
src/javelina/classes/errors/pycord_user.py
Normal file
8
src/javelina/classes/errors/pycord_user.py
Normal file
@@ -0,0 +1,8 @@
|
||||
class UserNotFoundError(Exception):
|
||||
"""PycordUser could not find user with such an ID in the database"""
|
||||
|
||||
def __init__(self, user_id: int, guild_id: int) -> None:
|
||||
self.user_id: int = user_id
|
||||
self.guild_id: int = guild_id
|
||||
|
||||
super().__init__(f"User with id {self.user_id} was not found in guild {self.guild_id}")
|
||||
62
src/javelina/classes/errors/wallet.py
Normal file
62
src/javelina/classes/errors/wallet.py
Normal file
@@ -0,0 +1,62 @@
|
||||
class WalletNotFoundError(Exception):
|
||||
"""Wallet could not find user with such an ID from a guild in the database"""
|
||||
|
||||
def __init__(self, owner_id: int, guild_id: int) -> None:
|
||||
self.owner_id = owner_id
|
||||
self.guild_id = guild_id
|
||||
|
||||
super().__init__(
|
||||
f"Wallet of a user with id {self.owner_id} was not found for the guild with id {self.guild_id}"
|
||||
)
|
||||
|
||||
|
||||
class WalletInsufficientFunds(Exception):
|
||||
"""Wallet's balance is not sufficient to perform the operation"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wallet: "Wallet",
|
||||
amount: float,
|
||||
) -> None:
|
||||
self.wallet = wallet
|
||||
self.amount = amount
|
||||
|
||||
super().__init__(
|
||||
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} does not have sufficient funds to perform the operation (balance: {self.wallet.balance}, requested: {self.amount})"
|
||||
)
|
||||
|
||||
|
||||
class WalletOverdraftLimitExceeded(Exception):
|
||||
"""Wallet's overdraft limit is not sufficient to perform the operation"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wallet: "Wallet",
|
||||
amount: float,
|
||||
overdraft_limit: float,
|
||||
) -> None:
|
||||
self.wallet = wallet
|
||||
self.amount = amount
|
||||
self.overdraft_limit = overdraft_limit
|
||||
|
||||
super().__init__(
|
||||
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} does not have sufficient funds to perform the operation (balance: {self.wallet.balance}, requested: {self.amount}, overdraft limit: {self.overdraft_limit})"
|
||||
)
|
||||
|
||||
|
||||
class WalletBalanceLimitExceeded(Exception):
|
||||
"""Wallet's balance limit is not high enough to perform the operation"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
wallet: "Wallet",
|
||||
amount: float,
|
||||
balance_limit: float,
|
||||
) -> None:
|
||||
self.wallet = wallet
|
||||
self.amount = amount
|
||||
self.balance_limit = balance_limit
|
||||
|
||||
super().__init__(
|
||||
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} would have too much funds after the operation (balance: {self.wallet.balance}, deposited: {self.amount}, balance limit: {self.balance_limit})"
|
||||
)
|
||||
18
src/javelina/classes/fastapi.py
Normal file
18
src/javelina/classes/fastapi.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI as OriginalFastAPI
|
||||
from libbot.pycord.classes import PycordBot
|
||||
|
||||
|
||||
class FastAPI(OriginalFastAPI):
|
||||
def __init__(self, *args, bot: Optional[PycordBot] = None, **kwargs) -> None:
|
||||
self.bot: PycordBot | None = bot
|
||||
self.status: Any | None = None
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_bot(self, bot: PycordBot) -> None:
|
||||
self.bot = bot
|
||||
|
||||
def update_status(self, status: Any) -> None:
|
||||
self.status = status
|
||||
39
src/javelina/classes/guild_rules.py
Normal file
39
src/javelina/classes/guild_rules.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..classes.guild_rules_section import GuildRulesSection
|
||||
|
||||
# Example JSON
|
||||
# {
|
||||
# "header": "These are our rules",
|
||||
# "sections": [
|
||||
# {
|
||||
# "title": "1. First section",
|
||||
# "description": "This sections contains some rules",
|
||||
# "rules": [
|
||||
# {
|
||||
# "title": "Example rule",
|
||||
# "content": "Do not wear sandals while in socks!",
|
||||
# "punishment": 0,
|
||||
# }
|
||||
# ],
|
||||
# }
|
||||
# ],
|
||||
# }
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildRules:
|
||||
__slots__ = ("header", "sections")
|
||||
|
||||
header: str
|
||||
sections: List[GuildRulesSection]
|
||||
|
||||
# TODO Implement this method
|
||||
@classmethod
|
||||
def from_json(cls, db_entry: Dict[str, Any]) -> "GuildRules":
|
||||
raise NotImplementedError()
|
||||
|
||||
# TODO Implement this method
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
13
src/javelina/classes/guild_rules_rule.py
Normal file
13
src/javelina/classes/guild_rules_rule.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from ..enums import Punishment
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildRulesRule:
|
||||
__slots__ = ("title", "content", "punishment")
|
||||
|
||||
title: str
|
||||
content: str
|
||||
punishment: Literal[Punishment.WARNING, Punishment.MUTE, Punishment.KICK, Punishment.BAN]
|
||||
13
src/javelina/classes/guild_rules_section.py
Normal file
13
src/javelina/classes/guild_rules_section.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
from ..classes.guild_rules_rule import GuildRulesRule
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuildRulesSection:
|
||||
__slots__ = ("title", "description", "rules")
|
||||
|
||||
title: str
|
||||
description: str
|
||||
rules: List[GuildRulesRule]
|
||||
335
src/javelina/classes/pycord_bot.py
Normal file
335
src/javelina/classes/pycord_bot.py
Normal file
@@ -0,0 +1,335 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from importlib import resources
|
||||
from importlib.resources.abc import Traversable
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from discord import Activity, ActivityType, Colour, Embed, Guild, User
|
||||
from libbot.cache.classes import CacheMemcached, CacheRedis
|
||||
from libbot.cache.manager import create_cache_client
|
||||
from libbot.i18n import BotLocale
|
||||
from libbot.pycord.classes import PycordBot as LibPycordBot
|
||||
from libbot.utils import json_read
|
||||
|
||||
from ..classes import PycordGuild, PycordUser, ScheduledAction
|
||||
from ..enums import CacheTTL, EmbedColor
|
||||
from ..modules.database import (
|
||||
_update_database_indexes,
|
||||
col_scheduled_actions,
|
||||
)
|
||||
from ..modules.utils.color_utils import hex_to_int
|
||||
from ..utils.package_utils import get_locales_root
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# from javelina.modules.tracking.dhl import update_tracks_dhl
|
||||
|
||||
|
||||
class PycordBot(LibPycordBot):
|
||||
started: datetime
|
||||
cache: CacheMemcached | CacheRedis | None = None
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, locales_root=get_locales_root(), **kwargs)
|
||||
|
||||
self._set_cache_engine()
|
||||
|
||||
self.client_session: ClientSession = ClientSession()
|
||||
|
||||
# This replacement exists because of the different
|
||||
# i18n formats than provided by libbot
|
||||
self._ = self._modified_string_getter
|
||||
|
||||
if self.scheduler is None:
|
||||
return
|
||||
|
||||
def _set_cache_engine(self) -> None:
|
||||
cache_type: Literal["redis", "memcached"] | None = self.config["cache"]["type"]
|
||||
|
||||
if "cache" in self.config and cache_type is not None:
|
||||
self.cache = create_cache_client(
|
||||
self.config,
|
||||
cache_type,
|
||||
prefix=self.config["cache"][cache_type]["prefix"],
|
||||
default_ttl_seconds=CacheTTL.NORMAL.value,
|
||||
)
|
||||
|
||||
def _modified_string_getter(self, key: str, *args: str, locale: str | None = None) -> Any:
|
||||
"""This method exists because of the different i18n formats than provided by libbot.
|
||||
It splits "-" and takes the first part of the provided locale to make complex language codes
|
||||
compatible with an easy libbot approach to i18n.
|
||||
"""
|
||||
return self.bot_locale._(
|
||||
key, *args, locale=None if locale is None else locale.split("-")[0]
|
||||
)
|
||||
|
||||
def load_cogs(self) -> None:
|
||||
cog_files: Traversable = resources.files("javelina").joinpath("cogs")
|
||||
cog_names: List[str] = []
|
||||
|
||||
for cog_file in cog_files.iterdir():
|
||||
if cog_file.name.startswith("_") or not cog_file.name.endswith(".py"):
|
||||
continue
|
||||
|
||||
cog_names.append(f".cogs.{cog_file.name.replace('.py', '')}")
|
||||
|
||||
self.load_extensions(*cog_names, package="javelina")
|
||||
|
||||
def load_integration_cogs(self) -> None:
|
||||
integrations_dir: Traversable = resources.files("javelina").joinpath("integrations")
|
||||
cog_names: List[str] = []
|
||||
|
||||
for integration_dir in integrations_dir.iterdir():
|
||||
if not integration_dir.is_dir() or integration_dir.name.startswith("_"):
|
||||
continue
|
||||
|
||||
cog_files: Traversable = integration_dir.joinpath("cogs")
|
||||
|
||||
if not cog_files.is_dir():
|
||||
continue
|
||||
|
||||
for cog_file in cog_files.iterdir():
|
||||
if cog_file.name.startswith("_") or not cog_file.name.endswith(".py"):
|
||||
continue
|
||||
|
||||
integration_name: str = cog_file.name.replace("cog_", "").replace(".py", "")
|
||||
|
||||
if not self.is_integration_enabled(integration_name):
|
||||
logger.info(
|
||||
"Not loading the integration %s because it is not enabled",
|
||||
integration_name,
|
||||
)
|
||||
continue
|
||||
|
||||
cog_names.append(
|
||||
f".integrations.{integration_dir.name}.cogs.{cog_file.name.replace('.py', '')}"
|
||||
)
|
||||
|
||||
self.load_extensions(*cog_names, package="javelina")
|
||||
|
||||
def load_plugins(self) -> None:
|
||||
if not Path("plugins").exists():
|
||||
return
|
||||
|
||||
cogs_loaded: Dict[str, Exception | bool] | List[str] | None = self.load_extension(
|
||||
"plugins"
|
||||
)
|
||||
|
||||
if cogs_loaded is None:
|
||||
return
|
||||
|
||||
if isinstance(cogs_loaded, list):
|
||||
if len(cogs_loaded) > 0:
|
||||
logger.info("Loaded %s plugins: %s", len(cogs_loaded), ", ".join(cogs_loaded))
|
||||
else:
|
||||
logger.info("No user plugins were loaded.")
|
||||
|
||||
return
|
||||
|
||||
cogs_loaded_list: List[str] = []
|
||||
|
||||
for cog_name, cog_status in cogs_loaded.items():
|
||||
if cog_status is True:
|
||||
cogs_loaded_list.append(cog_name)
|
||||
|
||||
if len(cogs_loaded_list) > 0:
|
||||
logger.info(
|
||||
"Loaded %s plugins: %s",
|
||||
len(cogs_loaded_list),
|
||||
", ".join(cogs_loaded_list),
|
||||
)
|
||||
else:
|
||||
logger.info("No user plugins were loaded.")
|
||||
|
||||
# TODO Add documentation
|
||||
async def get_scheduled_actions(self) -> List[ScheduledAction]:
|
||||
return [
|
||||
ScheduledAction.from_entry(entry) async for entry in col_scheduled_actions.find()
|
||||
]
|
||||
|
||||
# TODO Add rollback mechanism for recovery from broken config
|
||||
# TODO Add documentation
|
||||
def reload(self) -> None:
|
||||
config_old: Dict[str, Any] = self.config.copy()
|
||||
|
||||
try:
|
||||
self.config = json_read(Path("config.json"))
|
||||
|
||||
self.bot_locale = BotLocale(
|
||||
default_locale=self.config["locale"],
|
||||
locales_root=get_locales_root(),
|
||||
)
|
||||
self.default_locale = self.bot_locale.default
|
||||
self.locales = self.bot_locale.locales
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Could not reload the configuration, restoring old in-memory values due to: %s",
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
self.config = config_old
|
||||
|
||||
raise exc
|
||||
|
||||
async def set_status(self) -> None:
|
||||
activity_enabled: bool = self.config["bot"]["status"]["enabled"]
|
||||
activity_id: int = self.config["bot"]["status"]["activity_type"]
|
||||
activity_message: str = self.config["bot"]["status"]["activity_text"]
|
||||
|
||||
if not activity_enabled:
|
||||
logger.info("Activity is disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
activity_type: ActivityType = ActivityType(activity_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not activity with ID %s to ActivityType due to: %s",
|
||||
activity_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
logger.error("Activity type with ID %s is not supported", activity_id)
|
||||
return
|
||||
|
||||
await self.change_presence(activity=Activity(type=activity_type, name=activity_message))
|
||||
|
||||
logger.info(
|
||||
"Set activity type to %s (%s) with message '%s'",
|
||||
activity_id,
|
||||
activity_type.name,
|
||||
activity_message,
|
||||
)
|
||||
|
||||
async def find_user(self, user: int | User, guild_id: int) -> PycordUser:
|
||||
"""Find User by its ID or User object.
|
||||
|
||||
Args:
|
||||
user (int | User): ID or User object to extract ID from
|
||||
guild_id (int): ID of the guild user is member of
|
||||
|
||||
Returns:
|
||||
PycordUser: User object
|
||||
|
||||
Raises:
|
||||
UserNotFoundException: User was not found and creation was not allowed
|
||||
"""
|
||||
return (
|
||||
await PycordUser.from_id(user, guild_id, cache=self.cache)
|
||||
if isinstance(user, int)
|
||||
else await PycordUser.from_id(user.id, guild_id, cache=self.cache)
|
||||
)
|
||||
|
||||
async def find_guild(self, guild: int | Guild) -> PycordGuild:
|
||||
"""Find Guild by its ID or Guild object.
|
||||
|
||||
Args:
|
||||
guild (int | Guild): ID or User object to extract ID from
|
||||
|
||||
Returns:
|
||||
PycordGuild: Guild object
|
||||
|
||||
Raises:
|
||||
GuildNotFoundException: Guild was not found and creation was not allowed
|
||||
"""
|
||||
return (
|
||||
await PycordGuild.from_id(guild, cache=self.cache)
|
||||
if isinstance(guild, int)
|
||||
else await PycordGuild.from_id(guild.id, cache=self.cache)
|
||||
)
|
||||
|
||||
async def start(self, *args: Any, **kwargs: Any) -> None:
|
||||
await self._schedule_tasks()
|
||||
await _update_database_indexes()
|
||||
|
||||
self.started = datetime.now(tz=ZoneInfo("UTC"))
|
||||
|
||||
await super().start(*args, **kwargs)
|
||||
|
||||
async def close(self, **kwargs) -> None:
|
||||
await self.client_session.close()
|
||||
|
||||
await super().close(**kwargs)
|
||||
|
||||
def is_integration_enabled(self, integration_name: str) -> bool:
|
||||
return self.config.get("modules", {}).get(
|
||||
integration_name
|
||||
) is not None and self.config.get("modules", {}).get(integration_name, {}).get(
|
||||
"enabled", False
|
||||
)
|
||||
|
||||
async def _schedule_tasks(self) -> None:
|
||||
if self.scheduler is None:
|
||||
return
|
||||
|
||||
for scheduled_action in await self.get_scheduled_actions():
|
||||
match scheduled_action.action_type:
|
||||
# case ScheduledActionType.WEATHER_REPORT:
|
||||
# func: Callable | None = None
|
||||
case _:
|
||||
continue
|
||||
|
||||
self.scheduler.add_job(
|
||||
func,
|
||||
trigger=CronTrigger.from_crontab(scheduled_action.action_schedule),
|
||||
args=[self, scheduled_action.action_data],
|
||||
)
|
||||
|
||||
if scheduled_action.invoke_on_start:
|
||||
self.scheduler.add_job(
|
||||
func,
|
||||
trigger=DateTrigger(datetime.now() + timedelta(seconds=5)),
|
||||
args=[self, scheduled_action.action_data],
|
||||
)
|
||||
|
||||
# Scheduler job for DHL parcel tracking
|
||||
# self.scheduler.add_job(
|
||||
# update_tracks_dhl,
|
||||
# trigger="cron",
|
||||
# hour=self.config["modules"]["tracking"]["fetch_hours"],
|
||||
# args=[self, self.client_session],
|
||||
# )
|
||||
pass
|
||||
|
||||
# TODO Add support for guild colors
|
||||
def create_embed(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
color: Optional[EmbedColor] = EmbedColor.PRIMARY,
|
||||
) -> Embed:
|
||||
return Embed(
|
||||
title=title,
|
||||
description=description,
|
||||
url=url,
|
||||
color=Colour(hex_to_int(self.config["colors"][color.value])),
|
||||
)
|
||||
|
||||
def create_embed_error(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
) -> Embed:
|
||||
return self.create_embed(
|
||||
title=title, description=description, url=url, color=EmbedColor.ERROR
|
||||
)
|
||||
|
||||
def create_embed_success(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
url: Optional[str] = None,
|
||||
) -> Embed:
|
||||
return self.create_embed(
|
||||
title=title, description=description, url=url, color=EmbedColor.SUCCESS
|
||||
)
|
||||
181
src/javelina/classes/pycord_guild.py
Normal file
181
src/javelina/classes/pycord_guild.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from discord import Cog
|
||||
from libbot.cache.classes import Cache
|
||||
from libbot.pycord.classes import PycordBot as LibPycordBot
|
||||
from pymongo.results import InsertOneResult
|
||||
|
||||
from ..classes import GuildRules
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..classes.errors import GuildNotFoundError
|
||||
from ..modules.database import col_guilds
|
||||
from ..modules.utils import restore_from_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class PycordGuild(BaseCacheable):
|
||||
"""Dataclass of DB entry of a guild"""
|
||||
|
||||
__slots__ = ("_id", "id", "locale", "rules")
|
||||
__short_name__ = "guild"
|
||||
__collection__ = col_guilds
|
||||
|
||||
_id: ObjectId
|
||||
id: int
|
||||
locale: str | None
|
||||
rules: GuildRules | None
|
||||
emote_set_id: str | None
|
||||
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
|
||||
) -> "PycordGuild":
|
||||
"""Find the guild by its ID and construct PycordEventStage from database entry.
|
||||
|
||||
Args:
|
||||
guild_id (int): ID of the guild to look up.
|
||||
allow_creation (:obj:`bool`, optional): Create a new record if none found in the database.
|
||||
cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
|
||||
|
||||
Returns:
|
||||
PycordGuild: Object of the found or newly created guild.
|
||||
|
||||
Raises:
|
||||
GuildNotFoundError: Guild with such ID does not exist and creation was not allowed.
|
||||
"""
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__, guild_id, cache=cache
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"id": guild_id})
|
||||
|
||||
if db_entry is None:
|
||||
if not allow_creation:
|
||||
raise GuildNotFoundError(guild_id)
|
||||
|
||||
db_entry = PycordGuild.get_defaults(guild_id)
|
||||
|
||||
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry))
|
||||
|
||||
db_entry["rules"] = (
|
||||
None if db_entry["rules"] is None else GuildRules.from_json(db_entry["rules"])
|
||||
)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert PycordGuild object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of PycordGuild
|
||||
"""
|
||||
return {
|
||||
"_id": self._id if not json_compatible else str(self._id),
|
||||
"id": self.id,
|
||||
"locale": self.locale,
|
||||
"rules": None if self.rules is None else self.rules.to_dict(),
|
||||
"emote_set_id": self.emote_set_id,
|
||||
}
|
||||
|
||||
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
|
||||
await super()._set(cache, **kwargs)
|
||||
|
||||
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
|
||||
await super()._remove(*args, cache=cache)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.id}"
|
||||
|
||||
def _update_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._update_cache(cache)
|
||||
|
||||
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._delete_cache(cache)
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cache_entry: Dict[str, Any] = db_entry.copy()
|
||||
|
||||
cache_entry["_id"] = str(cache_entry["_id"])
|
||||
|
||||
if cache_entry["rules"] is not None and isinstance(cache_entry["rules"], GuildRules):
|
||||
cache_entry["rules"] = cache_entry["rules"].to_json()
|
||||
|
||||
return cache_entry
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_entry: Dict[str, Any] = cache_entry.copy()
|
||||
|
||||
db_entry["_id"] = ObjectId(db_entry["_id"])
|
||||
db_entry["rules"] = (
|
||||
None if db_entry["rules"] is None else GuildRules.from_json(db_entry["rules"])
|
||||
)
|
||||
|
||||
return db_entry
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
return {"id": guild_id, "locale": None, "rules": None, "emote_set_id": None}
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in PycordGuild.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in PycordGuild")
|
||||
|
||||
return PycordGuild.get_defaults()[key]
|
||||
|
||||
# TODO Add documentation
|
||||
async def set_rules(self, rules: GuildRules, cache: Optional[Cache] = None) -> None:
|
||||
await self.update(cache=cache, rules=rules.to_dict())
|
||||
|
||||
# TODO Add documentation
|
||||
async def clear_rules(self, cache: Optional[Cache] = None) -> None:
|
||||
await self.update(cache=cache, rules=None)
|
||||
|
||||
# TODO Add documentation
|
||||
async def set_locale(self, locale: str, cache: Optional[Cache] = None) -> None:
|
||||
await self.update(cache=cache, locale=locale)
|
||||
|
||||
# TODO Add documentation
|
||||
async def reset_locale(self, cache: Optional[Cache] = None) -> None:
|
||||
await self.update(cache=cache, locale=None)
|
||||
|
||||
# TODO Add documentation
|
||||
async def set_emote_set_id(
|
||||
self,
|
||||
set_id: str,
|
||||
cog: Optional[Cog] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
if self.emote_set_id is not None and cog is not None:
|
||||
await cog.unsubscribe_7tv_set(self.emote_set_id, self.id)
|
||||
|
||||
await self.update(cache=cache, emote_set_id=set_id)
|
||||
|
||||
if cog is not None:
|
||||
await cog.subscribe_7tv_set(set_id, self.id)
|
||||
|
||||
# TODO Add documentation
|
||||
async def reset_emote_set_id(
|
||||
self, cog: Optional[Cog] = None, cache: Optional[Cache] = None
|
||||
) -> None:
|
||||
if cog is not None:
|
||||
await cog.unsubscribe_7tv_set(self.emote_set_id, self.id)
|
||||
|
||||
await self.update(cache=cache, emote_set_id=None)
|
||||
158
src/javelina/classes/pycord_guild_emote.py
Normal file
158
src/javelina/classes/pycord_guild_emote.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo.results import InsertOneResult
|
||||
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..enums import GuildEmoteSource
|
||||
from ..modules.database import col_guild_emotes
|
||||
from ..modules.utils import restore_from_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class PycordGuildEmote(BaseCacheable):
|
||||
"""Dataclass of DB entry of a guild emote"""
|
||||
|
||||
__slots__ = (
|
||||
"_id",
|
||||
"id",
|
||||
"guild_id",
|
||||
"name",
|
||||
"source",
|
||||
"source_id",
|
||||
"source_set_id",
|
||||
"created",
|
||||
"modified",
|
||||
)
|
||||
__short_name__ = "guild_emote"
|
||||
__collection__ = col_guild_emotes
|
||||
|
||||
_id: ObjectId
|
||||
id: int
|
||||
guild_id: int
|
||||
name: str
|
||||
source: GuildEmoteSource | None
|
||||
source_id: str | None
|
||||
source_set_id: str | None
|
||||
created: datetime
|
||||
modified: datetime
|
||||
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls,
|
||||
id: int,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "PycordGuildEmote":
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__, id, cache=cache
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry = await cls.__collection__.find_one({"id": id})
|
||||
|
||||
# TODO Replace with a real exception
|
||||
if db_entry is None:
|
||||
raise RuntimeError(f"Guild emote with ID {id} was not found")
|
||||
# raise GuilEmoteNotFoundError(id)
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
@classmethod
|
||||
async def from_guild_and_source_id(
|
||||
cls,
|
||||
guild_id: int,
|
||||
source_id: str,
|
||||
) -> "PycordGuildEmote":
|
||||
db_entry = await cls.__collection__.find_one(
|
||||
{"guild_id": guild_id, "source_id": source_id}
|
||||
)
|
||||
|
||||
# TODO Replace with a real exception
|
||||
if db_entry is None:
|
||||
raise RuntimeError(
|
||||
f"Guild emote with source ID {source_id} in guild {guild_id} was not found"
|
||||
)
|
||||
# raise GuilEmoteNotFoundError(id)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
id: int,
|
||||
guild_id: int,
|
||||
name: str,
|
||||
source: Optional[GuildEmoteSource] = None,
|
||||
source_id: Optional[str] = None,
|
||||
source_set_id: Optional[str] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "PycordGuildEmote":
|
||||
db_entry: Dict[str, Any] = {
|
||||
"id": id,
|
||||
"guild_id": guild_id,
|
||||
"name": name,
|
||||
"source": None if source is None else source.value,
|
||||
"source_id": source_id,
|
||||
"source_set_id": source_set_id,
|
||||
"created": datetime.now(tz=timezone.utc),
|
||||
"modified": datetime.now(tz=timezone.utc),
|
||||
}
|
||||
|
||||
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.id}"
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert PycordEmote object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of PycordEmote
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def get_defaults(
|
||||
owner_id: Optional[int] = None, guild_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in PycordGuildEmote.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in PycordEmote")
|
||||
|
||||
return PycordGuildEmote.get_defaults()[key]
|
||||
|
||||
async def update_name(
|
||||
self,
|
||||
name: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await self._set(cache=cache, name=name)
|
||||
278
src/javelina/classes/pycord_user.py
Normal file
278
src/javelina/classes/pycord_user.py
Normal file
@@ -0,0 +1,278 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo.results import InsertOneResult
|
||||
|
||||
from ..classes import Consent
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..classes.errors.pycord_user import UserNotFoundError
|
||||
from ..classes.wallet import Wallet
|
||||
from ..enums import ConsentScope
|
||||
from ..modules.database import col_users
|
||||
from ..modules.utils import restore_from_cache
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PycordUser(BaseCacheable):
|
||||
"""Dataclass of DB entry of a user"""
|
||||
|
||||
__slots__ = ("_id", "id", "guild_id")
|
||||
__short_name__ = "user"
|
||||
__collection__ = col_users
|
||||
|
||||
_id: ObjectId
|
||||
id: int
|
||||
guild_id: int
|
||||
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls,
|
||||
user_id: int,
|
||||
guild_id: int,
|
||||
allow_creation: bool = True,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "PycordUser":
|
||||
"""Find user in database and create new record if user does not exist.
|
||||
|
||||
Args:
|
||||
user_id (int): User's Discord ID
|
||||
guild_id (int): User's guild Discord ID
|
||||
allow_creation (:obj:`bool`, optional): Create new user record if none found in the database
|
||||
cache (:obj:`Cache`, optional): Cache engine to get the cache from
|
||||
|
||||
Returns:
|
||||
PycordUser: User object
|
||||
|
||||
Raises:
|
||||
UserNotFoundError: User was not found and creation was not allowed
|
||||
"""
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__, f"{user_id}_{guild_id}", cache=cache
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
|
||||
{"id": user_id, "guild_id": guild_id}
|
||||
)
|
||||
|
||||
if db_entry is None:
|
||||
if not allow_creation:
|
||||
raise UserNotFoundError(user_id, guild_id)
|
||||
|
||||
db_entry = PycordUser.get_defaults(user_id, guild_id)
|
||||
|
||||
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(
|
||||
f"{cls.__short_name__}_{user_id}_{guild_id}",
|
||||
cls._entry_to_cache(db_entry),
|
||||
)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert PycordUser object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of PycordUser
|
||||
"""
|
||||
return {
|
||||
"_id": self._id if not json_compatible else str(self._id),
|
||||
"id": self.id,
|
||||
"guild_id": self.guild_id,
|
||||
}
|
||||
|
||||
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
|
||||
await super()._set(cache, **kwargs)
|
||||
|
||||
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
|
||||
await super()._remove(*args, cache=cache)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.id}_{self.guild_id}"
|
||||
|
||||
def _update_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._update_cache(cache)
|
||||
|
||||
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
|
||||
super()._delete_cache(cache)
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cache_entry: Dict[str, Any] = db_entry.copy()
|
||||
|
||||
cache_entry["_id"] = str(cache_entry["_id"])
|
||||
|
||||
return cache_entry
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_entry: Dict[str, Any] = cache_entry.copy()
|
||||
|
||||
db_entry["_id"] = ObjectId(db_entry["_id"])
|
||||
|
||||
return db_entry
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_defaults(
|
||||
user_id: Optional[int] = None, guild_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": user_id,
|
||||
"guild_id": guild_id,
|
||||
}
|
||||
|
||||
# TODO Add documentation
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in PycordUser.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in PycordUser")
|
||||
|
||||
return PycordUser.get_defaults()[key]
|
||||
|
||||
async def update(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
await super().update(cache=cache, **kwargs)
|
||||
|
||||
async def reset(
|
||||
self,
|
||||
*args: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await super().reset(*args, cache=cache)
|
||||
|
||||
async def purge(self, cache: Optional[Cache] = None) -> None:
|
||||
await super().purge(cache)
|
||||
|
||||
async def get_wallet(self, guild_id: int, cache: Optional[Cache] = None) -> Wallet:
|
||||
"""Get wallet of the user.
|
||||
|
||||
Args:
|
||||
guild_id (int): Guild ID of the wallet
|
||||
cache (:obj:`Cache`, optional): Cache engine to get the cache from
|
||||
|
||||
Returns:
|
||||
Wallet: Wallet object of the user
|
||||
"""
|
||||
return await Wallet.from_id(self.id, guild_id, cache=cache)
|
||||
|
||||
# TODO Add documentation
|
||||
async def has_active_consent(self, scope: ConsentScope) -> bool:
|
||||
# TODO Test this query
|
||||
consent: Dict[str, Any] | None = await Consent.__collection__.find_one(
|
||||
{
|
||||
"user_id": self.id,
|
||||
"guild_id": self.guild_id,
|
||||
"scope": scope.value,
|
||||
"withdrawal_date": None,
|
||||
"$or": [
|
||||
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
{"expiration_date": None},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return consent is not None
|
||||
|
||||
# TODO Add documentation
|
||||
async def give_consent(
|
||||
self,
|
||||
scope: ConsentScope,
|
||||
expiration_date: Optional[datetime] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await Consent.give(self.id, self.guild_id, scope, expiration_date, cache=cache)
|
||||
|
||||
# TODO Add documentation
|
||||
async def withdraw_consent(
|
||||
self,
|
||||
scope: ConsentScope,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
# TODO Test this query
|
||||
await Consent.__collection__.update_many(
|
||||
{
|
||||
"user_id": self.id,
|
||||
"guild_id": self.guild_id,
|
||||
"scope": scope.value,
|
||||
"withdrawal_date": None,
|
||||
"$or": [
|
||||
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
{"expiration_date": None},
|
||||
],
|
||||
},
|
||||
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
)
|
||||
|
||||
if cache is not None:
|
||||
cache.delete(Consent.get_cache_key(self.id, self.guild_id, scope))
|
||||
|
||||
# TODO Add documentation
|
||||
async def withdraw_all_consents(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
filter: Dict[str, Any] = {
|
||||
"user_id": self.id,
|
||||
"guild_id": self.guild_id,
|
||||
"withdrawal_date": None,
|
||||
"$or": [
|
||||
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
{"expiration_date": None},
|
||||
],
|
||||
}
|
||||
|
||||
consents: List[Dict[str, Any]] | None = await Consent.__collection__.find(
|
||||
filter
|
||||
).to_list()
|
||||
|
||||
await Consent.__collection__.update_many(
|
||||
filter,
|
||||
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
)
|
||||
|
||||
if cache is not None and consents is not None:
|
||||
for consent in consents:
|
||||
cache.delete(
|
||||
Consent.get_cache_key(
|
||||
self.id, self.guild_id, ConsentScope(consent["scope"])
|
||||
)
|
||||
)
|
||||
|
||||
async def get_consents(self) -> List[Consent]:
|
||||
consents: List[Consent] = []
|
||||
|
||||
async for consent in Consent.__collection__.find(
|
||||
{
|
||||
"user_id": self.id,
|
||||
"guild_id": self.guild_id,
|
||||
"withdrawal_date": None,
|
||||
"$or": [
|
||||
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
|
||||
{"expiration_date": None},
|
||||
],
|
||||
}
|
||||
):
|
||||
consents.append(Consent.from_entry(consent))
|
||||
|
||||
return consents
|
||||
32
src/javelina/classes/scheduled_action.py
Normal file
32
src/javelina/classes/scheduled_action.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from ..enums import ScheduledActionType
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledAction:
|
||||
"""Dataclass of DB entry of a scheduled action"""
|
||||
|
||||
__slots__ = (
|
||||
"_id",
|
||||
"action_type",
|
||||
"action_schedule",
|
||||
"action_data",
|
||||
"invoke_on_start",
|
||||
)
|
||||
|
||||
_id: ObjectId
|
||||
action_type: ScheduledActionType
|
||||
action_schedule: str
|
||||
action_data: Dict[str, Any] | None
|
||||
invoke_on_start: bool
|
||||
|
||||
# TODO Add documentation
|
||||
@classmethod
|
||||
def from_entry(cls, db_entry: Dict[str, Any]) -> "ScheduledAction":
|
||||
db_entry["action_type"] = ScheduledActionType(db_entry["action_type"])
|
||||
|
||||
return cls(**db_entry)
|
||||
23
src/javelina/classes/service_status.py
Normal file
23
src/javelina/classes/service_status.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Literal, Optional
|
||||
|
||||
from ..enums import HealthStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceStatus:
|
||||
status: Literal[
|
||||
HealthStatus.OPERATIONAL,
|
||||
HealthStatus.DEGRADED,
|
||||
HealthStatus.FAILED,
|
||||
HealthStatus.UNKNOWN,
|
||||
]
|
||||
message: str | None
|
||||
|
||||
def to_json(self, detailed: Optional[bool] = False) -> Dict[str, str | None]:
|
||||
output: Dict[str, str | None] = {"status": self.status.value}
|
||||
|
||||
if detailed:
|
||||
output["message"] = self.message
|
||||
|
||||
return output
|
||||
216
src/javelina/classes/wallet.py
Normal file
216
src/javelina/classes/wallet.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo.results import InsertOneResult
|
||||
|
||||
from ..classes.base import BaseCacheable
|
||||
from ..classes.errors.wallet import (
|
||||
WalletBalanceLimitExceeded,
|
||||
WalletInsufficientFunds,
|
||||
WalletNotFoundError,
|
||||
WalletOverdraftLimitExceeded,
|
||||
)
|
||||
from ..modules.database import col_wallets
|
||||
from ..modules.utils import restore_from_cache
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wallet(BaseCacheable):
|
||||
"""Dataclass of DB entry of a wallet"""
|
||||
|
||||
__slots__ = ("_id", "owner_id", "guild_id", "balance", "is_frozen", "created")
|
||||
__short_name__ = "wallet"
|
||||
__collection__ = col_wallets
|
||||
|
||||
_id: ObjectId
|
||||
owner_id: int
|
||||
guild_id: int
|
||||
balance: float
|
||||
is_frozen: bool
|
||||
created: datetime
|
||||
|
||||
# TODO Write a docstring
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls,
|
||||
owner_id: int,
|
||||
guild_id: int,
|
||||
allow_creation: bool = True,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "Wallet":
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__, f"{owner_id}_{guild_id}", cache=cache
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry = await col_wallets.find_one({"owner_id": owner_id, "guild_id": guild_id})
|
||||
|
||||
if db_entry is None:
|
||||
if not allow_creation:
|
||||
raise WalletNotFoundError(owner_id, guild_id)
|
||||
|
||||
db_entry = Wallet.get_defaults(owner_id, guild_id)
|
||||
|
||||
insert_result: InsertOneResult = await col_wallets.insert_one(db_entry)
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(
|
||||
f"{cls.__short_name__}_{owner_id}_{guild_id}",
|
||||
cls._entry_to_cache(db_entry),
|
||||
)
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.owner_id}_{self.guild_id}"
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cache_entry: Dict[str, Any] = db_entry.copy()
|
||||
|
||||
cache_entry["_id"] = str(cache_entry["_id"])
|
||||
cache_entry["created"] = cache_entry["created"].isoformat()
|
||||
|
||||
return cache_entry
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
db_entry: Dict[str, Any] = cache_entry.copy()
|
||||
|
||||
db_entry["_id"] = ObjectId(db_entry["_id"])
|
||||
db_entry["created"] = datetime.fromisoformat(db_entry["created"])
|
||||
|
||||
return db_entry
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert Wallet object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of Wallet
|
||||
"""
|
||||
return {
|
||||
"_id": self._id if not json_compatible else str(self._id),
|
||||
"owner_id": self.owner_id,
|
||||
"guild_id": self.guild_id,
|
||||
"balance": self.balance,
|
||||
"is_frozen": self.is_frozen,
|
||||
"created": self.created if not json_compatible else self.created.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_defaults(
|
||||
owner_id: Optional[int] = None, guild_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"owner_id": owner_id,
|
||||
"guild_id": guild_id,
|
||||
"balance": 0.0,
|
||||
"is_frozen": False,
|
||||
"created": datetime.now(tz=timezone.utc),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in Wallet.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in Wallet")
|
||||
|
||||
return Wallet.get_defaults()[key]
|
||||
|
||||
# TODO Write a docstring
|
||||
async def freeze(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await self.update(cache, is_frozen=True)
|
||||
|
||||
# TODO Write a docstring
|
||||
async def unfreeze(
|
||||
self,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await self.update(cache, is_frozen=False)
|
||||
|
||||
# TODO Write a docstring
|
||||
async def deposit(
|
||||
self,
|
||||
amount: float,
|
||||
balance_limit: Optional[float] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> float:
|
||||
new_balance: float = round(self.balance + amount, 2)
|
||||
|
||||
if balance_limit is not None and new_balance > balance_limit:
|
||||
raise WalletBalanceLimitExceeded(self, amount, balance_limit)
|
||||
|
||||
await self.update(cache, balance=new_balance)
|
||||
|
||||
return new_balance
|
||||
|
||||
# TODO Write a docstring
|
||||
async def withdraw(
|
||||
self,
|
||||
amount: float,
|
||||
allow_overdraft: bool = False,
|
||||
overdraft_limit: Optional[float] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> float:
|
||||
if amount > self.balance:
|
||||
if not allow_overdraft or overdraft_limit is None:
|
||||
raise WalletInsufficientFunds(self, amount)
|
||||
|
||||
if allow_overdraft and amount > overdraft_limit:
|
||||
raise WalletOverdraftLimitExceeded(self, amount, overdraft_limit)
|
||||
|
||||
new_balance: float = round(self.balance - amount, 2)
|
||||
|
||||
await self.update(cache, balance=new_balance)
|
||||
|
||||
return new_balance
|
||||
|
||||
async def transfer(
|
||||
self,
|
||||
wallet_owner_id: int,
|
||||
wallet_guild_id: int,
|
||||
amount: float,
|
||||
balance_limit: Optional[float] = None,
|
||||
allow_overdraft: bool = False,
|
||||
overdraft_limit: Optional[float] = None,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
# TODO Replace with a concrete exception
|
||||
if amount < 0:
|
||||
raise ValueError()
|
||||
|
||||
# allow_creation might need to be set to False in the future
|
||||
# if users will be able to opt out from having a wallet
|
||||
wallet: Wallet = await self.from_id(
|
||||
wallet_owner_id, wallet_guild_id, allow_creation=True, cache=cache
|
||||
)
|
||||
|
||||
if balance_limit is not None and amount + wallet.balance > balance_limit:
|
||||
raise WalletBalanceLimitExceeded(wallet, amount, balance_limit)
|
||||
|
||||
if amount > self.balance:
|
||||
if not allow_overdraft or overdraft_limit is None:
|
||||
raise WalletInsufficientFunds(self, amount)
|
||||
|
||||
if allow_overdraft and amount > overdraft_limit:
|
||||
raise WalletOverdraftLimitExceeded(self, amount, overdraft_limit)
|
||||
|
||||
# TODO Make a sanity check to revert the transaction if anything goes wrong
|
||||
await self.withdraw(amount, allow_overdraft, overdraft_limit, cache=cache)
|
||||
await wallet.deposit(amount, balance_limit, cache=cache)
|
||||
42
src/javelina/cli.py
Normal file
42
src/javelina/cli.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
from typer import Option, Typer, echo
|
||||
|
||||
from .modules.migrator import migrate_database
|
||||
|
||||
cli: Typer = Typer()
|
||||
|
||||
|
||||
@cli.command()
|
||||
def init(
|
||||
destination: Path = Option(
|
||||
"config.json", help="File to write the default configuration to"
|
||||
),
|
||||
overwrite: bool = Option(False, help="Overwrite config if already exists"),
|
||||
) -> None:
|
||||
if destination.exists() and not overwrite:
|
||||
raise FileExistsError(
|
||||
f"File at {destination} already exists. Pass --overwrite to overwrite it"
|
||||
)
|
||||
|
||||
with open(destination, "w", encoding="utf-8") as config_file:
|
||||
config_file.write(resources.read_text("javelina.resources", "config_example.json"))
|
||||
|
||||
echo(f"Copied default config to {destination}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
def migrate(
|
||||
config: Path = Option("config.json", help="Config file"),
|
||||
) -> None:
|
||||
echo("Performing migrations...")
|
||||
|
||||
with resources.path("javelina.migrations", "__init__.py") as init_file:
|
||||
migrations_path: Path = init_file.parent
|
||||
|
||||
migrate_database(config_file=config, migrations_path=migrations_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cli()
|
||||
124
src/javelina/cogs/cog_admin.py
Normal file
124
src/javelina/cogs/cog_admin.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from typing import Callable, List
|
||||
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
Cog,
|
||||
Forbidden,
|
||||
InteractionContextType,
|
||||
Message,
|
||||
User,
|
||||
default_permissions,
|
||||
option,
|
||||
slash_command,
|
||||
)
|
||||
from libbot.i18n import _, in_every_locale
|
||||
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..utils.package_utils import get_locales_root
|
||||
|
||||
|
||||
class CogAdmin(Cog):
|
||||
"""Cog with the guessing command."""
|
||||
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
|
||||
@slash_command(
|
||||
name="reload",
|
||||
description=_("description", "commands", "reload", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "reload", locales_root=get_locales_root()
|
||||
),
|
||||
contexts={InteractionContextType.bot_dm},
|
||||
)
|
||||
async def command_reload(self, ctx: ApplicationContext) -> None:
|
||||
if ctx.user.id not in self.bot.owner_ids:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._(
|
||||
"permission_denied_title",
|
||||
"messages",
|
||||
"general",
|
||||
locale=ctx.locale,
|
||||
),
|
||||
self.bot._("permission_denied", "messages", "general", locale=ctx.locale),
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
self.bot.reload()
|
||||
await self.bot.set_status()
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_success(
|
||||
self.bot._("success_title", "messages", "general", locale=ctx.locale),
|
||||
self.bot._("config_reload_success", "messages", "admin", locale=ctx.locale),
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._("error_title", "messages", "general", locale=ctx.locale),
|
||||
self.bot._(
|
||||
"config_reload_error", "messages", "admin", locale=ctx.locale
|
||||
).format(error=exc),
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# TODO Implement i18n
|
||||
# TODO Account for rate limits
|
||||
@slash_command(
|
||||
name="clear",
|
||||
description=_("description", "commands", "clear", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "clear", locales_root=get_locales_root()
|
||||
),
|
||||
contexts={InteractionContextType.guild},
|
||||
)
|
||||
@option("amount", description="How many messages to delete", min_value=1, max_value=100)
|
||||
@option(
|
||||
"user",
|
||||
description="User whose balance to check (if not your own)",
|
||||
required=False,
|
||||
)
|
||||
@default_permissions(manage_messages=True)
|
||||
async def command_clear(
|
||||
self, ctx: ApplicationContext, amount: int, user: User = None
|
||||
) -> None:
|
||||
try:
|
||||
check: Callable[[Message], bool] = (
|
||||
(lambda msg: True) if user is None else (lambda msg: msg.author.id == user.id)
|
||||
)
|
||||
|
||||
purged_messages: List[Message] = await ctx.channel.purge(limit=amount, check=check)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed(
|
||||
description=f"Deleted {len(purged_messages)} message(s)."
|
||||
),
|
||||
ephemeral=True,
|
||||
delete_after=3.0,
|
||||
)
|
||||
except Forbidden:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
"Missing permissions",
|
||||
description="The bot does not have permission to delete messages. Please, allow the bot to delete messages and try again.",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
"Something went wrong",
|
||||
description=f"Could not delete messages:\n```\n{exc}\n```",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
bot.add_cog(CogAdmin(bot))
|
||||
99
src/javelina/cogs/cog_analytics.py
Normal file
99
src/javelina/cogs/cog_analytics.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from discord import Cog, Message
|
||||
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..enums import AnalyticsEventType
|
||||
from ..modules.database import col_analytics
|
||||
from ..modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CogAnalytics(Cog):
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
|
||||
@Cog.listener()
|
||||
async def on_message(self, message: Message) -> None:
|
||||
if (
|
||||
message.guild is None
|
||||
or message.channel is None
|
||||
or message.author is None
|
||||
or message.author.bot
|
||||
):
|
||||
return
|
||||
|
||||
await col_analytics.insert_one(
|
||||
{
|
||||
"event_type": AnalyticsEventType.GUILD_MESSAGE_SENT.value,
|
||||
"event_date": message.created_at,
|
||||
"guild_id": message.guild.id,
|
||||
"channel_id": message.channel.id,
|
||||
"message_id": message.id,
|
||||
"user_id": message.author.id,
|
||||
"is_deleted": False,
|
||||
}
|
||||
)
|
||||
|
||||
@Cog.listener()
|
||||
async def on_message_edit(self, before: Message, after: Message) -> None:
|
||||
if (
|
||||
after.guild is None
|
||||
or after.channel is None
|
||||
or after.author is None
|
||||
or after.author.bot
|
||||
):
|
||||
return
|
||||
|
||||
await col_analytics.insert_one(
|
||||
{
|
||||
"event_type": AnalyticsEventType.GUILD_MESSAGE_EDITED.value,
|
||||
"event_date": after.edited_at,
|
||||
"guild_id": after.guild.id,
|
||||
"channel_id": after.channel.id,
|
||||
"message_id": after.id,
|
||||
"user_id": after.author.id,
|
||||
}
|
||||
)
|
||||
|
||||
@Cog.listener()
|
||||
async def on_message_delete(self, message: Message) -> None:
|
||||
if (
|
||||
message.guild is None
|
||||
or message.channel is None
|
||||
or message.author is None
|
||||
or message.author.bot
|
||||
):
|
||||
return
|
||||
|
||||
await col_analytics.insert_one(
|
||||
{
|
||||
"event_type": AnalyticsEventType.GUILD_MESSAGE_DELETED.value,
|
||||
"event_date": datetime.now(tz=ZoneInfo("UTC")),
|
||||
"guild_id": message.guild.id,
|
||||
"channel_id": message.channel.id,
|
||||
"message_id": message.id,
|
||||
"user_id": message.author.id,
|
||||
}
|
||||
)
|
||||
|
||||
await col_analytics.update_one(
|
||||
{
|
||||
"event_type": AnalyticsEventType.GUILD_MESSAGE_SENT.value,
|
||||
"guild_id": message.guild.id,
|
||||
"channel_id": message.channel.id,
|
||||
"message_id": message.id,
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"is_deleted": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
bot.add_cog(CogAnalytics(bot))
|
||||
410
src/javelina/cogs/cog_consent.py
Normal file
410
src/javelina/cogs/cog_consent.py
Normal file
@@ -0,0 +1,410 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, List
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
Interaction,
|
||||
InteractionContextType,
|
||||
OptionChoice,
|
||||
SlashCommandGroup,
|
||||
option,
|
||||
)
|
||||
from discord.ext import commands
|
||||
from libbot.i18n import _, in_every_locale
|
||||
from tempora import parse_timedelta
|
||||
|
||||
from ..classes import Consent, PycordUser
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..enums import ConsentDuration, ConsentScope
|
||||
from ..utils.package_utils import get_locales_root
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CogConsent(commands.Cog):
|
||||
def __init__(self, client: PycordBot):
|
||||
self.bot: PycordBot = client
|
||||
|
||||
command_group: SlashCommandGroup = SlashCommandGroup(
|
||||
"consent",
|
||||
description=_("description", "commands", "consent", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "consent", locales_root=get_locales_root()
|
||||
),
|
||||
contexts={InteractionContextType.guild},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_scope_choices() -> List[OptionChoice]:
|
||||
choices: List[OptionChoice] = []
|
||||
|
||||
for scope in ConsentScope._member_map_.values():
|
||||
scope_value: str = scope.value
|
||||
|
||||
choices.append(
|
||||
OptionChoice(
|
||||
_(
|
||||
"name",
|
||||
"data_control",
|
||||
"scopes",
|
||||
scope_value,
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
scope_value,
|
||||
in_every_locale(
|
||||
"name",
|
||||
"data_control",
|
||||
"scopes",
|
||||
scope_value,
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
@staticmethod
|
||||
def _get_consent_durations() -> List[OptionChoice]:
|
||||
choices: List[OptionChoice] = []
|
||||
|
||||
for duration in ConsentDuration._member_map_.values():
|
||||
duration_value: str = duration.value
|
||||
|
||||
choices.append(
|
||||
OptionChoice(
|
||||
_(
|
||||
duration_value,
|
||||
"data_control",
|
||||
"consent_durations",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
duration_value,
|
||||
in_every_locale(
|
||||
duration_value,
|
||||
"data_control",
|
||||
"consent_durations",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return choices
|
||||
|
||||
# /consent terms <scope>
|
||||
# Will provide information about terms
|
||||
# TODO Implement i18n
|
||||
# TODO Implement consent duration
|
||||
@command_group.command(
|
||||
name="terms",
|
||||
description=_(
|
||||
"description", "commands", "consent_terms", locales_root=get_locales_root()
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "consent_terms", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"scope",
|
||||
description="Scope of the consent",
|
||||
choices=_get_scope_choices(),
|
||||
)
|
||||
async def command_consent_terms(self, ctx: ApplicationContext, scope: str) -> None:
|
||||
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
|
||||
"scopes"
|
||||
]
|
||||
|
||||
if scope not in scopes_config:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
|
||||
"Scope does not exist in the config!",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
scope_config: Dict[str, Any] = scopes_config[scope]
|
||||
|
||||
interaction: Interaction = await ctx.respond(
|
||||
embed=self.bot.create_embed(
|
||||
"Terms for {third_party_notice}**{scope_name}**".format(
|
||||
third_party_notice=(
|
||||
"" if not scope_config["is_third_party"] else "a third-party scope "
|
||||
),
|
||||
scope_name=self.bot._(
|
||||
"name", "data_control", "scopes", scope, locale=ctx.locale
|
||||
),
|
||||
),
|
||||
"Terms of use: {terms_url}\nPrivacy policy: {privacy_url}\n\nNote: Any consent given on this Discord server will be valid only for this server.".format(
|
||||
terms_url=scope_config["terms_url"],
|
||||
privacy_url=scope_config["privacy_url"],
|
||||
),
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# /consent give <scope> [<duration>]
|
||||
# Will provide information about terms and a button to confirm
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="give",
|
||||
description=_(
|
||||
"description", "commands", "consent_give", locales_root=get_locales_root()
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "consent_give", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"scope",
|
||||
description="Scope of the consent",
|
||||
choices=_get_scope_choices(),
|
||||
)
|
||||
@option(
|
||||
"duration",
|
||||
description="Duration of the consent",
|
||||
choices=_get_consent_durations(),
|
||||
)
|
||||
async def command_consent_give(
|
||||
self,
|
||||
ctx: ApplicationContext,
|
||||
scope: str,
|
||||
duration: str = ConsentDuration.NORMAL.value,
|
||||
) -> None:
|
||||
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
|
||||
"scopes"
|
||||
]
|
||||
|
||||
if scope not in scopes_config:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
|
||||
"Scope does not exist in the config!",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
|
||||
|
||||
is_scope_third_party: bool = self.bot.config["modules"]["consent"]["scopes"][scope][
|
||||
"is_third_party"
|
||||
]
|
||||
expiration_date: datetime = datetime.now(tz=ZoneInfo("UTC")) + parse_timedelta(
|
||||
self.bot.config["modules"]["consent"]["durations"][
|
||||
"third_party" if is_scope_third_party else "first_party"
|
||||
][duration]
|
||||
)
|
||||
|
||||
try:
|
||||
await user.give_consent(ConsentScope(scope), expiration_date, cache=self.bot.cache)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_success("Success", "Consent has been given."),
|
||||
ephemeral=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Could not give consent due to: %s", exc, exc_info=exc)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error("Error", "Something went wrong!"),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# /consent withdraw <scope>
|
||||
# Will directly withdraw consent if confirmation is provided
|
||||
# TODO Implement i18n
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="withdraw",
|
||||
description=_(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_withdraw",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_withdraw",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"scope",
|
||||
description="Scope of the consent",
|
||||
choices=_get_scope_choices(),
|
||||
)
|
||||
async def command_consent_withdraw(self, ctx: ApplicationContext, scope: str) -> None:
|
||||
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
|
||||
"scopes"
|
||||
]
|
||||
|
||||
if scope not in scopes_config:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
|
||||
"Scope does not exist in the config!",
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
|
||||
|
||||
await user.withdraw_consent(ConsentScope(scope), cache=self.bot.cache)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed("Done", "The consent has been withdrawn."),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# /consent give_all [<duration>] [<confirm>]
|
||||
# Will inform about necessity to review all scopes and a button to confirm
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="give_all",
|
||||
description=_(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_give_all",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_give_all",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"duration",
|
||||
description="Duration of the consent",
|
||||
choices=_get_consent_durations(),
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description="Confirmation of the action",
|
||||
required=False,
|
||||
)
|
||||
async def command_consent_give_all(
|
||||
self,
|
||||
ctx: ApplicationContext,
|
||||
confirm: bool = False,
|
||||
duration: str = ConsentDuration.NORMAL.value,
|
||||
) -> None:
|
||||
if not confirm:
|
||||
await ctx.respond("Operation not confirmed!", ephemeral=True)
|
||||
return
|
||||
|
||||
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
|
||||
|
||||
try:
|
||||
for scope in ConsentScope._member_map_.values():
|
||||
is_scope_third_party: bool = self.bot.config["modules"]["consent"]["scopes"][
|
||||
scope.value
|
||||
]["is_third_party"]
|
||||
expiration_date: datetime = datetime.now(tz=ZoneInfo("UTC")) + parse_timedelta(
|
||||
self.bot.config["modules"]["consent"]["durations"][
|
||||
"third_party" if is_scope_third_party else "first_party"
|
||||
][duration]
|
||||
)
|
||||
|
||||
await user.give_consent(
|
||||
ConsentScope(scope), expiration_date, cache=self.bot.cache
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Could not give consent due to: %s", exc, exc_info=exc)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error("Error", "Something went wrong!"),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_success("Success", "Consent has been given."),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# /consent withdraw_all [<confirm>]
|
||||
# Will directly withdraw all consents if confirmation is provided
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="withdraw_all",
|
||||
description=_(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_withdraw_all",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description",
|
||||
"commands",
|
||||
"consent_withdraw_all",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description="Confirmation of the action",
|
||||
required=False,
|
||||
)
|
||||
async def command_consent_withdraw_all(
|
||||
self, ctx: ApplicationContext, confirm: bool = False
|
||||
) -> None:
|
||||
if not confirm:
|
||||
await ctx.respond("Operation not confirmed!", ephemeral=True)
|
||||
return
|
||||
|
||||
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
|
||||
|
||||
await user.withdraw_all_consents(cache=self.bot.cache)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed("Done", "All consents have been withdrawn."),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
# /consent review
|
||||
# Will show all consents provided by the user, including scopes and expiration dates
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="review",
|
||||
description=_(
|
||||
"description", "commands", "consent_review", locales_root=get_locales_root()
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "consent_review", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
async def command_consent_review(self, ctx: ApplicationContext) -> None:
|
||||
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
|
||||
|
||||
consents: List[Consent] = await user.get_consents()
|
||||
|
||||
if len(consents) == 0:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed("Consents", "You have no active consents."),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
joined_consents: str = "\n".join(
|
||||
[
|
||||
f"`{consent.scope.value}` - Expires <t:{int(consent.expiration_date.timestamp())}>"
|
||||
for consent in consents
|
||||
]
|
||||
)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed("Consents", f"Active consents:\n{joined_consents}"),
|
||||
ephemeral=True,
|
||||
)
|
||||
|
||||
|
||||
def setup(client: PycordBot) -> None:
|
||||
client.add_cog(CogConsent(client))
|
||||
73
src/javelina/cogs/cog_data.py
Normal file
73
src/javelina/cogs/cog_data.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
InteractionContextType,
|
||||
SlashCommandGroup,
|
||||
option,
|
||||
)
|
||||
from discord.ext import commands
|
||||
from libbot.i18n import _, in_every_locale
|
||||
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..utils.package_utils import get_locales_root
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CogData(commands.Cog):
|
||||
def __init__(self, client: PycordBot):
|
||||
self.bot: PycordBot = client
|
||||
|
||||
command_group: SlashCommandGroup = SlashCommandGroup(
|
||||
"data",
|
||||
description=_("description", "commands", "data", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "data", locales_root=get_locales_root()
|
||||
),
|
||||
contexts={InteractionContextType.guild},
|
||||
)
|
||||
|
||||
# /data checkout
|
||||
# Export all user data in a ZIP archive
|
||||
# TODO Implement i18n
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="checkout",
|
||||
description=_(
|
||||
"description", "commands", "data_checkout", locales_root=get_locales_root()
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "data_checkout", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
async def command_data_checkout(self, ctx: ApplicationContext) -> None:
|
||||
await ctx.respond("Command is not implemented!", ephemeral=True)
|
||||
|
||||
# /data purge [<confirm>]
|
||||
# Soft-delete all user data
|
||||
# TODO Implement i18n
|
||||
# TODO Implement the command
|
||||
@command_group.command(
|
||||
name="purge",
|
||||
description=_("description", "commands", "data_purge", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "data_purge", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description="Confirmation of the action",
|
||||
required=False,
|
||||
)
|
||||
async def command_data_purge(self, ctx: ApplicationContext, confirm: bool = False) -> None:
|
||||
if not confirm:
|
||||
await ctx.respond("Operation not confirmed!", ephemeral=True)
|
||||
return
|
||||
|
||||
await ctx.respond("Command is not implemented!", ephemeral=True)
|
||||
|
||||
|
||||
def setup(client: PycordBot) -> None:
|
||||
client.add_cog(CogData(client))
|
||||
14
src/javelina/cogs/cog_organizational.py
Normal file
14
src/javelina/cogs/cog_organizational.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from discord import Cog
|
||||
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
|
||||
|
||||
class CogOrganizational(Cog):
|
||||
"""Cog with the guessing command."""
|
||||
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
bot.add_cog(CogOrganizational(bot))
|
||||
47
src/javelina/cogs/cog_utility.py
Normal file
47
src/javelina/cogs/cog_utility.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from logging import Logger
|
||||
|
||||
from discord import ApplicationContext, Cog, DiscordException
|
||||
from discord.ext.commands import CheckFailure
|
||||
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..enums import EmbedColor
|
||||
from ..modules.utils import get_logger
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CogUtility(Cog):
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
|
||||
@Cog.listener()
|
||||
async def on_ready(self) -> None:
|
||||
"""Listener for the event when bot connects to Discord and becomes "ready"."""
|
||||
logger.info("Logged in as %s", self.bot.user)
|
||||
|
||||
await self.bot.set_status()
|
||||
|
||||
# TODO Should probably also add an error message displayed to users
|
||||
@Cog.listener()
|
||||
async def on_application_command_error(
|
||||
self, ctx: ApplicationContext, error: DiscordException
|
||||
) -> None:
|
||||
if isinstance(error, CheckFailure):
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed(
|
||||
"Something went wrong", str(error), color=EmbedColor.ERROR
|
||||
),
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"An error has occurred during execution of a command %s: %s",
|
||||
ctx.command,
|
||||
error,
|
||||
exc_info=error,
|
||||
)
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
bot.add_cog(CogUtility(bot))
|
||||
125
src/javelina/cogs/cog_wallet.py
Normal file
125
src/javelina/cogs/cog_wallet.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
InteractionContextType,
|
||||
SlashCommandGroup,
|
||||
User,
|
||||
option,
|
||||
)
|
||||
from discord.ext import commands
|
||||
from libbot.i18n import _, in_every_locale
|
||||
|
||||
from ..classes.errors import WalletInsufficientFunds
|
||||
from ..classes.pycord_bot import PycordBot
|
||||
from ..classes.wallet import Wallet
|
||||
from ..enums import ConsentScope
|
||||
from ..modules.middleware import user_consent_required
|
||||
from ..utils.package_utils import get_locales_root
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CogWallet(commands.Cog):
|
||||
def __init__(self, client: PycordBot):
|
||||
self.bot: PycordBot = client
|
||||
|
||||
command_group: SlashCommandGroup = SlashCommandGroup(
|
||||
"wallet",
|
||||
description=_("description", "commands", "wallet", locales_root=get_locales_root()),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "wallet", locales_root=get_locales_root()
|
||||
),
|
||||
contexts={InteractionContextType.guild},
|
||||
)
|
||||
|
||||
@command_group.command(
|
||||
name="balance",
|
||||
description=_(
|
||||
"description", "commands", "wallet_balance", locales_root=get_locales_root()
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description", "commands", "wallet_balance", locales_root=get_locales_root()
|
||||
),
|
||||
)
|
||||
@option(
|
||||
"user",
|
||||
description="User whose balance to check (if not your own)",
|
||||
required=False,
|
||||
)
|
||||
@user_consent_required(ConsentScope.GENERAL)
|
||||
async def command_wallet_balance(self, ctx: ApplicationContext, user: User = None) -> None:
|
||||
wallet: Wallet = await Wallet.from_id(
|
||||
ctx.user.id if not user else user.id, ctx.guild_id, cache=self.bot.cache
|
||||
)
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed(
|
||||
self.bot._("balance_title", "messages", "wallet", locale=ctx.locale),
|
||||
(
|
||||
self.bot._("balance_own", "messages", "wallet", locale=ctx.locale).format(
|
||||
balance=wallet.balance
|
||||
)
|
||||
if user is None
|
||||
else self.bot._(
|
||||
"balance_user", "messages", "wallet", locale=ctx.locale
|
||||
).format(balance=wallet.balance, user=user.display_name)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@command_group.command(
|
||||
name="transfer",
|
||||
description=_(
|
||||
"description",
|
||||
"commands",
|
||||
"wallet_transfer",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
description_localizations=in_every_locale(
|
||||
"description",
|
||||
"commands",
|
||||
"wallet_transfer",
|
||||
locales_root=get_locales_root(),
|
||||
),
|
||||
)
|
||||
@option("user", description="Recipient")
|
||||
@option("amount", description="Amount", min_value=0.01)
|
||||
async def command_wallet_transfer(
|
||||
self, ctx: ApplicationContext, user: User, amount: float
|
||||
) -> None:
|
||||
amount = round(amount, 2)
|
||||
|
||||
# Guild will be needed for overdraft options
|
||||
# guild: PycordGuild = await PycordGuild.from_id(ctx.guild_id)
|
||||
wallet: Wallet = await Wallet.from_id(ctx.user.id, ctx.guild_id, cache=self.bot.cache)
|
||||
|
||||
try:
|
||||
await wallet.transfer(user.id, ctx.guild_id, amount)
|
||||
except WalletInsufficientFunds:
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_error(
|
||||
self.bot._("error_title", "messages", "general", locale=ctx.locale),
|
||||
self.bot._(
|
||||
"transfer_insufficient_funds",
|
||||
"messages",
|
||||
"wallet",
|
||||
locale=ctx.locale,
|
||||
).format(amount=round(abs(wallet.balance - amount), 2)),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.respond(
|
||||
embed=self.bot.create_embed_success(
|
||||
self.bot._("success_title", "messages", "general", locale=ctx.locale),
|
||||
self.bot._("transfer_success", "messages", "wallet", locale=ctx.locale).format(
|
||||
amount=amount, recipient=user.display_name
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def setup(client: PycordBot) -> None:
|
||||
client.add_cog(CogWallet(client))
|
||||
10
src/javelina/enums/__init__.py
Normal file
10
src/javelina/enums/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .analytics_event_type import AnalyticsEventType
|
||||
from .cache_ttl import CacheTTL
|
||||
from .consent_duration import ConsentDuration
|
||||
from .consent_scope import ConsentScope
|
||||
from .embed_color import EmbedColor
|
||||
from .guild_emote_source import GuildEmoteSource
|
||||
from .health_status import HealthStatus
|
||||
from .message_events import MessageEvents
|
||||
from .punishment import Punishment
|
||||
from .scheduled_action_type import ScheduledActionType
|
||||
7
src/javelina/enums/analytics_event_type.py
Normal file
7
src/javelina/enums/analytics_event_type.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AnalyticsEventType(Enum):
|
||||
GUILD_MESSAGE_SENT = "guild_message_sent"
|
||||
GUILD_MESSAGE_EDITED = "guild_message_edited"
|
||||
GUILD_MESSAGE_DELETED = "guild_message_deleted"
|
||||
7
src/javelina/enums/cache_ttl.py
Normal file
7
src/javelina/enums/cache_ttl.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CacheTTL(Enum):
|
||||
SHORT = 300
|
||||
NORMAL = 3600
|
||||
LONG = 86400
|
||||
7
src/javelina/enums/consent_duration.py
Normal file
7
src/javelina/enums/consent_duration.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ConsentDuration(Enum):
|
||||
SHORT = "short"
|
||||
NORMAL = "normal"
|
||||
LONG = "long"
|
||||
7
src/javelina/enums/consent_scope.py
Normal file
7
src/javelina/enums/consent_scope.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ConsentScope(Enum):
|
||||
GENERAL = "general"
|
||||
INTEGRATION_DEEPL = "integration_deepl"
|
||||
INTEGRATION_7TV = "integration_7tv"
|
||||
9
src/javelina/enums/embed_color.py
Normal file
9
src/javelina/enums/embed_color.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EmbedColor(Enum):
|
||||
PRIMARY = "primary"
|
||||
SECONDARY = "secondary"
|
||||
SUCCESS = "success"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
5
src/javelina/enums/guild_emote_source.py
Normal file
5
src/javelina/enums/guild_emote_source.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from enum import StrEnum, auto
|
||||
|
||||
|
||||
class GuildEmoteSource(StrEnum):
|
||||
SEVEN_TV = auto()
|
||||
8
src/javelina/enums/health_status.py
Normal file
8
src/javelina/enums/health_status.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class HealthStatus(Enum):
|
||||
OPERATIONAL = "operational"
|
||||
DEGRADED = "degraded"
|
||||
FAILED = "failed"
|
||||
UNKNOWN = "unknown"
|
||||
5
src/javelina/enums/message_events.py
Normal file
5
src/javelina/enums/message_events.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MessageEvents(Enum):
|
||||
WEATHER_FORECAST = 0
|
||||
8
src/javelina/enums/punishment.py
Normal file
8
src/javelina/enums/punishment.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Punishment(Enum):
|
||||
WARNING = 0
|
||||
MUTE = 1
|
||||
KICK = 2
|
||||
BAN = 3
|
||||
5
src/javelina/enums/scheduled_action_type.py
Normal file
5
src/javelina/enums/scheduled_action_type.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ScheduledActionType(Enum):
|
||||
WEATHER_REPORT = 0
|
||||
1
src/javelina/integrations/__init__.py
Normal file
1
src/javelina/integrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import seven_tv
|
||||
1
src/javelina/integrations/seven_tv/__init__.py
Normal file
1
src/javelina/integrations/seven_tv/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .classes import EventHandler7TV # noqa
|
||||
4
src/javelina/integrations/seven_tv/classes/__init__.py
Normal file
4
src/javelina/integrations/seven_tv/classes/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .emote_7tv import Emote7TV
|
||||
from .event_emote import EventEmote
|
||||
from .event_handler_7tv import EventHandler7TV
|
||||
from .set_7tv import Set7TV
|
||||
126
src/javelina/integrations/seven_tv/classes/emote_7tv.py
Normal file
126
src/javelina/integrations/seven_tv/classes/emote_7tv.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from bson import ObjectId
|
||||
from libbot.cache.classes import Cache
|
||||
from pymongo.results import InsertOneResult
|
||||
|
||||
from ....classes.base import BaseCacheable
|
||||
from ....modules.database import col_int_7tv_emotes
|
||||
from ....modules.utils import restore_from_cache
|
||||
|
||||
|
||||
@dataclass
|
||||
class Emote7TV(BaseCacheable):
|
||||
"""Dataclass of DB entry of a 7TV emote"""
|
||||
|
||||
__slots__ = (
|
||||
"_id",
|
||||
"id",
|
||||
"emote_name",
|
||||
"emote_set_id",
|
||||
"created",
|
||||
"modified",
|
||||
)
|
||||
__short_name__ = "7tv_emote"
|
||||
__collection__ = col_int_7tv_emotes
|
||||
|
||||
_id: ObjectId
|
||||
id: int
|
||||
emote_name: str
|
||||
emote_set_id: str
|
||||
created: datetime
|
||||
modified: datetime
|
||||
|
||||
@classmethod
|
||||
async def from_id(
|
||||
cls,
|
||||
id: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "Emote7TV":
|
||||
cached_entry: Dict[str, Any] | None = restore_from_cache(
|
||||
cls.__short_name__, id, cache=cache
|
||||
)
|
||||
|
||||
if cached_entry is not None:
|
||||
return cls(**cls._entry_from_cache(cached_entry))
|
||||
|
||||
db_entry = await cls.__collection__.find_one({"id": id})
|
||||
|
||||
# TODO Replace with a real exception
|
||||
if db_entry is None:
|
||||
raise RuntimeError(f"7TV emote with ID {id} was not found")
|
||||
# raise EmoteNotFoundError(id)
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
id: str,
|
||||
emote_name: str,
|
||||
emote_set_id: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> "Emote7TV":
|
||||
db_entry: Dict[str, Any] = {
|
||||
"id": id,
|
||||
"emote_name": emote_name,
|
||||
"emote_set_id": emote_set_id,
|
||||
"created": datetime.now(tz=timezone.utc),
|
||||
"modified": datetime.now(tz=timezone.utc),
|
||||
}
|
||||
|
||||
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
|
||||
|
||||
db_entry["_id"] = insert_result.inserted_id
|
||||
|
||||
if cache is not None:
|
||||
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
|
||||
|
||||
return cls(**db_entry)
|
||||
|
||||
def _get_cache_key(self) -> str:
|
||||
return f"{self.__short_name__}_{self.id}"
|
||||
|
||||
@staticmethod
|
||||
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
|
||||
"""Convert Emote7TV object to a JSON representation.
|
||||
|
||||
Args:
|
||||
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: JSON representation of Emote7TV
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def get_defaults(
|
||||
owner_id: Optional[int] = None, guild_id: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
def get_default_value(key: str) -> Any:
|
||||
if key not in Emote7TV.get_defaults():
|
||||
raise KeyError(f"There's no default value for key '{key}' in Emote7TV")
|
||||
|
||||
return Emote7TV.get_defaults()[key]
|
||||
|
||||
async def update_name(
|
||||
self,
|
||||
name: str,
|
||||
cache: Optional[Cache] = None,
|
||||
) -> None:
|
||||
await self._set(cache=cache, emote_name=name)
|
||||
84
src/javelina/integrations/seven_tv/classes/event_emote.py
Normal file
84
src/javelina/integrations/seven_tv/classes/event_emote.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from logging import Logger
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiohttp import ClientSession
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventEmote:
|
||||
id: str
|
||||
name: str
|
||||
set_id: str
|
||||
_data: Dict[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any], set_id: str) -> "EventEmote":
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
set_id=set_id,
|
||||
_data=data.get("data", {}),
|
||||
)
|
||||
|
||||
def get_best_image_name(self) -> Optional[str]:
|
||||
files: List[Dict[str, Any]] = self._data.get("host", {}).get("files", [])
|
||||
is_animated: bool = self._data.get("animated", False)
|
||||
|
||||
# Priority order for formats (higher index = higher priority)
|
||||
format_priority: Dict[str, int] = {"GIF": 3, "PNG": 2} if is_animated else {"PNG": 2}
|
||||
|
||||
# Scale multipliers ordered from smallest to largest
|
||||
scale_order: List[str] = ["1x", "2x", "3x", "4x"]
|
||||
scale_index: Dict[str, int] = {scale: idx for idx, scale in enumerate(scale_order)}
|
||||
|
||||
# Find the best format based on priority
|
||||
best_image_name: str | None = None
|
||||
best_format_priority: int = -1
|
||||
best_scale_index: int = -1
|
||||
|
||||
for file in files:
|
||||
image_name: str = file.get("name", "")
|
||||
image_size: int = file.get("size", 0)
|
||||
format_name: str = file.get("format", "")
|
||||
scale_name: str = file.get("name", "").split(".")[0] # Extract scale like
|
||||
|
||||
if format_name not in format_priority:
|
||||
continue
|
||||
|
||||
current_priority: int = format_priority[format_name]
|
||||
current_scale_idx: int = scale_index.get(scale_name, -1)
|
||||
|
||||
# Update best match: higher priority wins, or same priority with larger scale
|
||||
if (
|
||||
current_priority > best_format_priority
|
||||
or (
|
||||
current_priority == best_format_priority
|
||||
and current_scale_idx > best_scale_index
|
||||
)
|
||||
) and image_size <= 262144:
|
||||
best_image_name = image_name
|
||||
best_format_priority = current_priority
|
||||
best_scale_index = current_scale_idx
|
||||
|
||||
logger.debug(f"Found the best format for the emote {self.id}: {best_image_name}")
|
||||
|
||||
return best_image_name
|
||||
|
||||
# TODO Review whether this is sufficient for Discord emoji requirements
|
||||
async def get_url(self) -> str:
|
||||
image_name: str | None = self.get_best_image_name()
|
||||
|
||||
if image_name is None:
|
||||
raise ValueError("No supported format is available.")
|
||||
|
||||
return f"https:{self._data['host']['url']}/{image_name}"
|
||||
|
||||
async def fetch_image(self) -> bytes:
|
||||
image_url: str = await self.get_url()
|
||||
|
||||
async with ClientSession() as session:
|
||||
return await (await session.get(image_url)).read()
|
||||
@@ -0,0 +1,94 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
|
||||
from eventapi import Dispatch, EventType, ResponseTypes
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ....classes.pycord_bot import PycordBot
|
||||
from ..integration import (
|
||||
add_emote_to_guilds,
|
||||
delete_emote_in_guilds,
|
||||
update_emote_in_guilds,
|
||||
)
|
||||
from . import Emote7TV, EventEmote
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventHandler7TV:
|
||||
def __init__(self, bot: PycordBot, app: FastAPI):
|
||||
self.bot: PycordBot = bot
|
||||
self.app: FastAPI = app
|
||||
|
||||
async def handle_event(self, data: ResponseTypes) -> None:
|
||||
if not isinstance(data, Dispatch):
|
||||
return
|
||||
|
||||
if data.type != EventType.EMOTE_SET_UPDATE:
|
||||
return
|
||||
|
||||
emote_set_id: str = data.body.id
|
||||
|
||||
logger.debug("Got an event for the 7TV set %s, processing...", emote_set_id)
|
||||
|
||||
if data.body.pulled:
|
||||
for pulled in data.body.pulled:
|
||||
if pulled.old_value is None:
|
||||
continue
|
||||
|
||||
emote: EventEmote = EventEmote.from_dict(pulled.old_value, emote_set_id)
|
||||
|
||||
try:
|
||||
int_emote: Emote7TV = await Emote7TV.from_id(pulled.old_value["id"])
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
await int_emote.purge()
|
||||
|
||||
# Process emote for each guild
|
||||
await delete_emote_in_guilds(self.bot, emote, emote_set_id)
|
||||
|
||||
if data.body.pushed:
|
||||
for pushed in data.body.pushed:
|
||||
if pushed.value is None or isinstance(pushed.value, list):
|
||||
continue
|
||||
|
||||
emote: EventEmote = EventEmote.from_dict(pushed.value, emote_set_id)
|
||||
|
||||
await Emote7TV.create(
|
||||
pushed.value["id"],
|
||||
pushed.value["name"],
|
||||
emote_set_id,
|
||||
)
|
||||
|
||||
await add_emote_to_guilds(self.bot, emote, emote_set_id)
|
||||
|
||||
if data.body.updated:
|
||||
for updated in data.body.updated:
|
||||
if (
|
||||
updated.old_value is None
|
||||
or updated.value is None
|
||||
or isinstance(updated.value, list)
|
||||
):
|
||||
continue
|
||||
|
||||
emote_old: EventEmote = EventEmote.from_dict(updated.old_value, emote_set_id)
|
||||
emote_new: EventEmote = EventEmote.from_dict(updated.value, emote_set_id)
|
||||
|
||||
# TODO Review possibility of image changes
|
||||
if emote_old.name == emote_new.name:
|
||||
continue
|
||||
|
||||
try:
|
||||
int_emote: Emote7TV = await Emote7TV.from_id(emote_old.id)
|
||||
except RuntimeError:
|
||||
await Emote7TV.create(
|
||||
emote_old.id,
|
||||
emote_new.name,
|
||||
emote_set_id,
|
||||
)
|
||||
return
|
||||
|
||||
await int_emote.update_name(emote_new.name, cache=self.bot.cache)
|
||||
|
||||
await update_emote_in_guilds(self.bot, emote_old, emote_new, emote_set_id)
|
||||
20
src/javelina/integrations/seven_tv/classes/set_7tv.py
Normal file
20
src/javelina/integrations/seven_tv/classes/set_7tv.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bson import ObjectId
|
||||
|
||||
from ....classes.base import BaseCacheable
|
||||
from ....enums import GuildEmoteSource
|
||||
from ....modules.database import col_int_7tv_sets
|
||||
|
||||
|
||||
@dataclass
|
||||
class Set7TV(BaseCacheable):
|
||||
"""Dataclass of DB entry of a 7TV emote set"""
|
||||
|
||||
__slots__ = ("_id", "id")
|
||||
__short_name__ = "7tv_set"
|
||||
__collection__ = col_int_7tv_sets
|
||||
|
||||
_id: ObjectId
|
||||
id: str
|
||||
source: GuildEmoteSource
|
||||
182
src/javelina/integrations/seven_tv/cogs/cog_7tv.py
Normal file
182
src/javelina/integrations/seven_tv/cogs/cog_7tv.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from logging import Logger
|
||||
from typing import Dict, List
|
||||
|
||||
from discord import (
|
||||
ApplicationContext,
|
||||
Cog,
|
||||
InteractionContextType,
|
||||
SlashCommandGroup,
|
||||
option,
|
||||
)
|
||||
from eventapi import EventApi, EventType, SubscriptionCondition, SubscriptionData
|
||||
|
||||
from ....classes import PycordGuild
|
||||
from ....classes.pycord_bot import PycordBot
|
||||
from ....enums import ConsentScope
|
||||
from ....modules.database import col_guilds
|
||||
from ....modules.middleware import user_consent_required
|
||||
from ....modules.utils import get_logger
|
||||
from ..classes.event_handler_7tv import EventHandler7TV
|
||||
|
||||
logger: Logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Cog7TV(Cog):
|
||||
"""Cog with the guessing command."""
|
||||
|
||||
def __init__(self, bot: PycordBot):
|
||||
self.bot: PycordBot = bot
|
||||
self.event_handler: EventHandler7TV
|
||||
self.emotes_event_api: EventApi
|
||||
self.subscribed_7tv_sets: Dict[str, List[int]] = {}
|
||||
|
||||
def set_event_handler(self, event_handler: EventHandler7TV) -> None:
|
||||
self.event_handler = event_handler
|
||||
|
||||
def set_event_api(self, event_api: EventApi) -> None:
|
||||
self.emotes_event_api = event_api
|
||||
|
||||
# TODO Implement i18n
|
||||
command_group: SlashCommandGroup = SlashCommandGroup(
|
||||
"emotes",
|
||||
description="Guild's emote set management",
|
||||
contexts={InteractionContextType.guild},
|
||||
)
|
||||
|
||||
@Cog.listener()
|
||||
async def on_ready(self) -> None:
|
||||
if not self.bot.is_integration_enabled("7tv"):
|
||||
return
|
||||
|
||||
logger.debug("Initializing the 7TV integration...")
|
||||
await self.init_7tv_integration()
|
||||
logger.info("Initialized the 7TV integration")
|
||||
|
||||
def _add_7tv_subscription(self, emote_set_id: str, guild_id: int) -> None:
|
||||
if emote_set_id not in self.subscribed_7tv_sets.keys():
|
||||
self.subscribed_7tv_sets[emote_set_id] = []
|
||||
|
||||
self.subscribed_7tv_sets[emote_set_id].append(guild_id)
|
||||
|
||||
def _remove_7tv_subscription(self, emote_set_id: str, guild_id: int) -> None:
|
||||
if emote_set_id not in self.subscribed_7tv_sets.keys():
|
||||
return
|
||||
|
||||
self.subscribed_7tv_sets[emote_set_id].remove(guild_id)
|
||||
|
||||
if len(self.subscribed_7tv_sets[emote_set_id]) == 0:
|
||||
self.subscribed_7tv_sets.pop(emote_set_id, None)
|
||||
|
||||
async def subscribe_7tv_set(self, emote_set_id: str, guild_id: int) -> None:
|
||||
if self.emotes_event_api is None:
|
||||
return
|
||||
|
||||
self._add_7tv_subscription(emote_set_id, guild_id)
|
||||
|
||||
subscription_condition: SubscriptionCondition = SubscriptionCondition(
|
||||
object_id=emote_set_id
|
||||
)
|
||||
subscription_data: SubscriptionData = SubscriptionData(
|
||||
subscription_type=EventType.EMOTE_SET_ALL, condition=subscription_condition
|
||||
)
|
||||
|
||||
await self.emotes_event_api.subscribe(subscription_data=subscription_data)
|
||||
|
||||
async def unsubscribe_7tv_set(self, emote_set_id: str, guild_id: int) -> None:
|
||||
if self.emotes_event_api is None:
|
||||
return
|
||||
|
||||
self._remove_7tv_subscription(emote_set_id, guild_id)
|
||||
|
||||
if emote_set_id not in self.subscribed_7tv_sets.keys():
|
||||
subscription_condition: SubscriptionCondition = SubscriptionCondition(
|
||||
object_id=emote_set_id
|
||||
)
|
||||
subscription_data: SubscriptionData = SubscriptionData(
|
||||
subscription_type=EventType.EMOTE_SET_ALL,
|
||||
condition=subscription_condition,
|
||||
)
|
||||
|
||||
await self.emotes_event_api.unsubscribe(subscription_data)
|
||||
|
||||
async def init_7tv_integration(self) -> None:
|
||||
if self.emotes_event_api is None:
|
||||
return
|
||||
|
||||
await self.emotes_event_api.connect()
|
||||
|
||||
# This entire block has to be replaced with something that would
|
||||
# allow the bot to only subscribe to emote sets that
|
||||
# guilds actually need to function properly
|
||||
async for guild in col_guilds.find(
|
||||
{"emote_set_id": {"$ne": None}}, {"_id": 0, "id": 1, "emote_set_id": 1}
|
||||
):
|
||||
logger.info(
|
||||
"Guild %s is using 7TV emote set %s, adding to subscriptions...",
|
||||
guild["id"],
|
||||
guild["emote_set_id"],
|
||||
)
|
||||
|
||||
self._add_7tv_subscription(guild["emote_set_id"], guild["id"])
|
||||
|
||||
for emote_set_id in self.subscribed_7tv_sets.keys():
|
||||
logger.info("Subscribing to the 7TV set %s", emote_set_id)
|
||||
|
||||
subscription_condition: SubscriptionCondition = SubscriptionCondition(
|
||||
object_id=emote_set_id
|
||||
)
|
||||
subscription_data: SubscriptionData = SubscriptionData(
|
||||
subscription_type=EventType.EMOTE_SET_ALL,
|
||||
condition=subscription_condition,
|
||||
)
|
||||
|
||||
await self.emotes_event_api.subscribe(subscription_data=subscription_data)
|
||||
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="set",
|
||||
description="Set a 7TV emotes set to use in the guild",
|
||||
)
|
||||
@option("set_id", description="ID of the 7TV emotes set", required=False)
|
||||
@user_consent_required(ConsentScope.INTEGRATION_7TV)
|
||||
async def command_emotes_set(self, ctx: ApplicationContext, set_id: str) -> None:
|
||||
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
|
||||
|
||||
if guild.emote_set_id == set_id:
|
||||
await ctx.respond(
|
||||
"You can't provide the same set ID as is already in use.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
await guild.set_emote_set_id(set_id, self, self.bot.cache)
|
||||
|
||||
await ctx.respond(f"Subscribed to the emote set {set_id}")
|
||||
|
||||
# TODO Implement i18n
|
||||
@command_group.command(
|
||||
name="reset",
|
||||
description="Reset a 7TV emotes set used in the guild",
|
||||
)
|
||||
@option(
|
||||
"confirm",
|
||||
description="Confirmation of the action",
|
||||
required=False,
|
||||
)
|
||||
@user_consent_required(ConsentScope.INTEGRATION_7TV)
|
||||
async def command_emotes_reset(
|
||||
self, ctx: ApplicationContext, confirm: bool = False
|
||||
) -> None:
|
||||
if not confirm:
|
||||
await ctx.respond("Operation not confirmed!", ephemeral=True)
|
||||
return
|
||||
|
||||
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
|
||||
|
||||
await guild.reset_emote_set_id(self, self.bot.cache)
|
||||
|
||||
await ctx.respond("Unsubscribed from the emote set")
|
||||
|
||||
|
||||
def setup(bot: PycordBot) -> None:
|
||||
bot.add_cog(Cog7TV(bot))
|
||||
224
src/javelina/integrations/seven_tv/integration.py
Normal file
224
src/javelina/integrations/seven_tv/integration.py
Normal file
@@ -0,0 +1,224 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
from typing import List
|
||||
|
||||
from discord import Guild, GuildEmoji
|
||||
from discord.errors import Forbidden, HTTPException
|
||||
|
||||
from ...classes import PycordGuildEmote
|
||||
from ...classes.pycord_bot import PycordBot
|
||||
from ...enums.guild_emote_source import GuildEmoteSource
|
||||
from ...modules.database import col_guilds
|
||||
from .classes import EventEmote
|
||||
|
||||
logger: Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_guild_ids_using_set(emote_set_id: str) -> List[int]:
|
||||
guild_ids: List[int] = []
|
||||
|
||||
async for guild in col_guilds.find({"emote_set_id": emote_set_id}, {"_id": 0, "id": 1}):
|
||||
guild_ids.append(guild["id"])
|
||||
|
||||
return guild_ids
|
||||
|
||||
|
||||
async def add_emote_to_guilds(bot: PycordBot, emote: EventEmote, emote_set_id: str) -> None:
|
||||
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
|
||||
|
||||
for guild_id in guild_ids:
|
||||
logging.debug(
|
||||
"Adding a 7TV emote %s from the set %s to the guild %s",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
|
||||
guild: Guild | None = bot.get_guild(guild_id)
|
||||
|
||||
if guild is None:
|
||||
logger.error(
|
||||
"Could not add 7TV emote %s from the set %s to the guild %s because the bot could not fetch the guild",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if len(guild.emojis) >= guild.emoji_limit:
|
||||
logger.error(
|
||||
"Could not add 7TV emote %s from the set %s to the guild %s because the emoji limit was reached",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
discord_emoji: GuildEmoji = await guild.create_custom_emoji(
|
||||
name=emote.name, image=await emote.fetch_image()
|
||||
)
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Could not add 7TV emote %s from the set %s to the guild %s because the emote does not contain a valid URL",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
continue
|
||||
except Forbidden as exc:
|
||||
logger.error(
|
||||
"Could not add 7TV emote %s from the set %s to the guild %s because the bot is missing permissions",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
continue
|
||||
except HTTPException as exc:
|
||||
logger.error(
|
||||
"Could not add 7TV emote %s from the set %s to the guild %s due to: %s",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
continue
|
||||
|
||||
await PycordGuildEmote.create(
|
||||
discord_emoji.id,
|
||||
guild_id,
|
||||
emote.name,
|
||||
GuildEmoteSource.SEVEN_TV,
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
cache=bot.cache,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Added a 7TV emote {emote.id} ({emote.name}) from the set {emote_set_id} to the guild {guild_id}"
|
||||
)
|
||||
|
||||
|
||||
async def update_emote_in_guilds(
|
||||
bot: PycordBot, emote_old: EventEmote, emote_new: EventEmote, emote_set_id: str
|
||||
) -> None:
|
||||
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
|
||||
|
||||
for guild_id in guild_ids:
|
||||
try:
|
||||
guild_emote: PycordGuildEmote = await PycordGuildEmote.from_guild_and_source_id(
|
||||
guild_id, emote_old.id
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.error(
|
||||
"Could not update 7TV emote %s from the set %s in the guild %s due to: %s",
|
||||
emote_old.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
continue
|
||||
|
||||
guild: Guild | None = bot.get_guild(guild_id)
|
||||
|
||||
if guild is None:
|
||||
logger.error(
|
||||
"Could not update 7TV emote %s from the set %s in the guild %s because the bot could not fetch the guild",
|
||||
emote_old.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
continue
|
||||
|
||||
await guild_emote.update_name(emote_new.name, cache=bot.cache)
|
||||
|
||||
discord_emoji: GuildEmoji | None = guild.get_emoji(guild_emote.id)
|
||||
|
||||
if discord_emoji is None:
|
||||
logger.error(
|
||||
"Could not update 7TV emote %s from the set %s in the guild %s because the bot could not find the emoji",
|
||||
emote_old.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await discord_emoji.edit(name=emote_new.name)
|
||||
except Forbidden as exc:
|
||||
logger.error(
|
||||
"Could not update 7TV emote %s from the set %s in the guild %s because the bot is missing permissions",
|
||||
emote_old.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
continue
|
||||
except HTTPException as exc:
|
||||
logger.error(
|
||||
"Could not update 7TV emote %s from the set %s in the guild %s due to: %s",
|
||||
emote_old.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
|
||||
async def delete_emote_in_guilds(bot: PycordBot, emote: EventEmote, emote_set_id: str) -> None:
|
||||
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
|
||||
|
||||
for guild_id in guild_ids:
|
||||
guild_emote: PycordGuildEmote = await PycordGuildEmote.from_guild_and_source_id(
|
||||
guild_id, emote.id
|
||||
)
|
||||
|
||||
guild: Guild | None = bot.get_guild(guild_id)
|
||||
|
||||
if guild is None:
|
||||
logger.error(
|
||||
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot could not fetch the guild",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
await guild_emote.purge(cache=bot.cache)
|
||||
continue
|
||||
|
||||
discord_emoji: GuildEmoji | None = guild.get_emoji(guild_emote.id)
|
||||
|
||||
if discord_emoji is None:
|
||||
logger.error(
|
||||
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot could not find the emoji",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
)
|
||||
await guild_emote.purge(cache=bot.cache)
|
||||
continue
|
||||
|
||||
try:
|
||||
await discord_emoji.delete()
|
||||
except Forbidden as exc:
|
||||
logger.error(
|
||||
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot is missing permissions",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc_info=exc,
|
||||
)
|
||||
continue
|
||||
except HTTPException as exc:
|
||||
logger.error(
|
||||
"Could not delete 7TV emote %s from the set %s from the guild %s due to: %s",
|
||||
emote.id,
|
||||
emote_set_id,
|
||||
guild_id,
|
||||
exc,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
await guild_emote.purge(cache=bot.cache)
|
||||
189
src/javelina/locale/de.json
Normal file
189
src/javelina/locale/de.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "German",
|
||||
"native_name": "Deutsch"
|
||||
},
|
||||
"messages": {
|
||||
"general": {
|
||||
"permission_denied": "You're not allowed to perform this operation.",
|
||||
"permission_denied_title": "Permission denied",
|
||||
"error_title": "Something went wrong",
|
||||
"success_title": "Success",
|
||||
"warning_title": "Warning"
|
||||
},
|
||||
"admin": {
|
||||
"config_reload_success": "Bot's configuration has been reloaded.",
|
||||
"config_reload_error": "Could not reload the bot's configuration:\n```\n{error}\n```"
|
||||
},
|
||||
"data_control": {
|
||||
"consent_required": "Consent to following scope(s) is required to access the command: {scopes}\n\nYou can use following commands to manage your consents:\n`/consent terms <scope>` - Review the terms for a consent\n`/consent give <scope>` - Give consent for a scope"
|
||||
},
|
||||
"wallet": {
|
||||
"balance_title": "Balance",
|
||||
"balance_own": "Your balance is `{balance}`.",
|
||||
"balance_user": "**{user}**'s balance is `{balance}`.",
|
||||
"transfer_success": "You have transferred `{amount}` to **{recipient}**.",
|
||||
"transfer_insufficient_funds": "Insufficient funds. `{amount}` more is needed for this transaction."
|
||||
},
|
||||
"reload": {
|
||||
"reload_success": "Configuration has been successfully reloaded.",
|
||||
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
|
||||
},
|
||||
"welcome": {
|
||||
"morning": [
|
||||
"{0} Добрий ранок та ласкаво просимо! {1}"
|
||||
],
|
||||
"midday": [
|
||||
"{0} Добрий день! Ласкаво просимо! {1}"
|
||||
],
|
||||
"evening": [
|
||||
"{0} Добрий вечір! Ласкаво просимо! {1}"
|
||||
],
|
||||
"night": [
|
||||
"{0} Доброї ночі! Ласкаво просимо! {1}"
|
||||
],
|
||||
"unknown": [
|
||||
"{0} Вітаннячко! Ласкаво просимо! {1}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"data_control": {
|
||||
"scopes": {
|
||||
"general": {
|
||||
"name": "General",
|
||||
"description": ""
|
||||
},
|
||||
"integration_deepl": {
|
||||
"name": "Integration: DeepL",
|
||||
"description": ""
|
||||
},
|
||||
"integration_7tv": {
|
||||
"name": "Integration: 7TV",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"consent_durations": {
|
||||
"short": "Short",
|
||||
"normal": "Normal",
|
||||
"long": "Long"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"reload": {
|
||||
"description": "Reload bot's configuration"
|
||||
},
|
||||
"clear": {
|
||||
"description": "Delete some messages in the current channel"
|
||||
},
|
||||
"consent": {
|
||||
"description": "Consent management"
|
||||
},
|
||||
"consent_terms": {
|
||||
"description": "View terms for the consent scope"
|
||||
},
|
||||
"consent_give": {
|
||||
"description": "Give consent to the scope"
|
||||
},
|
||||
"consent_withdraw": {
|
||||
"description": "Withdraw consent to the scope"
|
||||
},
|
||||
"consent_give_all": {
|
||||
"description": "Give consent to all scopes"
|
||||
},
|
||||
"consent_withdraw_all": {
|
||||
"description": "Withdraw consent to all scopes"
|
||||
},
|
||||
"consent_review": {
|
||||
"description": "Review all given consents"
|
||||
},
|
||||
"data": {
|
||||
"description": "Data management"
|
||||
},
|
||||
"data_checkout": {
|
||||
"description": "Checkout all user data"
|
||||
},
|
||||
"data_purge": {
|
||||
"description": "Delete all user data"
|
||||
},
|
||||
"wallet": {
|
||||
"description": "Wallet management"
|
||||
},
|
||||
"wallet_balance": {
|
||||
"description": "View wallet's balance"
|
||||
},
|
||||
"wallet_transfer": {
|
||||
"description": "Transfer money from the wallet"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"bite": {
|
||||
"name": "Вкусити",
|
||||
"text": "**{user_name}** робить кусь **{target_name}**"
|
||||
},
|
||||
"poke": {
|
||||
"name": "Тикнути",
|
||||
"text": "**{user_name}** тикає в **{target_name}**"
|
||||
},
|
||||
"hug": {
|
||||
"name": "Обійняти",
|
||||
"text": "**{user_name}** обіймає **{target_name}**"
|
||||
},
|
||||
"kiss": {
|
||||
"name": "Поцілувати",
|
||||
"text": "**{user_name}** цілує **{target_name}**"
|
||||
},
|
||||
"lick": {
|
||||
"name": "Лизнути",
|
||||
"text": "**{user_name}** лиже **{target_name}**"
|
||||
},
|
||||
"pat": {
|
||||
"name": "Погладити",
|
||||
"text": "**{user_name}** гладить **{target_name}**"
|
||||
},
|
||||
"wave": {
|
||||
"name": "Помахати",
|
||||
"text": "**{user_name}** махає **{target_name}**"
|
||||
},
|
||||
"wink": {
|
||||
"name": "Підморгнути",
|
||||
"text": "**{user_name}** підморгує **{target_name}**"
|
||||
}
|
||||
},
|
||||
"tracking": {
|
||||
"dhl": {
|
||||
"statuses": {
|
||||
"AA": "Departed from the hub",
|
||||
"AE": "Pickup successful",
|
||||
"AN": "Pickup not successful",
|
||||
"BV": "Exception occurred",
|
||||
"DD": "Data service",
|
||||
"EE": "Arrived at the hub",
|
||||
"ES": "First processed by DHL",
|
||||
"GT": "Money transfer",
|
||||
"LA": "In storage",
|
||||
"NB": "Processing during transit",
|
||||
"PO": "In delivery",
|
||||
"VA": "Electronic pre-advise",
|
||||
"ZF": "Delivery",
|
||||
"ZN": "Delivery not successful",
|
||||
"ZO": "Customs clearance",
|
||||
"ZU": "Delivery successful"
|
||||
},
|
||||
"messages": {
|
||||
"DHL PAKET (parcel)": "DHL PAKET (посилка)",
|
||||
"The shipment was prepared for onward transport.": "Вантаж підготовлено до подальшого транспортування.",
|
||||
"Warenpost (Merchandise Shipment)": "Варенпост (відвантаження товарів)",
|
||||
"The shipment has been processed in the parcel center": "Відправлення пройшло обробку в посилковому центрі",
|
||||
"Unfortunately, the shipment could not be delivered today due to a strike action.": "На жаль, сьогодні вантаж не вдалося доставити через страйк.",
|
||||
"Die Sendung wurde von DHL abgeholt.": "Відправлення забрала компанія DHL.",
|
||||
"The shipment has been successfully delivered": "Відправлення успішно доставлено",
|
||||
"The shipment could not be delivered, and the recipient has been notified": "Відправлення не вдалося доставити, одержувача було повідомлено про це",
|
||||
"The shipment has been loaded onto the delivery vehicle": "Вантаж завантажено на транспортний засіб доставки",
|
||||
"The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.": "Відправлення прибуло в регіон одержувача і буде доставлено на базу доставки на наступному кроці",
|
||||
"The shipment has been processed in the parcel center of origin": "Відправлення оброблено в центрі відправлення посилок",
|
||||
"The shipment has been posted by the sender at the retail outlet": "Відправлення відправлено відправником у торговій точці",
|
||||
"The instruction data for this shipment have been provided by the sender to DHL electronically": "Дані інструкцій для цього відправлення були надані DHL відправником в електронному вигляді"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
189
src/javelina/locale/en-US.json
Normal file
189
src/javelina/locale/en-US.json
Normal file
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "English, US",
|
||||
"native_name": "English, US"
|
||||
},
|
||||
"messages": {
|
||||
"general": {
|
||||
"permission_denied": "You're not allowed to perform this operation.",
|
||||
"permission_denied_title": "Permission denied",
|
||||
"error_title": "Something went wrong",
|
||||
"success_title": "Success",
|
||||
"warning_title": "Warning"
|
||||
},
|
||||
"admin": {
|
||||
"config_reload_success": "Bot's configuration has been reloaded.",
|
||||
"config_reload_error": "Could not reload the bot's configuration:\n```\n{error}\n```"
|
||||
},
|
||||
"data_control": {
|
||||
"consent_required": "Consent to following scope(s) is required to access the command: {scopes}\n\nYou can use following commands to manage your consents:\n`/consent terms <scope>` - Review the terms for a consent\n`/consent give <scope>` - Give consent for a scope"
|
||||
},
|
||||
"wallet": {
|
||||
"balance_title": "Balance",
|
||||
"balance_own": "Your balance is `{balance}`.",
|
||||
"balance_user": "**{user}**'s balance is `{balance}`.",
|
||||
"transfer_success": "You have transferred `{amount}` to **{recipient}**.",
|
||||
"transfer_insufficient_funds": "Insufficient funds. `{amount}` more is needed for this transaction."
|
||||
},
|
||||
"reload": {
|
||||
"reload_success": "Configuration has been successfully reloaded.",
|
||||
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
|
||||
},
|
||||
"welcome": {
|
||||
"morning": [
|
||||
"{0} Good morning and welcome! {1}"
|
||||
],
|
||||
"midday": [
|
||||
"{0} Good day and welcome! {1}"
|
||||
],
|
||||
"evening": [
|
||||
"{0} Good evening and welcome! {1}"
|
||||
],
|
||||
"night": [
|
||||
"{0} Good night and welcome! {1}"
|
||||
],
|
||||
"unknown": [
|
||||
"{0} Hello and welcome! {1}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"data_control": {
|
||||
"scopes": {
|
||||
"general": {
|
||||
"name": "General",
|
||||
"description": ""
|
||||
},
|
||||
"integration_deepl": {
|
||||
"name": "Integration: DeepL",
|
||||
"description": ""
|
||||
},
|
||||
"integration_7tv": {
|
||||
"name": "Integration: 7TV",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"consent_durations": {
|
||||
"short": "Short",
|
||||
"normal": "Normal",
|
||||
"long": "Long"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"reload": {
|
||||
"description": "Reload bot's configuration"
|
||||
},
|
||||
"clear": {
|
||||
"description": "Delete some messages in the current channel"
|
||||
},
|
||||
"consent": {
|
||||
"description": "Consent management"
|
||||
},
|
||||
"consent_terms": {
|
||||
"description": "View terms for the consent scope"
|
||||
},
|
||||
"consent_give": {
|
||||
"description": "Give consent to the scope"
|
||||
},
|
||||
"consent_withdraw": {
|
||||
"description": "Withdraw consent to the scope"
|
||||
},
|
||||
"consent_give_all": {
|
||||
"description": "Give consent to all scopes"
|
||||
},
|
||||
"consent_withdraw_all": {
|
||||
"description": "Withdraw consent to all scopes"
|
||||
},
|
||||
"consent_review": {
|
||||
"description": "Review all given consents"
|
||||
},
|
||||
"data": {
|
||||
"description": "Data management"
|
||||
},
|
||||
"data_checkout": {
|
||||
"description": "Checkout all user data"
|
||||
},
|
||||
"data_purge": {
|
||||
"description": "Delete all user data"
|
||||
},
|
||||
"wallet": {
|
||||
"description": "Wallet management"
|
||||
},
|
||||
"wallet_balance": {
|
||||
"description": "View wallet's balance"
|
||||
},
|
||||
"wallet_transfer": {
|
||||
"description": "Transfer money from the wallet"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"bite": {
|
||||
"name": "Bite",
|
||||
"text": "**{user_name}** bites **{target_name}**"
|
||||
},
|
||||
"hug": {
|
||||
"name": "Hug",
|
||||
"text": "**{user_name}** hugs **{target_name}**"
|
||||
},
|
||||
"kiss": {
|
||||
"name": "Kiss",
|
||||
"text": "**{user_name}** kisses **{target_name}**"
|
||||
},
|
||||
"lick": {
|
||||
"name": "Lick",
|
||||
"text": "**{user_name}** licks **{target_name}**"
|
||||
},
|
||||
"pat": {
|
||||
"name": "Pat",
|
||||
"text": "**{user_name}** pats **{target_name}**"
|
||||
},
|
||||
"poke": {
|
||||
"name": "Poke",
|
||||
"text": "**{user_name}** pokes **{target_name}**"
|
||||
},
|
||||
"wave": {
|
||||
"name": "Wave",
|
||||
"text": "**{user_name}** waves **{target_name}**"
|
||||
},
|
||||
"wink": {
|
||||
"name": "Wink",
|
||||
"text": "**{user_name}** winks **{target_name}**"
|
||||
}
|
||||
},
|
||||
"tracking": {
|
||||
"dhl": {
|
||||
"statuses": {
|
||||
"AA": "Departed from the hub",
|
||||
"AE": "Pickup successful",
|
||||
"AN": "Pickup not successful",
|
||||
"BV": "Exception occurred",
|
||||
"DD": "Data service",
|
||||
"EE": "Arrived at the hub",
|
||||
"ES": "First processed by DHL",
|
||||
"GT": "Money transfer",
|
||||
"LA": "In storage",
|
||||
"NB": "Processing during transit",
|
||||
"PO": "In delivery",
|
||||
"VA": "Electronic pre-advise",
|
||||
"ZF": "Delivery",
|
||||
"ZN": "Delivery not successful",
|
||||
"ZO": "Customs clearance",
|
||||
"ZU": "Delivery successful"
|
||||
},
|
||||
"messages": {
|
||||
"DHL PAKET (parcel)": "DHL PAKET (parcel)",
|
||||
"The shipment was prepared for onward transport.": "The shipment was prepared for onward transport.",
|
||||
"Warenpost (Merchandise Shipment)": "Warenpost (Merchandise Shipment)",
|
||||
"The shipment has been processed in the parcel center": "The shipment has been processed in the parcel center",
|
||||
"Unfortunately, the shipment could not be delivered today due to a strike action.": "Unfortunately, the shipment could not be delivered today due to a strike action.",
|
||||
"Die Sendung wurde von DHL abgeholt.": "The shipment has been picked up by DHL.",
|
||||
"The shipment has been successfully delivered": "The shipment has been successfully delivered",
|
||||
"The shipment could not be delivered, and the recipient has been notified": "The shipment could not be delivered, and the recipient has been notified",
|
||||
"The shipment has been loaded onto the delivery vehicle": "The shipment has been loaded onto the delivery vehicle",
|
||||
"The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.": "The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.",
|
||||
"The shipment has been processed in the parcel center of origin": "The shipment has been processed in the parcel center of origin",
|
||||
"The shipment has been posted by the sender at the retail outlet": "The shipment has been posted by the sender at the retail outlet",
|
||||
"The instruction data for this shipment have been provided by the sender to DHL electronically": "The instruction data for this shipment have been provided by the sender to DHL electronically"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Ukrainian",
|
||||
"native_name": "Українська"
|
||||
},
|
||||
"messages": {
|
||||
"general": {
|
||||
"permission_denied": "Ви не маєте права виконувати цю операцію.",
|
||||
"permission_denied_title": "Permission denied",
|
||||
"error_title": "Something went wrong",
|
||||
"success_title": "Success",
|
||||
"warning_title": "Warning"
|
||||
},
|
||||
"admin": {
|
||||
"config_reload_success": "Конфігурацію бота було перезавантажено.",
|
||||
"config_reload_error": "Не вдалося перезавантажити конфігурацію бота:\n```\n{error}\n```"
|
||||
},
|
||||
"data_control": {
|
||||
"consent_required": "Для доступу до команди необхідна згода на наступні сфери застосування: {scopes}\n\nВи можете використовувати наступні команди для управління своїми згодами:\n`/consent terms <scope>` - Переглянути умови згоди\n`/consent give <scope>` - Надати згоду на певну сферу застосування"
|
||||
},
|
||||
"wallet": {
|
||||
"balance_title": "Balance",
|
||||
"balance_own": "Ваш баланс складає `{balance}`.",
|
||||
"balance_user": "Баланс **{user}** складає `{balance}`.",
|
||||
"transfer_success": "Ви перевели `{amount}` на рахунок **{recipient}**.",
|
||||
"transfer_insufficient_funds": "Недостатньо коштів. Потрібно ще `{amount}` для цієї транзакції."
|
||||
},
|
||||
"reload": {
|
||||
"reload_success": "Конфігурацію було успішно перезавантажено.",
|
||||
"reload_failure": "Не вдалось перезавантажити конфігурацію:\n```\n{exception}\n```"
|
||||
},
|
||||
"welcome": {
|
||||
"morning": [
|
||||
"{0} Добрий ранок та ласкаво просимо! {1}",
|
||||
@@ -32,13 +61,81 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"data_control": {
|
||||
"scopes": {
|
||||
"general": {
|
||||
"name": "Загальне",
|
||||
"description": ""
|
||||
},
|
||||
"integration_deepl": {
|
||||
"name": "Інтеграція: DeepL",
|
||||
"description": ""
|
||||
},
|
||||
"integration_7tv": {
|
||||
"name": "Інтеграція: 7TV",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"consent_durations": {
|
||||
"short": "Короткий",
|
||||
"normal": "Звичаний",
|
||||
"long": "Довгий"
|
||||
}
|
||||
},
|
||||
"commands": {
|
||||
"reload": {
|
||||
"description": "Перезавантажити конфігурацію бота"
|
||||
},
|
||||
"clear": {
|
||||
"description": "Delete some messages in the current channel"
|
||||
},
|
||||
"consent": {
|
||||
"description": "Consent management"
|
||||
},
|
||||
"consent_terms": {
|
||||
"description": "View terms for the consent scope"
|
||||
},
|
||||
"consent_give": {
|
||||
"description": "Give consent to the scope"
|
||||
},
|
||||
"consent_withdraw": {
|
||||
"description": "Withdraw consent to the scope"
|
||||
},
|
||||
"consent_give_all": {
|
||||
"description": "Give consent to all scopes"
|
||||
},
|
||||
"consent_withdraw_all": {
|
||||
"description": "Withdraw consent to all scopes"
|
||||
},
|
||||
"consent_review": {
|
||||
"description": "Review all given consents"
|
||||
},
|
||||
"data": {
|
||||
"description": "Data management"
|
||||
},
|
||||
"data_checkout": {
|
||||
"description": "Checkout all user data"
|
||||
},
|
||||
"data_purge": {
|
||||
"description": "Delete all user data"
|
||||
},
|
||||
"wallet": {
|
||||
"description": "Wallet management"
|
||||
},
|
||||
"wallet_balance": {
|
||||
"description": "View wallet's balance"
|
||||
},
|
||||
"wallet_transfer": {
|
||||
"description": "Transfer money from the wallet"
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"bite": {
|
||||
"name": "Вкусити",
|
||||
"text": "**{user_name}** робить кусь **{target_name}**"
|
||||
},
|
||||
"hug": {
|
||||
"name": "",
|
||||
"name": "Обійняти",
|
||||
"text": "**{user_name}** обіймає **{target_name}**"
|
||||
},
|
||||
"kiss": {
|
||||
@@ -69,10 +166,22 @@
|
||||
"tracking": {
|
||||
"dhl": {
|
||||
"statuses": {
|
||||
"delivered": "Доставлено",
|
||||
"transit": "Транзит",
|
||||
"pre-transit": "Пре-транзит",
|
||||
"failure": "Невдача"
|
||||
"AA": "Departed from the hub",
|
||||
"AE": "Pickup successful",
|
||||
"AN": "Pickup not successful",
|
||||
"BV": "Exception occurred",
|
||||
"DD": "Data service",
|
||||
"EE": "Arrived at the hub",
|
||||
"ES": "First processed by DHL",
|
||||
"GT": "Money transfer",
|
||||
"LA": "In storage",
|
||||
"NB": "Processing during transit",
|
||||
"PO": "In delivery",
|
||||
"VA": "Electronic pre-advise",
|
||||
"ZF": "Delivery",
|
||||
"ZN": "Delivery not successful",
|
||||
"ZO": "Customs clearance",
|
||||
"ZU": "Delivery successful"
|
||||
},
|
||||
"messages": {
|
||||
"DHL PAKET (parcel)": "DHL PAKET (посилка)",
|
||||
0
src/javelina/migrations/__init__.py
Normal file
0
src/javelina/migrations/__init__.py
Normal file
1
src/javelina/modules/__init__.py
Normal file
1
src/javelina/modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import database, migrator, scheduler, utils
|
||||
65
src/javelina/modules/database.py
Normal file
65
src/javelina/modules/database.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Module that provides all database collections"""
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from libbot.utils import config_get
|
||||
from pymongo import AsyncMongoClient
|
||||
from pymongo.asynchronous.collection import AsyncCollection
|
||||
from pymongo.asynchronous.database import AsyncDatabase
|
||||
|
||||
db_config: Mapping[str, Any] = config_get("database")
|
||||
|
||||
if db_config["user"] is not None and db_config["password"] is not None:
|
||||
con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format(
|
||||
db_config["user"],
|
||||
db_config["password"],
|
||||
db_config["host"],
|
||||
db_config["port"],
|
||||
db_config["name"],
|
||||
)
|
||||
else:
|
||||
con_string = "mongodb://{0}:{1}/{2}".format(
|
||||
db_config["host"], db_config["port"], db_config["name"]
|
||||
)
|
||||
|
||||
# Async declarations
|
||||
db_client = AsyncMongoClient(con_string, connectTimeoutMS=3000)
|
||||
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
|
||||
|
||||
col_users: AsyncCollection = db.get_collection("users")
|
||||
col_guilds: AsyncCollection = db.get_collection("guilds")
|
||||
col_wallets: AsyncCollection = db.get_collection("wallets")
|
||||
col_consents: AsyncCollection = db.get_collection("consents")
|
||||
col_analytics: AsyncCollection = db.get_collection("analytics")
|
||||
col_guild_emotes: AsyncCollection = db.get_collection("guild_emotes")
|
||||
col_custom_channels: AsyncCollection = db.get_collection("custom_channels")
|
||||
col_scheduled_actions: AsyncCollection = db.get_collection("scheduled_actions")
|
||||
|
||||
col_int_7tv_sets: AsyncCollection = db.get_collection("int_7tv_sets")
|
||||
col_int_7tv_emotes: AsyncCollection = db.get_collection("int_7tv_emotes")
|
||||
|
||||
|
||||
# col_messages: AsyncCollection = db.get_collection("messages")
|
||||
# col_warnings: AsyncCollection = db.get_collection("warnings")
|
||||
# col_checkouts: AsyncCollection = db.get_collection("checkouts")
|
||||
# col_trackings: AsyncCollection = db.get_collection("trackings")
|
||||
# col_authorized: AsyncCollection = db.get_collection("authorized")
|
||||
# col_transactions: AsyncCollection = db.get_collection("transactions")
|
||||
|
||||
|
||||
# Update indexes
|
||||
async def _update_database_indexes() -> None:
|
||||
await col_users.create_index(["id", "guild_id"], name="user_id-guild_id", unique=True)
|
||||
await col_guilds.create_index("guild_id", name="guild_id", unique=True)
|
||||
await col_guild_emotes.create_index(
|
||||
["id", "guild_id"], name="emote_id-guild_id", unique=True
|
||||
)
|
||||
await col_wallets.create_index(
|
||||
["owner_id", "guild_id"], name="owner_id-guild_id", unique=True
|
||||
)
|
||||
await col_consents.create_index(
|
||||
["owner_id", "guild_id", "scope"], name="owner_id-guild_id-scope", unique=False
|
||||
)
|
||||
await col_custom_channels.create_index(
|
||||
["owner_id", "guild_id", "channel_id"], name="owner_id-guild_id-channel_id", unique=True
|
||||
)
|
||||
1
src/javelina/modules/middleware/__init__.py
Normal file
1
src/javelina/modules/middleware/__init__.py
Normal file