Compare commits
94 Commits
i18n
...
9e10cf4fa4
Author | SHA1 | Date | |
---|---|---|---|
9e10cf4fa4 | |||
7b15480c30 | |||
54bfef981d | |||
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 |
@@ -14,3 +14,9 @@
|
||||
<img alt="Discord" src="https://img.shields.io/discord/981251696208531466">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Starting the bot
|
||||
|
||||
```shell
|
||||
uvicorn main:app
|
||||
```
|
25
api/app.py
25
api/app.py
@@ -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
40
api/extensions/guild.py
Normal 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))
|
41
api/extensions/identity.py
Normal file
41
api/extensions/identity.py
Normal 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
20
api/extensions/me.py
Normal 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
13
api/extensions/public.py
Normal 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
23
api/extensions/user.py
Normal 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))
|
@@ -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
7
classes/__init__.py
Normal 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
|
1
classes/abstract/__init__.py
Normal file
1
classes/abstract/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .cacheable import Cacheable
|
81
classes/abstract/cacheable.py
Normal file
81
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
|
1
classes/base/__init__.py
Normal file
1
classes/base/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .base_cacheable import BaseCacheable
|
110
classes/base/base_cacheable.py
Normal file
110
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)
|
127
classes/consent.py
Normal file
127
classes/consent.py
Normal 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
108
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
classes/custom_role.py
Normal file
14
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
|
3
classes/enums/__init__.py
Normal file
3
classes/enums/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .consent_scope import ConsentScope
|
||||
from .message_events import MessageEvents
|
||||
from .punishment import Punishment
|
6
classes/enums/consent_scope.py
Normal file
6
classes/enums/consent_scope.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ConsentScope(Enum):
|
||||
GENERAL = "general"
|
||||
MODULE_DEEPL = "module_deepl"
|
5
classes/enums/message_events.py
Normal file
5
classes/enums/message_events.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MessageEvents(Enum):
|
||||
WEATHER_FORECAST = 0
|
8
classes/enums/punishment.py
Normal file
8
classes/enums/punishment.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Punishment(Enum):
|
||||
WARNING = 0
|
||||
MUTE = 1
|
||||
KICK = 2
|
||||
BAN = 3
|
8
classes/errors/__init__.py
Normal file
8
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
classes/errors/pycord_guild.py
Normal file
7
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
classes/errors/pycord_user.py
Normal file
8
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
classes/errors/wallet.py
Normal file
62
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})"
|
||||
)
|
39
classes/guild_rules.py
Normal file
39
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
classes/guild_rules_rule.py
Normal file
13
classes/guild_rules_rule.py
Normal 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]
|
13
classes/guild_rules_section.py
Normal file
13
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]
|
176
classes/pycord_bot.py
Normal file
176
classes/pycord_bot.py
Normal 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
145
classes/pycord_guild.py
Normal 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
218
classes/pycord_user.py
Normal 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)
|
@@ -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)
|
166
classes/wallet.py
Normal file
166
classes/wallet.py
Normal 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
27
cli.py
Normal 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
29
cogs/cog_admin.py
Normal 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
38
cogs/cog_consent.py
Normal 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
26
cogs/cog_data.py
Normal 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))
|
14
cogs/cog_organizational.py
Normal file
14
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))
|
24
cogs/cog_utility.py
Normal file
24
cogs/cog_utility.py
Normal 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
73
cogs/cog_wallet.py
Normal 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
73
cogs/wallet.py
Normal 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))
|
@@ -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"
|
||||
},
|
||||
|
@@ -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 (посилка)",
|
||||
|
@@ -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)",
|
@@ -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
52
main.py
@@ -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())
|
||||
|
@@ -1,4 +1,3 @@
|
||||
from modules.migrator import migrate_database
|
||||
|
||||
|
||||
migrate_database()
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -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]
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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")
|
||||
|
2
modules/utils/__init__.py
Normal file
2
modules/utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .cache_utils import restore_from_cache
|
||||
from .logging_utils import get_logger, get_logging_config
|
10
modules/utils/cache_utils.py
Normal file
10
modules/utils/cache_utils.py
Normal 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}")
|
35
modules/utils/logging_utils.py
Normal file
35
modules/utils/logging_utils.py
Normal 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)
|
9
modules/weather/parser.py
Normal file
9
modules/weather/parser.py
Normal 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
106
modules/weather/reporter.py
Normal 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
6
pyproject.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[tool.black]
|
||||
line-length = 96
|
||||
target-version = ["py311"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
@@ -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
|
Reference in New Issue
Block a user