94 Commits

Author SHA1 Message Date
9e10cf4fa4 Merge pull request 'Added stubs for Data and Consent cogs' (#54) from feature/data-control into feature/profitroll/data-control
Reviewed-on: #54
2025-06-07 00:21:27 +03:00
7b15480c30 WIP: Added stubs for Data and Consent cogs 2025-06-06 23:17:55 +02:00
54bfef981d WIP: Implemented basic methods for Consent and added necessary methods in PycordUser (#51) 2025-06-01 16:00:40 +02:00
4b4b9f5b0d WIP: Added stubs for #51 2025-06-01 01:06:26 +02:00
d08ea6240e Added simple Discord auth 2025-05-26 09:17:23 +02:00
ce86b95163 Slightly improved API extensions 2025-05-26 01:47:49 +02:00
296ef50a53 Removed "en" because it was already replaced by "en-US" 2025-05-25 22:57:51 +02:00
d5dc438601 WIP: #44, #43, #13, #12 2025-05-25 22:32:36 +02:00
62ee26b20f Replaced async_pymongo with default pymongo's async calls, fixed indexes 2025-05-19 00:16:51 +02:00
27ab68f6c5 Allowed empty wallet creation during transfers 2025-05-19 00:16:17 +02:00
32f19ee16b Added support for cache prefixes, improved logging and cached objects 2025-05-19 00:15:41 +02:00
b2fe8c516d Merge pull request 'Update dependency libbot to v4.2.0' (#48) from renovate/libbot-4.x into dev
Reviewed-on: Hessenuk/Javelina#48
2025-05-18 20:26:37 +03:00
df6ed8ac11 Update dependency libbot to v4.2.0 2025-05-18 19:29:06 +03:00
123f7e8e4f Update dependency pyrmv to v0.5.0 (#47) 2025-05-05 01:38:33 +03:00
08435f3dbb Update dependency pyrmv to v0.5.0 2025-05-05 01:28:58 +03:00
2cbe2a07e1 Update dependency deepl to v1.22.0 (#46) 2025-04-30 21:42:36 +03:00
9f99a2d507 Update dependency deepl to v1.22.0 2025-04-30 21:26:36 +03:00
187abbbbb4 Update dependency deepl to v1.21.1 (#45) 2025-03-12 20:18:21 +02:00
ab67e610d4 Update dependency deepl to v1.21.1 2025-03-12 19:41:36 +02:00
c6f971b39e WIP: Modified i18n usage 2025-02-24 21:36:01 +01:00
fcb09303ec WIP: Wallet Cog and i18n 2025-02-23 23:41:42 +01:00
1c8365b11f Added API version 2025-02-20 22:55:08 +01:00
bf6ca24eed WIP: Guilds and Wallets 2025-02-20 22:51:01 +01:00
65b0e30c75 WIP: Transactions 2025-02-20 14:39:19 +01:00
8e2003b7df WIP: Added stubs for Guilds and fixed formatting 2025-02-18 21:20:14 +01:00
kku
3ffea8b46b Added withdrawals and deposits 2025-02-18 20:25:57 +01:00
f3bb1ff79a WIP: Wallets, added missing changes 2025-02-18 20:19:09 +01:00
8883c8eda8 WIP: Wallets 2025-02-18 08:04:02 +01:00
654034491a Added stubs for custom channel, custom role and wallet 2025-02-16 22:36:18 +01:00
222a618591 Fixed a typo and removed an old class 2025-02-16 21:07:52 +01:00
a1bfbb537a Replaced built-in caching with the one from libbot 2025-02-16 20:38:41 +01:00
e0e307e35f Merge pull request 'Update dependency libbot to v4.1.0' (#41) from renovate/libbot-4.x into dev
Reviewed-on: Hessenuk/Javelina#41
2025-02-16 20:01:44 +02:00
e0564e150c Update dependency libbot to v4.1.0 2025-02-16 19:21:12 +02:00
4b401e878b Replaced UserNotFoundException with UserNotFoundError in a docstring 2025-02-16 14:09:32 +01:00
4ad79f1445 Improved type hints and added a placeholder for guilds 2025-02-16 13:41:23 +01:00
ffcfbbfc3b Added caching, updated libbot, refactored PycordUser 2025-02-16 13:11:48 +01:00
8154394539 Merge pull request 'Update dependency pytz to v2025' (#40) from renovate/pytz-2025.x into dev
Reviewed-on: Hessenuk/Javelina#40
2025-01-31 09:55:34 +02:00
e9ac435b40 Update dependency pytz to v2025 2025-01-31 04:27:32 +02:00
a5f18e9a4e Merge pull request 'Update dependency deepl to v1.21.0' (#39) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#39
2025-01-16 12:29:51 +02:00
faa0537c35 Update dependency deepl to v1.21.0 2025-01-15 20:52:46 +02:00
3794ad5aae Update requirements.txt 2024-12-27 01:54:13 +02:00
f952aa8c9d Merge pull request 'Update dependency libbot to v3.3.1' (#37) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#37
2024-12-17 00:03:28 +02:00
42293719e4 Update dependency libbot to v3.3.1 2024-12-17 00:00:45 +02:00
7f05cd79d9 Merge pull request 'Update dependency deepl to v1.20.0' (#35) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#35
2024-11-30 21:15:42 +02:00
8035610111 Update dependency deepl to v1.20.0 2024-11-24 22:57:57 +02:00
145357f487 Merge pull request 'Update dependency apscheduler to ~=3.11.0' (#36) from renovate/apscheduler-3.x into dev
Reviewed-on: Hessenuk/Javelina#36
2024-11-24 22:17:57 +02:00
c9a3943bca Update dependency apscheduler to ~=3.11.0 2024-11-24 21:55:07 +02:00
2b017c02d6 Merge pull request 'Update dependency async_pymongo to v0.1.11' (#34) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#34
2024-10-17 09:56:02 +03:00
d632201f65 Update dependency async_pymongo to v0.1.11 2024-10-16 20:14:07 +03:00
3b3f39a8f6 Merge pull request 'Update dependency async_pymongo to v0.1.10' (#33) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#33
2024-10-15 16:25:26 +03:00
dd1ce61cd1 Update dependency async_pymongo to v0.1.10 2024-10-15 13:09:26 +03:00
a4a95a61e2 Merge pull request 'Update dependency async_pymongo to v0.1.9' (#32) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#32
2024-10-08 16:49:41 +03:00
9b4df44564 Update dependency async_pymongo to v0.1.9 2024-10-08 16:29:37 +03:00
247c670b2e Merge pull request 'Update dependency async_pymongo to v0.1.8' (#31) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#31
2024-09-25 22:25:38 +03:00
00d6418c88 Update dependency async_pymongo to v0.1.8 2024-09-25 17:13:25 +03:00
a559f4c319 Merge pull request 'Update dependency async_pymongo to v0.1.7' (#30) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#30
2024-09-21 01:52:50 +03:00
91ad1baafa Update dependency async_pymongo to v0.1.7 2024-09-20 17:24:42 +03:00
8832ba89e4 Merge pull request 'Update dependency fastapi to ~=0.115.0' (#29) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#29
2024-09-18 09:24:41 +03:00
7102ba5922 Update dependency fastapi to ~=0.115.0 2024-09-18 00:37:16 +03:00
c679af095d Merge pull request 'Update dependency deepl to v1.19.1' (#28) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#28
2024-09-18 00:01:50 +03:00
6f644b5236 Update dependency deepl to v1.19.1 2024-09-17 13:06:18 +03:00
c85140ee8b Update dependency pyrmv to v0.4.0 (#27) 2024-09-08 03:20:28 +03:00
63ac55d831 Update dependency pyrmv to v0.4.0 2024-09-08 03:05:11 +03:00
a2ebfe5867 Merge pull request 'Update dependency fastapi to ~=0.114.0' (#26) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#26
2024-09-07 11:31:01 +03:00
76074a46b8 Update dependency fastapi to ~=0.114.0 2024-09-06 20:51:34 +03:00
3d1d7e2701 Merge pull request 'Update dependency fastapi to ~=0.112.0' (#25) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#25
2024-08-03 00:58:30 +03:00
46bb3db995 Update dependency fastapi to ~=0.112.0 2024-08-02 09:57:19 +03:00
b6fb7e51b4 Merge pull request 'Update dependency libbot to v3.2.3' (#24) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#24
2024-07-10 08:12:41 +03:00
f91ed1fba4 Update dependency libbot to v3.2.3 2024-07-10 00:44:02 +03:00
dc63cbb563 Merge pull request 'Update dependency async_pymongo to v0.1.6' (#23) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#23
2024-06-23 14:33:54 +03:00
87c3a3cea2 Update dependency async_pymongo to v0.1.6 2024-06-23 13:30:25 +03:00
80b2f47403 Merge pull request 'Update dependency async_pymongo to v0.1.5' (#22) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#22
2024-06-02 12:57:29 +03:00
9708fd6c2f Selected async_pymongo from PyPi 2024-06-02 12:56:30 +03:00
1bc84f0fcb Update dependency async_pymongo to v0.1.5 2024-06-01 15:32:29 +03:00
beb542b834 Merge pull request 'Update dependency libbot to v3.2.2' (#21) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#21
2024-05-26 23:58:34 +03:00
8f599c776a Update dependency libbot to v3.2.2 2024-05-26 23:13:04 +03:00
a352da2f3e Merge pull request 'Update dependency libbot to v3.2.0' (#20) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#20
2024-05-26 19:40:28 +03:00
2a7f582dd8 Update dependency libbot to v3.2.1 2024-05-26 19:01:56 +03:00
1e09ea7ec6 Merge pull request 'Update dependency libbot to v3.1.0' (#19) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#19
2024-05-24 22:46:52 +03:00
c6e048177e Update dependency libbot to v3.1.0 2024-05-24 22:42:35 +03:00
331184a7fd Update dependency mongodb-migrations to v1.3.1 (#17) 2024-05-14 01:48:11 +03:00
00642835bd Update dependency mongodb-migrations to v1.3.1 2024-05-14 01:26:11 +03:00
bbe72f2fdf Update dependency fastapi to ~=0.111.0 (#16) 2024-05-03 11:42:55 +03:00
c7c46060e8 Update dependency fastapi to ~=0.111.0 2024-05-03 04:06:30 +03:00
da969dad58 Update dependency deepl to v1.18.0 (#15) 2024-04-26 16:54:19 +03:00
dd368733d4 Update dependency deepl to v1.18.0 2024-04-26 14:00:04 +03:00
6bfc329666 Merge pull request 'Update dependency fastapi to ~=0.110.0' (#14) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#14
2024-02-25 22:31:42 +02:00
edfd739023 Update dependency fastapi to ~=0.110.0 2024-02-25 02:13:58 +02:00
e2c05f3bf6 WIP: #9 2024-02-19 23:09:00 +01:00
fe4dcc4a92 Message events initialized 2024-02-19 23:08:41 +01:00
d5691c2bbb Fixed scheduler-related issues 2024-02-19 23:07:38 +01:00
40376d2e6d Merge pull request 'Update dependency deepl to v1.17.0' (#7) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#7
2024-02-13 19:05:55 +02:00
507c9dc9ed Update dependency deepl to v1.17.0 2024-02-07 13:53:18 +02:00
4d178bc3f2 Merge pull request 'Intial en locale addition' (#2) from i18n into dev
Reviewed-on: Hessenuk/Javelina#2
2024-02-04 02:13:29 +02:00
58 changed files with 2187 additions and 203 deletions

View File

@@ -14,3 +14,9 @@
<img alt="Discord" src="https://img.shields.io/discord/981251696208531466">
</a>
</p>
## Starting the bot
```shell
uvicorn main:app
```

View File

@@ -1,3 +1,24 @@
from fastapi import FastAPI
from urllib.parse import urljoin
app = FastAPI()
from fastapi import FastAPI
from fastapi_discord import DiscordOAuthClient
from libbot.utils import config_get
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",
)
# TODO Replace this with a FastAPI lifespan
@app.on_event("startup")
async def on_startup():
await discord_oauth.init()

40
api/extensions/guild.py Normal file
View File

@@ -0,0 +1,40 @@
from logging import Logger
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from api.app import app
from classes import PycordGuild
from classes.errors import GuildNotFoundError, WalletNotFoundError
from classes.wallet import Wallet
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get("/v1/admin/guilds/{guild_id}", response_class=JSONResponse)
async def get_guild_v1(guild_id: int) -> JSONResponse:
try:
guild: PycordGuild = await PycordGuild.from_id(guild_id, allow_creation=False)
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))
@app.get("/v1/admin/guilds/{guild_id}/wallets/{user_id}", response_class=JSONResponse)
async def get_guild_wallet_v1(guild_id: int, user_id: int) -> JSONResponse:
try:
wallet: Wallet = await Wallet.from_id(user_id, guild_id, allow_creation=False)
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))

View File

@@ -0,0 +1,41 @@
from logging import Logger
from fastapi_discord import RateLimited, Unauthorized
from fastapi_discord.exceptions import ClientSessionNotInitialized
from starlette.responses import JSONResponse
from api.app import app, discord_oauth
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get("/v1/login", response_class=JSONResponse)
async def login() -> JSONResponse:
return JSONResponse({"url": discord_oauth.oauth_login_url})
@app.get("/v1/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})
@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)

20
api/extensions/me.py Normal file
View File

@@ -0,0 +1,20 @@
from logging import Logger
from fastapi import Depends
from fastapi_discord import User
from starlette.responses import JSONResponse
from api.app import app, discord_oauth
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get(
"/v1/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

13
api/extensions/public.py Normal file
View File

@@ -0,0 +1,13 @@
from logging import Logger
from starlette.responses import JSONResponse
from api.app import app
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get("/v1/health", response_class=JSONResponse)
async def get_health_v1() -> JSONResponse:
return JSONResponse({"status": "ok"})

23
api/extensions/user.py Normal file
View File

@@ -0,0 +1,23 @@
from logging import Logger
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from api.app import app
from classes import PycordUser
from classes.errors import UserNotFoundError
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get("/v1/admin/users/{user_id}", response_class=JSONResponse)
async def get_user_v1(user_id: int) -> JSONResponse:
try:
user: PycordUser = await PycordUser.from_id(user_id, allow_creation=False)
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))

View File

@@ -1,8 +1,14 @@
from logging import Logger
from pathlib import Path
from api.app import app
from fastapi.responses import FileResponse
from api.app import app
from modules.utils import get_logger
logger: Logger = get_logger(__name__)
@app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False)
async def favicon():
async def favicon() -> FileResponse:
return FileResponse(Path("api/assets/favicon.ico"))

7
classes/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from .consent import Consent
from .guild_rules import GuildRules
from .pycord_guild import PycordGuild
from .pycord_user import PycordUser
# from .pycord_guild_colors import PycordGuildColors
# from .wallet import Wallet

View File

@@ -0,0 +1 @@
from .cacheable import Cacheable

View 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

1
classes/base/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .base_cacheable import BaseCacheable

View 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)

127
classes/consent.py Normal file
View File

@@ -0,0 +1,127 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Any, Dict, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from libbot.cache.classes import Cache
from classes.base import BaseCacheable
from classes.enums import ConsentScope
from modules.database import col_consents
# TODO Implement all necessary methods
@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: Literal[ConsentScope.GENERAL, ConsentScope.MODULE_DEEPL]
consent_date: datetime
expiration_date: datetime | None
withdrawal_date: datetime | None
# TODO Implement this method
@classmethod
async def from_id(cls, id: ObjectId, cache: Optional[Cache] = None, **kwargs: Any) -> Any:
raise NotImplementedError()
@classmethod
def from_entry(cls, db_entry: Dict[str, Any]) -> "Consent":
return cls(**db_entry)
# TODO Implement this method
@classmethod
async def give(
cls,
user_id: int,
guild_id: int,
scope: ConsentScope,
expiration_date: Optional[datetime] = None,
) -> Any:
raise NotImplementedError()
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]:
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
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,
"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
) -> Dict[str, Any]:
return {
"user_id": user_id,
"guild_id": guild_id,
"scope": None,
"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
classes/custom_channel.py Normal file
View 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
classes/custom_role.py Normal file
View 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

View File

@@ -0,0 +1,3 @@
from .consent_scope import ConsentScope
from .message_events import MessageEvents
from .punishment import Punishment

View File

@@ -0,0 +1,6 @@
from enum import Enum
class ConsentScope(Enum):
GENERAL = "general"
MODULE_DEEPL = "module_deepl"

View File

@@ -0,0 +1,5 @@
from enum import Enum
class MessageEvents(Enum):
WEATHER_FORECAST = 0

View File

@@ -0,0 +1,8 @@
from enum import Enum
class Punishment(Enum):
WARNING = 0
MUTE = 1
KICK = 2
BAN = 3

View File

@@ -0,0 +1,8 @@
from .pycord_guild import GuildNotFoundError
from .pycord_user import UserNotFoundError
from .wallet import (
WalletBalanceLimitExceeded,
WalletInsufficientFunds,
WalletNotFoundError,
WalletOverdraftLimitExceeded,
)

View 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")

View 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
classes/errors/wallet.py Normal file
View 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})"
)

39
classes/guild_rules.py Normal file
View 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()

View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from typing import Literal
from classes.enums import Punishment
@dataclass
class GuildRulesRule:
__slots__ = ("title", "content", "punishment")
title: str
content: str
punishment: Literal[Punishment.WARNING, Punishment.MUTE, Punishment.KICK, Punishment.BAN]

View 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]

176
classes/pycord_bot.py Normal file
View File

@@ -0,0 +1,176 @@
import logging
from datetime import datetime
from logging import Logger
from pathlib import Path
from typing import Any, Dict, Literal
from zoneinfo import ZoneInfo
from aiohttp import ClientSession
from discord import Activity, ActivityType, 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
from modules.database import _update_database_indexes
logger: Logger = logging.getLogger(__name__)
# from modules.tracking.dhl import update_tracks_dhl
class PycordBot(LibPycordBot):
__version__ = "0.0.1"
started: datetime
cache: CacheMemcached | CacheRedis | None = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._set_cache_engine()
self.client_session = ClientSession()
if self.scheduler is None:
return
# This replacement exists because of the different
# i18n formats than provided by libbot
self._ = self._modified_string_getter
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"]
)
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]
)
# 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"],
)
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) -> PycordUser:
"""Find User by its ID or User object.
Args:
user (int | User): ID or User object to extract ID from
Returns:
PycordUser: User object
Raises:
UserNotFoundException: User was not found and creation was not allowed
"""
return (
await PycordUser.from_id(user, cache=self.cache)
if isinstance(user, int)
else await PycordUser.from_id(user.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()
if self.scheduler is not None:
self.scheduler.shutdown()
await super().close(**kwargs)
async def _schedule_tasks(self) -> None:
# 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

145
classes/pycord_guild.py Normal file
View File

@@ -0,0 +1,145 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
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
rules: GuildRules
@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(),
}
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}
# 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)

218
classes/pycord_user.py Normal file
View File

@@ -0,0 +1,218 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from logging import Logger
from typing import Any, Dict, 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.enums import ConsentScope
from classes.errors.pycord_user import UserNotFoundError
from classes.wallet import Wallet
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) -> Wallet:
"""Get wallet of the user.
Args:
guild_id (int): Guild ID of the wallet
Returns:
Wallet: Wallet object of the user
"""
return await Wallet.from_id(self.id, guild_id)
# 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,
"$gt": {
"expiration_date": datetime.now(tz=ZoneInfo("UTC")).replace(tzinfo=None)
},
}
)
return consent is not None
# TODO Add documentation
async def give_consent(
self, scope: ConsentScope, expiration_date: Optional[datetime] = None
) -> None:
await Consent.give(self.id, self.guild_id, scope, expiration_date)
# TODO Add documentation
async def withdraw_consent(
self,
scope: ConsentScope,
cache: Optional[Cache] = None,
) -> None:
# TODO Test this query
async for consent_entry in Consent.__collection__.find(
{
"user_id": self.id,
"guild_id": self.guild_id,
"scope": scope.value,
"withdrawal_date": None,
"$gt": {
"expiration_date": datetime.now(tz=ZoneInfo("UTC")).replace(tzinfo=None)
},
}
):
await Consent.from_entry(consent_entry).withdraw(cache)

View File

@@ -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)

View File

@@ -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)

166
classes/wallet.py Normal file
View File

@@ -0,0 +1,166 @@
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 pymongo.results import InsertOneResult
from classes.errors.wallet import (
WalletBalanceLimitExceeded,
WalletInsufficientFunds,
WalletNotFoundError,
WalletOverdraftLimitExceeded,
)
from modules.database import col_wallets
logger: Logger = logging.getLogger(__name__)
@dataclass
class Wallet:
_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
) -> "Wallet":
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
return cls(**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,
}
async def _set(self, key: str, value: Any) -> None:
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await col_wallets.update_one({"_id": self._id}, {"$set": {key: value}}, upsert=True)
logger.info("Set attribute '%s' of the wallet %s to '%s'", key, str(self._id), value)
@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) -> None:
await self._set("is_frozen", True)
# TODO Write a docstring
async def unfreeze(self) -> None:
await self._set("is_frozen", False)
# TODO Write a docstring
async def deposit(self, amount: float, balance_limit: Optional[float] = 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._set("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,
) -> 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._set("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,
) -> 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
)
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)
await wallet.deposit(amount, balance_limit)

27
cli.py Normal file
View File

@@ -0,0 +1,27 @@
import logging
from argparse import ArgumentParser
from logging import Logger
from modules.migrator import migrate_database
logger: Logger = logging.getLogger(__name__)
parser = ArgumentParser(
prog="Javelina",
description="Discord bot for community management.",
)
parser.add_argument("--migrate", action="store_true")
parser.add_argument("--only-api", action="store_true")
args = parser.parse_args()
def main():
if args.migrate:
logger.info("Performing migrations...")
migrate_database()
if __name__ == "__main__":
main()

29
cogs/cog_admin.py Normal file
View File

@@ -0,0 +1,29 @@
from discord import ApplicationContext, Cog, slash_command
from libbot.i18n import _, in_every_locale
from classes.pycord_bot import PycordBot
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"),
description_localizations=in_every_locale("description", "commands", "reload"),
)
async def command_guess(self, ctx: ApplicationContext) -> None:
try:
self.bot.reload()
await self.bot.set_status()
await ctx.respond("Okay.")
except Exception as exc:
await ctx.respond(f"Not okay:\n```\n{exc}\n```", ephemeral=True)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogAdmin(bot))

38
cogs/cog_consent.py Normal file
View File

@@ -0,0 +1,38 @@
import logging
from logging import Logger
from discord import SlashCommandGroup
from discord.ext import commands
from classes.pycord_bot import PycordBot
logger: Logger = logging.getLogger(__name__)
class CogConsent(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup("consent", "Consent management")
# /consent terms <scope>
# Will provide information about terms
# /consent give <scope>
# Will provide information about terms and a button to confirm
# /consent withdraw <scope>
# Will directly withdraw consent if confirmation is provided
# /consent give_all [<confirm>]
# Will inform about necessity to review all scopes and a button to confirm
# /consent withdraw_all [<confirm>]
# Will directly withdraw all consents if confirmation is provided
# /consent review
# Will show all consents provided by the user, including scopes and expiration dates
def setup(client: PycordBot) -> None:
client.add_cog(CogConsent(client))

26
cogs/cog_data.py Normal file
View File

@@ -0,0 +1,26 @@
import logging
from logging import Logger
from discord import SlashCommandGroup
from discord.ext import commands
from classes.pycord_bot import PycordBot
logger: Logger = logging.getLogger(__name__)
class CogData(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup("data", "Data management")
# /data checkout
# Export all user data in a ZIP archive
# /data purge [<confirm>]
# Soft-delete all user data
def setup(client: PycordBot) -> None:
client.add_cog(CogData(client))

View 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))

24
cogs/cog_utility.py Normal file
View File

@@ -0,0 +1,24 @@
from logging import Logger
from discord import Cog
from classes.pycord_bot import PycordBot
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()
def setup(bot: PycordBot) -> None:
bot.add_cog(CogUtility(bot))

73
cogs/cog_wallet.py Normal file
View File

@@ -0,0 +1,73 @@
import logging
from logging import Logger
from discord import ApplicationContext, SlashCommandGroup, User, option
from discord.ext import commands
from classes.errors import WalletInsufficientFunds
from classes.pycord_bot import PycordBot
from classes.wallet import Wallet
logger: Logger = logging.getLogger(__name__)
class CogWallet(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup("wallet", "Wallet management")
@command_group.command(
name="balance",
description="View wallet's balance",
)
@option("user", description="User whose balance to check (if not your own)", required=False)
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
)
await ctx.respond(
self.client._("balance_own", "messages", "wallet", locale=ctx.locale).format(
balance=wallet.balance
)
if user is None
else self.client._("balance_user", "messages", "wallet", locale=ctx.locale).format(
balance=wallet.balance, user=user.display_name
)
)
@command_group.command(
name="transfer",
description="View wallet's balance",
)
@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)
try:
await wallet.transfer(user.id, ctx.guild_id, amount)
except WalletInsufficientFunds:
await ctx.respond(
self.client._(
"transfer_insufficient_funds", "messages", "wallet", locale=ctx.locale
).format(amount=round(abs(wallet.balance - amount), 2))
)
return
await ctx.respond(
self.client._("transfer_success", "messages", "wallet", locale=ctx.locale).format(
amount=amount, recipient=user.display_name
)
)
def setup(client: PycordBot) -> None:
client.add_cog(CogWallet(client))

73
cogs/wallet.py Normal file
View File

@@ -0,0 +1,73 @@
import logging
from logging import Logger
from discord import ApplicationContext, SlashCommandGroup, User, option
from discord.ext import commands
from classes.errors import WalletInsufficientFunds
from classes.pycord_bot import PycordBot
from classes.wallet import Wallet
logger: Logger = logging.getLogger(__name__)
class WalletCog(commands.Cog):
def __init__(self, client: PycordBot):
self.client: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup("wallet", "Wallet management")
@command_group.command(
name="balance",
description="View wallet's balance",
)
@option("user", description="User whose balance to check (if not your own)", required=False)
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
)
await ctx.respond(
self.client._("balance_own", "messages", "wallet", locale=ctx.locale).format(
balance=wallet.balance
)
if user is None
else self.client._("balance_user", "messages", "wallet", locale=ctx.locale).format(
balance=wallet.balance, user=user.display_name
)
)
@command_group.command(
name="transfer",
description="View wallet's balance",
)
@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)
try:
await wallet.transfer(user.id, ctx.guild_id, amount)
except WalletInsufficientFunds:
await ctx.respond(
self.client._(
"transfer_insufficient_funds", "messages", "wallet", locale=ctx.locale
).format(amount=round(abs(wallet.balance - amount), 2))
)
return
await ctx.respond(
self.client._("transfer_success", "messages", "wallet", locale=ctx.locale).format(
amount=amount, recipient=user.display_name
)
)
def setup(client: PycordBot) -> None:
client.add_cog(WalletCog(client))

View File

@@ -1,5 +1,5 @@
{
"locale": "en",
"locale": "en-US",
"debug": false,
"bot": {
"owners": [
@@ -16,6 +16,13 @@
"activity_text": "The Game Of Life"
}
},
"api": {
"oauth": {
"client_id": null,
"client_secret": null
},
"public_url": "http://127.0.0.1:8000"
},
"database": {
"user": null,
"password": null,
@@ -23,6 +30,17 @@
"port": 27017,
"name": "javelina"
},
"cache": {
"type": null,
"memcached": {
"uri": "127.0.0.1:11211",
"prefix": null
},
"redis": {
"uri": "redis://127.0.0.1:6379/0",
"prefix": null
}
},
"privacy": {
"api_endpoint": "https://api.javelina.eu/v1"
},

View File

@@ -1,4 +1,28 @@
{
"messages": {
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}"
],
"midday": [
"{0} Добрий день! Ласкаво просимо! {1}"
],
"evening": [
"{0} Добрий вечір! Ласкаво просимо! {1}"
],
"night": [
"{0} Доброї ночі! Ласкаво просимо! {1}"
],
"unknown": [
"{0} Вітаннячко! Ласкаво просимо! {1}"
]
}
},
"commands": {
"reload": {
"description": "Reload bot's configuration"
}
},
"actions": {
"bite": {
"name": "Вкусити",
@@ -33,46 +57,25 @@
"text": "**{user_name}** підморгує **{target_name}**"
}
},
"messages": {
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}",
"{0} Доброго ранку та ласкаво просимо! {1}",
"{0} Вітаннячко! Ласкаво просимо! {1}",
"{0} Доброго ранку! Ласкаво просимо! {1}"
],
"midday": [
"{0} Добрий день! Ласкаво просимо! {1}",
"{0} Добридень! Ласкаво просимо! {1}",
"{0} День добрий! Ласкаво просимо! {1}",
"{0} Мої вітання! Ласкаво просимо! {1}",
"{0} Здоровенькі були! Ласкаво просимо! {1}",
"{0} Раді вітати вас! Ласкаво просимо! {1}",
"{0} Доброго здоров’ячка! Ласкаво просимо! {1}"
],
"evening": [
"{0} Добрий вечір! Ласкаво просимо! {1}",
"{0} Доброго вечора! Ласкаво просимо! {1}",
"{0} Добривечір! Ласкаво просимо! {1}",
"{0} Доброго вечора та ласкаво просимо! {1}",
"{0} Добрий вечір та ласкаво просимо! {1}"
],
"night": [
"{0} Доброї ночі! Ласкаво просимо! {1}",
"{0} Здоровенькі були! Ласкаво просимо! {1}"
],
"unknown": [
"{0} Вітаннячко! Ласкаво просимо! {1}"
]
}
},
"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 (посилка)",

