36 Commits

Author SHA1 Message Date
kku
2bd980b827 Added stubs for plugins (#53) 2025-06-02 00:15:45 +02:00
1d8c29e73f Closes #12 2025-06-01 15:28:41 +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
53 changed files with 1844 additions and 223 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"))

6
classes/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
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)

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

@@ -1 +1,2 @@
from .message_events import MessageEvents
from .punishment import Punishment

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)

171
classes/pycord_user.py Normal file
View File

@@ -0,0 +1,171 @@
import logging
from dataclasses import dataclass
from logging import Logger
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from classes.base import BaseCacheable
from classes.errors.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)

View File

@@ -1,50 +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()
if self.scheduler is None:
return
# 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()
if self.scheduler is not None:
self.scheduler.shutdown()
await super().close(*args, **kwargs)

View File

@@ -1,9 +0,0 @@
from dataclasses import dataclass
@dataclass
class PycordGuildColors:
default: str
success: str
warning: str
error: str

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

33
cogs/cog_admin.py Normal file
View File

@@ -0,0 +1,33 @@
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()
# TODO Make sure the right locale is being used
await ctx.respond(self.bot._("reload_success", "messages"))
except Exception as exc:
# TODO Make sure the right locale is being used
await ctx.respond(
self.bot._("reload_failure", "messages").format(exception=exc), ephemeral=True
)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogAdmin(bot))

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,38 @@
{
"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."
},
"reload": {
"reload_success": "Configuration has been successfully reloaded.",
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}"
],
"midday": [
"{0} Добрий день! Ласкаво просимо! {1}"
],
"evening": [
"{0} Добрий вечір! Ласкаво просимо! {1}"
],
"night": [
"{0} Доброї ночі! Ласкаво просимо! {1}"
],
"unknown": [
"{0} Вітаннячко! Ласкаво просимо! {1}"
]
}
},
"commands": {
"reload": {
"description": "Reload bot's configuration"
}
},
"actions": {
"bite": {
"name": "Вкусити",
@@ -33,46 +67,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,38 @@
{
"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."
},
"reload": {
"reload_success": "Configuration has been successfully reloaded.",
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Good morning and welcome! {1}",
"{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 +70,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,15 @@
{
"messages": {
"wallet": {
"balance_own": "Ваш баланс складає `{balance}`.",
"balance_user": "Баланс **{user}** складає `{balance}`.",
"transfer_success": "Ви перевели `{amount}` на рахунок **{recipient}**.",
"transfer_insufficient_funds": "Недостатньо коштів. Потрібно ще `{amount}` для цієї транзакції."
},
"reload": {
"reload_success": "Конфігурацію було успішно перезавантажено.",
"reload_failure": "Не вдалось перезавантажити конфігурацію:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}",
@@ -32,6 +42,11 @@
]
}
},
"commands": {
"reload": {
"description": "Перезавантажити конфігурацію бота"
}
},
"actions": {
"bite": {
"name": "Вкусити",
@@ -69,10 +84,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 (посилка)",

61
main.py
View File

@@ -1,38 +1,65 @@
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 typing import Dict
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():
bot = PycordBot(scheduler=scheduler)
bot.load_extension("cogs")
logger.debug("Loading cogs...")
cogs: Dict[str, Exception | bool] = bot.load_extension("cogs", store=True)
logger.info("Loaded cogs: %s", cogs)
logger.debug("Loading plugins...")
plugins: Dict[str, Exception | bool] = bot.load_extension("plugins", store=True)
logger.info("Loaded plugins: %s", plugins)
# 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,13 +22,31 @@ 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_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")
col_guilds: AsyncCollection = db.get_collection("guilds")
col_wallets: AsyncCollection = db.get_collection("wallets")
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_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,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

@@ -1,16 +1,16 @@
from datetime import datetime
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 ujson import loads
from modules.utils import hex_to_int
from database import col_messages
from modules.weather.parser import parse_weather
# Example guild key
@@ -71,9 +71,9 @@ async def report_weather(
# 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]
str(timezone(bot.config["bot"]["timezone"]).utcoffset(datetime.utcnow())).split(
":"
)[:2]
)
api_response = await (

0
plugins/.gitkeep Normal file
View File

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.11.0
async_pymongo==0.1.11
colorthief==0.2.1
deepl==1.21.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.4.0
pytz~=2024.1
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
libbot[speed,pycord]~=3.3.0,<4.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