View File

@@ -1,37 +1,34 @@
{
"messages": {
"wallet": {
"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."
},
"welcome": {
"morning": [
"{0} Good morning and welcome! {1}",
"{0} Доброго ранку та ласкаво просимо! {1}",
"{0} Вітаннячко! Ласкаво просимо! {1}",
"{0} Доброго ранку! Ласкаво просимо! {1}"
"{0} Good morning and welcome! {1}"
],
"midday": [
"{0} Good day and welcome! {1}",
"{0} Добридень! Ласкаво просимо! {1}",
"{0} День добрий! Ласкаво просимо! {1}",
"{0} Мої вітання! Ласкаво просимо! {1}",
"{0} Здоровенькі були! Ласкаво просимо! {1}",
"{0} Раді вітати вас! Ласкаво просимо! {1}",
"{0} Доброго здоров’ячка! Ласкаво просимо! {1}"
"{0} Good day and welcome! {1}"
],
"evening": [
"{0} Good evening and welcome! {1}",
"{0} Доброго вечора! Ласкаво просимо! {1}",
"{0} Добривечір! Ласкаво просимо! {1}",
"{0} Доброго вечора та ласкаво просимо! {1}",
"{0} Добрий вечір та ласкаво просимо! {1}"
"{0} Good evening and welcome! {1}"
],
"night": [
"{0} Good night and welcome! {1}",
"{0} Здоровенькі були! Ласкаво просимо! {1}"
"{0} Good night and welcome! {1}"
],
"unknown": [
"{0} Hello and welcome! {1}"
]
}
},
"commands": {
"reload": {
"description": "Reload bot's configuration"
}
},
"actions": {
"bite": {
"name": "Bite",
@@ -69,10 +66,22 @@
"tracking": {
"dhl": {
"statuses": {
"delivered": "Delivered",
"transit": "Transit",
"pre-transit": "Pre-transit",
"failure": "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 (parcel)",

View File

@@ -1,5 +1,11 @@
{
"messages": {
"wallet": {
"balance_own": "Ваш баланс складає `{balance}`.",
"balance_user": "Баланс **{user}** складає `{balance}`.",
"transfer_success": "Ви перевели `{amount}` на рахунок **{recipient}**.",
"transfer_insufficient_funds": "Недостатньо коштів. Потрібно ще `{amount}` для цієї транзакції."
},
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}",
@@ -32,6 +38,11 @@
]
}
},
"commands": {
"reload": {
"description": "Перезавантажити конфігурацію бота"
}
},
"actions": {
"bite": {
"name": "Вкусити",
@@ -69,10 +80,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 (посилка)",

52
main.py
View File

@@ -1,23 +1,34 @@
import asyncio
import logging
from os import getpid
import contextlib
import logging.config
from asyncio import get_event_loop
from logging import Logger
from os import getpid, makedirs
from pathlib import Path
from sys import exit
from libbot import sync
from classes.pycordbot import PycordBot
from modules.extensions_loader import dynamic_import_from_src
from modules.scheduler import scheduler
from discord import LoginFailure
from libbot.utils import config_get
# Import required for uvicorn
from api.app import app
from api.app import app # noqa
from classes.pycord_bot import PycordBot
from modules.extensions_loader import dynamic_import_from_src
from modules.scheduler import scheduler
from modules.utils import get_logger, get_logging_config
logging.basicConfig(
level=logging.DEBUG if sync.config_get("debug") else logging.INFO,
format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
datefmt="[%X]",
)
makedirs(Path("logs/"), exist_ok=True)
logger = logging.getLogger(__name__)
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():
@@ -26,13 +37,22 @@ async def main():
bot.load_extension("cogs")
# Import API modules
dynamic_import_from_src("api.extensions", star_import=True)
dynamic_import_from_src("api/extensions", star_import=True)
try:
await bot.start(sync.config_get("bot_token", "bot"))
await bot.start(config_get("bot_token", "bot"))
except LoginFailure as exc:
logger.error("Provided bot token is invalid: %s", exc)
except KeyboardInterrupt:
logger.warning("Forcefully shutting down with PID %s...", getpid())
await bot.close()
except Exception as exc:
logger.error("An unexpected error has occurred: %s", exc, exc_info=exc)
exit(1)
asyncio.create_task(main())
if __name__ == "__main__":
event_loop = get_event_loop()
event_loop.run_until_complete(main())

View File

@@ -1,4 +1,3 @@
from modules.migrator import migrate_database
migrate_database()

View File

@@ -2,8 +2,10 @@
from typing import Any, Mapping
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
from libbot.sync import config_get
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")
@@ -20,12 +22,35 @@ else:
db_config["host"], db_config["port"], db_config["name"]
)
db_client = AsyncClient(con_string)
# Async declarations
db_client = AsyncMongoClient(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")
col_guilds: AsyncCollection = db.get_collection("guilds")
col_wallets: AsyncCollection = db.get_collection("wallets")
col_consents: AsyncCollection = db.get_collection("consents")
col_custom_channels: AsyncCollection = db.get_collection("custom_channels")
# 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_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=True
)
await col_custom_channels.create_index(
["owner_id", "guild_id", "channel_id"], name="owner_id-guild_id-channel_id", unique=True
)

View File

@@ -1,28 +1,26 @@
from importlib.util import module_from_spec, spec_from_file_location
import logging
from importlib.util import module_from_spec, spec_from_file_location
from os import getcwd, walk
from pathlib import Path
from types import ModuleType
from typing import List, Union
from typing import List
logger = logging.getLogger(__name__)
# Import functions
# Took from https://stackoverflow.com/a/57892961
def get_py_files(src: Union[str, Path]) -> List[str]:
def get_py_files(src: 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")
)
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]:
def dynamic_import(module_name: str, py_path: str) -> ModuleType | None:
try:
module_spec = spec_from_file_location(module_name, py_path)
@@ -38,7 +36,7 @@ def dynamic_import(module_name: str, py_path: str) -> Union[ModuleType, None]:
"Could not load extension %s due to spec loader being None.",
module_name,
)
return
return None
module_spec.loader.exec_module(module)
@@ -48,13 +46,13 @@ def dynamic_import(module_name: str, py_path: str) -> Union[ModuleType, None]:
"Could not load extension %s due to invalid syntax. Check logs/errors.log for details.",
module_name,
)
return
return None
except Exception as exc:
logger.warning("Could not load extension %s due to %s", module_name, exc)
return
return None
def dynamic_import_from_src(src: Union[str, Path], star_import=False) -> None:
def dynamic_import_from_src(src: str | Path, star_import=False) -> None:
my_py_files = get_py_files(src)
for py_file in my_py_files:
@@ -64,7 +62,7 @@ def dynamic_import_from_src(src: Union[str, Path], star_import=False) -> None:
imported_module = dynamic_import(module_name, py_file)
if imported_module != None:
if imported_module is not None:
if star_import:
for obj in dir(imported_module):
globals()[obj] = imported_module.__dict__[obj]

View File

@@ -1,6 +1,6 @@
from typing import Any, Mapping
from libbot.sync import config_get
from libbot.utils import config_get
from mongodb_migrations.cli import MigrationManager
from mongodb_migrations.config import Configuration

View File

@@ -0,0 +1,6 @@
def hex_to_int(hex_color: str) -> int:
return int(hex_color.lstrip("#"), 16)
def int_to_hex(integer_color: int) -> str:
return "#" + format(integer_color, "06x")

View File

@@ -0,0 +1,2 @@
from .cache_utils import restore_from_cache
from .logging_utils import get_logger, get_logging_config

View File

@@ -0,0 +1,10 @@
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
def restore_from_cache(
cache_prefix: str, cache_key: str | int | ObjectId, cache: Optional[Cache] = None
) -> Dict[str, Any] | None:
return None if cache is None else cache.get_json(f"{cache_prefix}_{cache_key}")

View File

@@ -0,0 +1,35 @@
import logging
from logging import Logger
from pathlib import Path
from typing import Any, Dict
from libbot.utils import config_get
def get_logging_config() -> Dict[str, Any]:
return {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": str(Path("logs/latest.log")),
"maxBytes": 500000,
"backupCount": 10,
"formatter": "simple",
},
"console": {"class": "logging.StreamHandler", "formatter": "systemd"},
},
"formatters": {
"simple": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"},
"systemd": {"format": "%(name)s - %(levelname)s - %(message)s"},
},
"root": {
"level": "DEBUG" if config_get("debug") else "INFO",
"handlers": ["file", "console"],
},
}
def get_logger(name: str) -> Logger:
return logging.getLogger(name)

View File

@@ -0,0 +1,9 @@
from typing import Any, Dict
def parse_weather(api_response: Dict[str, Any]) -> str:
return ""
def parse_weather_current(api_response: Dict[str, Any]) -> str:
return ""

106
modules/weather/reporter.py Normal file
View File

@@ -0,0 +1,106 @@
import logging
from datetime import datetime
from typing import Any, Dict, List
from database import col_messages
from discord import Embed
from pymongo import DESCENDING
from pytz import timezone
from ujson import loads
from classes.enums import MessageEvents
from classes.pycordbot import PycordBot
from modules.utils import hex_to_int
from modules.weather.parser import parse_weather
# Example guild key
# "forecast": {
# "channel": 0,
# "time": "10:00:00",
# "delete_previous": true
# "locations": [{"name": "Sample Location", "location": [10.000, 20.000]}],
# }
logger = logging.getLogger(__name__)
async def report_weather(
bot: PycordBot,
guild: PycordGuild, # TODO
channel_id: int,
delete_previous: bool,
locations: List[Dict[str, Any]],
) -> None: # sourcery skip: aware-datetime-for-utc
channel = bot.get_channel(channel_id)
if channel is None:
logger.error(
"Cannot generate weather report for %s's channel %s because channel was not found.",
guild.id,
channel_id,
)
return
# Find and delete previous forecast, if needed
if delete_previous:
async for event in col_messages.find(
{
"event": MessageEvents.WEATHER_FORECAST,
"guild": guild.id,
"channel": channel_id,
},
limit=1,
).sort("date", direction=DESCENDING):
try:
old_message = bot.get_message(event["message"])
if old_message is not None:
await old_message.delete(
reason="Cleanup of the old weather report (look in guild config for details)"
)
except Exception as exc:
logger.warning(
"Could not delete the previous weather report in %s' channel %s due to %s",
guild.id,
channel_id,
exc,
)
embeds: List[Embed] = []
# Iterate through the locations and request their forecasts.
# Results must be parsed and added as embeds to the embeds lits.
for location in locations:
location_timezone_offset = ":".join(
str(timezone(bot.config["bot"]["timezone"]).utcoffset(datetime.utcnow())).split(
":"
)[:2]
)
api_response = await (
await bot.client_session.get(
f"https://api.openweathermap.org/data/2.5/onecall?lat={location['location'][0]}&lon={location['location'][1]}&exclude=minutely&units=metric&lang=uk&appid={bot.config['modules']['weather']['forecasts']['api_key']}&tz={location_timezone_offset}"
)
).json(loads=loads)
parsed_weather = parse_weather(api_response)
embeds.append(
Embed(
title=location["name"],
description=parsed_weather,
color=hex_to_int(guild.colors.default),
)
)
# Add a trailing embed with OWM information
embeds.append(
Embed(
title=bot._("weather_report_title", "embeds"),
description=bot._("weather_report_description", "embeds"),
color=hex_to_int(guild.colors.default),
)
)
await channel.send( # type: ignore
content=bot._("weather_report_content", "messages"), embeds=embeds
)

6
pyproject.toml Normal file
View File

@@ -0,0 +1,6 @@
[tool.black]
line-length = 96
target-version = ["py311"]
[tool.isort]
profile = "black"

View File

@@ -1,12 +1,14 @@
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
apscheduler~=3.11.0
fastapi[all]~=0.115.0
fastapi_discord==0.2.7
libbot[speed,pycord,cache]==4.2.0
mongodb-migrations==1.3.1
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
pytz~=2025.1
# Temporarily disabled because
# these are still unused for now
# colorthief==0.2.1
# deepl==1.22.0
# pyrmv==0.5.0