diff --git a/.gitignore b/.gitignore index 5d381cc..fea8917 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +/.tool-versions diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8b45ff8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-bookworm as base +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +FROM base as app +COPY . . + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..2855657 --- /dev/null +++ b/main.py @@ -0,0 +1,5 @@ +import token_bot.token_bot as token_bot + +if __name__ == '__main__': + bot = token_bot.TokenBot() + bot.run() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1cc6d14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +aiodynamo==24.1 +aiohttp==3.9.5 +aiosignal==1.3.1 +anyio==4.4.0 +attrs==23.2.0 +certifi==2024.6.2 +croniter==2.0.5 +discord-py-interactions==5.12.1 +discord-typings==0.9.0 +emoji==2.12.1 +frozenlist==1.4.1 +h11==0.14.0 +httpcore==1.0.5 +httpx==0.27.0 +idna==3.7 +multidict==6.0.5 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +pytz==2024.1 +six==1.16.0 +sniffio==1.3.1 +tomli==2.0.1 +typing_extensions==4.12.2 +yarl==1.9.4 diff --git a/token_bot/__init__.py b/token_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/token_bot/controller/__init__.py b/token_bot/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/token_bot/controller/alerts.py b/token_bot/controller/alerts.py new file mode 100644 index 0000000..0a5dc1e --- /dev/null +++ b/token_bot/controller/alerts.py @@ -0,0 +1,42 @@ +import os +from typing import List + +import aiodynamo.client +import aiohttp + +from token_bot.persistant_database import Alert, User, AlertType +from token_bot.persistant_database import database as pdb +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region + + +class AlertsController: + def __init__(self, session: aiohttp.ClientSession): + self._pdb: pdb.Database = pdb.Database(session) + self.table = aiodynamo.client.Table = self._pdb.client.table(os.getenv('ALERTS_TABLE')) + + @staticmethod + def _user_to_obj(user: int | User) -> User: + if isinstance(user, int): + return User(user) + return user + + @staticmethod + def _alert_to_obj(alert: str | Alert) -> Alert: + if isinstance(alert, str): + return Alert.from_str(alert) + return alert + + async def add_user(self, user: int | User, alert: str | Alert) -> None: + user = self._user_to_obj(user) + alert = self._alert_to_obj(alert) + await alert.add_user(self.table, user) + + async def delete_user(self, user: int | User, alert: str | Alert): + user = self._user_to_obj(user) + alert = self._alert_to_obj(alert) + await alert.remove_user(self.table, user) + + async def get_users(self, alert: str | Alert, consistent: bool = False) -> List[User]: + alert = self._alert_to_obj(alert) + return await alert.get_users(self.table, consistent=consistent ) diff --git a/token_bot/controller/users.py b/token_bot/controller/users.py new file mode 100644 index 0000000..90ef000 --- /dev/null +++ b/token_bot/controller/users.py @@ -0,0 +1,62 @@ +import os +from typing import List + +import aiodynamo.client +import aiohttp + +from token_bot.persistant_database.user_schema import User +from token_bot.persistant_database import database as pdb, Alert +from token_bot.controller.alerts import AlertsController as AlertS, AlertsController + + +class UsersController: + def __init__(self, session: aiohttp.ClientSession): + self._pdb: pdb.Database = pdb.Database(session) + self.table: aiodynamo.client.Table = self._pdb.client.table(os.getenv('USERS_TABLE')) + + @staticmethod + def _user_to_obj(user: int | User) -> User: + if isinstance(user, int): + return User(user) + return user + + @staticmethod + def _alert_to_obj(alert: str | Alert) -> Alert: + if isinstance(alert, str): + return Alert.from_str(alert) + return alert + + async def add(self, user: int | User): + user = self._user_to_obj(user) + await user.put(self.table) + + async def delete(self, user: int | User): + user = self._user_to_obj(user) + await user.delete(self.table) + + async def exists(self, user: int | User) -> bool: + user = self._user_to_obj(user) + return await user.get(self.table) + + async def get(self, user: int | User) -> User: + user = self._user_to_obj(user) + await user.get(self.table) + return user + + async def list_alerts(self, user: int | User) -> List[Alert]: + user = self._user_to_obj(user) + await user.get(self.table) + return user.subscribed_alerts + + async def is_subscribed(self, user: int | User, alert: str | Alert) -> bool: + user = self._user_to_obj(user) + alert = self._alert_to_obj(alert) + await user.get(self.table) + return alert in user.subscribed_alerts + + async def add_alert(self, user: int | User, alert: str | Alert) -> None: + user = self._user_to_obj(user) + alert = self._alert_to_obj(alert) + await user.get(self.table) + user.subscribed_alerts.append(alert) + await user.put(self.table) diff --git a/token_bot/core.py b/token_bot/core.py new file mode 100644 index 0000000..024633f --- /dev/null +++ b/token_bot/core.py @@ -0,0 +1,50 @@ +import json +import os + +import aiohttp +from interactions import Extension, Permissions, SlashContext, OptionType, slash_option +from interactions import check, is_owner, slash_command, slash_default_member_permission, listen +from interactions.api.events import Startup, MessageCreate +from interactions import Task, IntervalTrigger + + +from token_bot.token_database import database as tdb +from token_bot.token_database import database as pdb + +VERSION = "0.1.0" + + +class Core(Extension): + def __init__(self, bot): + self._tdb: tdb.Database | None = None + self._pdb: pdb.Database | None = None + + @listen(Startup) + async def on_start(self): + print("TokenBot Core ready") + print(f"This is bot version {VERSION}") + self._tdb = tdb.Database(aiohttp.ClientSession()) + + @slash_command() + async def version(self, ctx): + await ctx.send(f"This is bot version {VERSION}", ephemeral=True) + + @slash_command() + async def help(self, ctx): + await ctx.send(f"This is bot help command", ephemeral=True) + + @slash_command( + description="The current retail token cost" + ) + async def current(self, ctx): + current = await self._tdb.current(tdb.Flavor.RETAIL) + await ctx.send(f"us: {current['us']}\neu: {current['eu']}\ntw: {current['tw']}\nkr: {current['kr']}", + ephemeral=True) + + @slash_command( + description="The current classic token cost" + ) + async def current_classic(self, ctx): + current = await self._tdb.current(tdb.Flavor.CLASSIC) + await ctx.send(f"us: {current['us']}\neu: {current['eu']}\ntw: {current['tw']}\nkr: {current['kr']}", + ephemeral=True) diff --git a/token_bot/history_manager/__init__.py b/token_bot/history_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/token_bot/history_manager/history.py b/token_bot/history_manager/history.py new file mode 100644 index 0000000..a131767 --- /dev/null +++ b/token_bot/history_manager/history.py @@ -0,0 +1,55 @@ +import datetime +from typing import LiteralString, Set, List, Tuple + +from token_bot.history_manager.update_trigger import UpdateTrigger +from token_bot.persistant_database import AlertType, Alert +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region + + +class History: + def __init__(self, flavor: Flavor, region: Region): + self._flavor : Flavor = flavor + self._region : Region = region + self._history : List[Tuple[datetime, int]] = [] + self._last_price_movement : int = 0 + self._latest_price_datum : Tuple[datetime.datetime, int] | None = None + self._update_triggers : List[UpdateTrigger] = [] + self._squelched_alerts : List[Alert] = [] + + for alert_type in AlertType: + self._update_triggers.append(UpdateTrigger(Alert(alert_type, flavor, self._region))) + + @property + def flavor(self) -> Flavor: + return self._flavor + + @property + def region(self) -> Region: + return self._region + + @property + def last_price_datum(self) -> Tuple[datetime.datetime, int] | None: + return self._latest_price_datum + + @property + def history(self) -> List[Tuple[datetime.datetime, int]]: + return self._history + + async def _process_update_triggers(self) -> List[Alert]: + alerts = [] + for trigger in self._update_triggers: + if trigger.check_and_update(self._latest_price_datum, self._history) and trigger.alert not in self._squelched_alerts: + alerts.append(trigger.alert) + + return alerts + + async def add_price(self, datum: Tuple[datetime.datetime, int]) -> List[Alert]: + if self._latest_price_datum is None: + self._latest_price_datum = datum + self._last_price_movement = datum[1] - self._latest_price_datum[1] + self._latest_price_datum = datum + self._history.append(datum) + return await self._process_update_triggers() + + diff --git a/token_bot/history_manager/history_manager.py b/token_bot/history_manager/history_manager.py new file mode 100644 index 0000000..3c3dfd6 --- /dev/null +++ b/token_bot/history_manager/history_manager.py @@ -0,0 +1,61 @@ +import datetime +from typing import Set, List, Dict, LiteralString, Tuple + +from token_bot.history_manager.history import History +from token_bot.persistant_database import Alert +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region + +from token_bot.token_database import database as tdb + +class HistoryManager: + def __init__(self, token_db: tdb.Database): + self._history : Dict[Flavor, Dict[Region, History]] = {} + self._tdb : tdb.Database = token_db + for flavor in Flavor: + self._history[flavor] = {} + for region in Region: + self._history[flavor][Region(region)] = History(flavor, Region(region)) + + + async def _retrieve_data(self, flavor: Flavor, region: Region) -> List[Tuple[datetime.datetime, int]]: + high_fidelity_time = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(hours=72) + all_history = await self._tdb.history(flavor, region) + high_fidelity_history = await self._tdb.history(flavor, region, '72h') + final_response = [] + for data_point in all_history: + datum = (datetime.datetime.fromisoformat(data_point[0]), data_point[1]) + if datum[0] < high_fidelity_time: + final_response.append(datum) + for data_point in high_fidelity_history: + datum = (datetime.datetime.fromisoformat(data_point[0]), data_point[1]) + final_response.append(datum) + return final_response + + + async def load_data(self): + for flavor in Flavor: + for r in Region: + region = Region(r) + history = History(flavor, Region(region)) + history_response = await self._retrieve_data(flavor, region) + for item in history_response: + await history.add_price(item) + self._history[flavor][region] = history + + + async def update_data(self, flavor: Flavor, region: Region) -> List[Alert]: + history = self._history[flavor][region] + current_price_data = await self._tdb.current(flavor) + current_region_data = current_price_data[region.value.lower()] + datum = ( + datetime.datetime.fromisoformat(current_region_data[0]), + current_region_data[1] + ) + if datum != history.last_price_datum: + return await history.add_price(datum) + return [] + + + async def get_history(self, flavor, region) -> History: + return self._history[flavor][region] diff --git a/token_bot/history_manager/update_trigger.py b/token_bot/history_manager/update_trigger.py new file mode 100644 index 0000000..919d7a9 --- /dev/null +++ b/token_bot/history_manager/update_trigger.py @@ -0,0 +1,79 @@ +import datetime +import operator +from typing import Tuple, List + +from token_bot.persistant_database import Alert, AlertType + + +class UpdateTrigger: + def __init__(self, alert: Alert): + self._alert : Alert = alert + self._last_trigger : Tuple[datetime.datetime, int] | None = None + + @property + def alert(self) -> Alert: + return self._alert + + def _find_next_trigger(self, comparison_operator: operator, starting_point: datetime.datetime, history: List[Tuple[datetime.datetime, int]]): + candidate_datum : Tuple[datetime.datetime, int] | None = None + for datum in history: + if candidate_datum is None: + candidate_datum = datum + if (datum[0] > starting_point and datum != history[-1]) and comparison_operator(datum[1], candidate_datum[1]): + candidate_datum = datum + self._last_trigger = candidate_datum + + def check_and_update(self, new_datum: Tuple[datetime.datetime, int], history: List[Tuple[datetime.datetime, int]]) -> bool: + now = datetime.datetime.now(tz=datetime.timezone.utc) + match self._alert.alert_type: + case AlertType.DAILY_LOW: + time_range = datetime.timedelta(days=1) + comparison_operator = operator.lt + case AlertType.DAILY_HIGH: + time_range = datetime.timedelta(days=1) + comparison_operator = operator.gt + case AlertType.WEEKLY_LOW: + time_range = datetime.timedelta(weeks=1) + comparison_operator = operator.lt + case AlertType.WEEKLY_HIGH: + time_range = datetime.timedelta(weeks=1) + comparison_operator = operator.gt + case AlertType.MONTHLY_LOW: + time_range = datetime.timedelta(days=31) + comparison_operator = operator.lt + case AlertType.MONTHLY_HIGH: + time_range = datetime.timedelta(days=31) + comparison_operator = operator.gt + case AlertType.YEARLY_LOW: + time_range = datetime.timedelta(days=365) + comparison_operator = operator.lt + case AlertType.YEARLY_HIGH: + time_range = datetime.timedelta(days=365) + comparison_operator = operator.gt + case AlertType.ALL_TIME_LOW: + # TODO Make this calculate based on Flavor and current time the range back to look + time_range = datetime.timedelta(days=int(365.25 * 6)) + comparison_operator = operator.lt + case AlertType.ALL_TIME_HIGH: + time_range = datetime.timedelta(days=int(365.25 * 6)) + comparison_operator = operator.gt + case _: + # TODO: The logic here is certainly wrong for Custom + time_range = datetime.timedelta(days=int(365.25 * 6)) + comparison_operator = operator.eq + + if new_datum[0] > now - time_range: + if self._last_trigger is None: + self._last_trigger = new_datum + return True + + # If the self._last_trigger falls out of scope of the alert, find the next thing that would have triggered + # the alert so the next time a high or low comes up it's correctly comparing against the range of the alert + # rather than since the alert triggered + if self._last_trigger[0] < now - time_range: + self._find_next_trigger(comparison_operator, now - time_range, history) + + if comparison_operator(new_datum[1], self._last_trigger[1]): + self._last_trigger = new_datum + return True + return False \ No newline at end of file diff --git a/token_bot/persistant_database/__init__.py b/token_bot/persistant_database/__init__.py new file mode 100644 index 0000000..520dd6c --- /dev/null +++ b/token_bot/persistant_database/__init__.py @@ -0,0 +1,4 @@ +from .alert_type import AlertType +from .user_schema import User +from .alert_schema import Alert + diff --git a/token_bot/persistant_database/alert_category.py b/token_bot/persistant_database/alert_category.py new file mode 100644 index 0000000..da2986c --- /dev/null +++ b/token_bot/persistant_database/alert_category.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class AlertCategory(Enum): + HIGH = 1 + LOW = 2 + CUSTOM = 3 + + @staticmethod + def from_str(category: str): # It gets mad when I use the Type[AlertCategory] as a type hint + match category: + case "high_alert_button": + return AlertCategory.HIGH + case "low_alert_button": + return AlertCategory.LOW + case "sp_add_button": + return AlertCategory.CUSTOM + case _: + return AlertCategory[category.upper()] \ No newline at end of file diff --git a/token_bot/persistant_database/alert_schema.py b/token_bot/persistant_database/alert_schema.py new file mode 100644 index 0000000..52f4a68 --- /dev/null +++ b/token_bot/persistant_database/alert_schema.py @@ -0,0 +1,131 @@ +from typing import List + +from aiodynamo.client import Table +from aiodynamo.errors import ItemNotFound +from aiodynamo.expressions import F + +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region +import token_bot.persistant_database as pdb + + +class Alert: + def __init__(self, alert: pdb.AlertType, flavor: Flavor, region: Region, price: int = 0) -> None: + # AlertType is the Primary Key + self.alert_type: pdb.AlertType = alert + # Flavor (Retail, Classic) is the Sort Key + self.flavor: Flavor = flavor + self.region: Region = region + self.price: int = price + self._loaded: bool = False + self._users: List[pdb.User] = [] + + @classmethod + def from_item(cls, primary_key: int, sort_key: str, users: List[int]) -> 'Alert': + alert_type = pdb.AlertType(primary_key) + flavor_repr, region_repr, price_repr = sort_key.split('-') + flavor = Flavor(int(flavor_repr)) + region = Region(region_repr) + price = int(price_repr) + return cls(alert_type, flavor, region, price) + + @classmethod + def from_str(cls, string_trinity: str) -> 'Alert': + alert_repr, flavor_repr, region_repr, price_repr = string_trinity.split('-') + if len(string_trinity.split('-')) != 4: + raise ValueError + alert = pdb.AlertType(int(alert_repr)) + flavor = Flavor(int(flavor_repr)) + region = Region(region_repr) + price = int(price_repr) + return cls(alert, flavor, region, price) + + @property + def primary_key(self) -> int: + return self.alert_type.value + + @property + def primary_key_name(self) -> str: + return "alert" + + @property + def sort_key(self) -> str: + return f"{self.flavor.value}-{self.region.value}-{self.price}" + + @property + def sort_key_name(self) -> str: + return "flavor-region-price" + + @property + def key(self) -> dict[str, str | int]: + return { + self.primary_key_name: self.primary_key, + self.sort_key_name: self.sort_key + } + + def __str__(self): + return f"{self.alert_type.value}-{self.flavor.value}-{self.region.value}-{self.price}" + + def __eq__(self, other): + return self.alert_type == other.alert_type and self.flavor == other.flavor and self.price == other.price + + def to_human_string(self): + if self.alert_type.SPECIFIC_PRICE: + raise NotImplementedError + else: + return f"\n|Region: {self.region.value.upper()}\tFlavor: {self.flavor.value.upper()}\tAlert: {self.alert_type.name.lower()}|" + + async def _lazy_load(self, table: Table, consistent: bool = False) -> None: + if consistent or not self._loaded: + await self.get(table, consistent=consistent) + + async def _append_user(self, table: Table, user: pdb.User) -> None: + self._users.append(user) + await self.put(table) + + async def _remove_user(self, table: Table, user: pdb.User) -> None: + update_expression = F("users").delete({user.user_id}) + await table.update_item( + key=self.key, + update_expression=update_expression + ) + + async def put(self, table: Table) -> None: + user_ids = [user.user_id for user in self._users] + await table.put_item( + item={ + self.primary_key_name: self.primary_key, + self.sort_key_name: self.sort_key, + 'users': user_ids + } + ) + + async def get(self, table: Table, consistent: bool = False) -> bool: + try: + response = await table.get_item( + key=self.key, + consistent_read=consistent + ) + except ItemNotFound: + return False + if 'Item' in response: + self._users = [pdb.User(int(user_id)) for user_id in response['Item']['users']['NS']] + self._loaded = True + return True + + async def get_users(self, table: Table, consistent: bool = False) -> List[pdb.User]: + await self._lazy_load(table, consistent=consistent) + + return self._users + + async def add_user(self, table: Table, user: pdb.User, consistent: bool = False) -> None: + await self._lazy_load(table, consistent=consistent) + + if user not in self._users: + await self._append_user(table=table, user=user) + + async def remove_user(self, table: Table, user: pdb.User, consistent: bool = True) -> None: + await self._lazy_load(table, consistent=consistent) + + if user in self._users: + await self._remove_user(table=table, user=user) diff --git a/token_bot/persistant_database/alert_type.py b/token_bot/persistant_database/alert_type.py new file mode 100644 index 0000000..0cf4750 --- /dev/null +++ b/token_bot/persistant_database/alert_type.py @@ -0,0 +1,36 @@ +from enum import Enum + +class AlertType(Enum): + ALL_TIME_HIGH = 1 + ALL_TIME_LOW = 2 + DAILY_HIGH = 3 + DAILY_LOW = 4 + WEEKLY_HIGH = 5 + WEEKLY_LOW = 6 + MONTHLY_HIGH = 7 + MONTHLY_LOW = 8 + YEARLY_HIGH = 9 + YEARLY_LOW = 10 + SPECIFIC_PRICE = 11 + + @staticmethod + def from_str(category: str): + match category: + case "Daily High": + return AlertType.DAILY_HIGH + case "Daily Low": + return AlertType.DAILY_LOW + case "Weekly High": + return AlertType.WEEKLY_HIGH + case "Weekly Low": + return AlertType.WEEKLY_LOW + case "Monthly High": + return AlertType.MONTHLY_HIGH + case "Monthly Low": + return AlertType.MONTHLY_LOW + case "Yearly High": + return AlertType.YEARLY_HIGH + case "Yearly Low": + return AlertType.YEARLY_LOW + case _: + return AlertType.SPECIFIC_PRICE \ No newline at end of file diff --git a/token_bot/persistant_database/database.py b/token_bot/persistant_database/database.py new file mode 100644 index 0000000..f3632b0 --- /dev/null +++ b/token_bot/persistant_database/database.py @@ -0,0 +1,16 @@ +import os + +import aiohttp + +from aiodynamo.client import Client +from aiodynamo.client import Table +from aiodynamo.credentials import Credentials +from aiodynamo.http.httpx import HTTPX +from aiodynamo.http.aiohttp import AIOHTTP +from httpx import AsyncClient + + +class Database: + def __init__(self, session: aiohttp.ClientSession): + self.client = Client(AIOHTTP(session), Credentials.auto(), os.getenv('AWS_REGION')) + diff --git a/token_bot/persistant_database/user_schema.py b/token_bot/persistant_database/user_schema.py new file mode 100644 index 0000000..85e7a7a --- /dev/null +++ b/token_bot/persistant_database/user_schema.py @@ -0,0 +1,80 @@ +from typing import List, Tuple, Dict + +from aiodynamo.client import Table +from aiodynamo.errors import ItemNotFound + + +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region +import token_bot.persistant_database as pdb + +class User: + def __init__(self, user_id: int, region: Region = None, subscribed_alerts: List['pdb.Alert'] = None) -> None: + self.user_id: int = user_id + self._loaded: bool = False + self.region: Region = region + self.subscribed_alerts: List[pdb.Alert] = subscribed_alerts + + def __eq__(self, other): + return self.user_id == other.user_id + + @classmethod + def from_item(cls, primary_key: int, region: Region, subscribed_alerts: List[str]) -> 'User': + alerts = [pdb.Alert.from_str(alert_str) for alert_str in subscribed_alerts] + return cls(primary_key, region, alerts) + + @property + def primary_key(self) -> int: + return self.user_id + + @property + def primary_key_name(self) -> str: + return 'user_id' + + @property + def key(self) -> Dict[str, int]: + return { + self.primary_key_name: self.primary_key + } + + + def _subscribed_alerts_as_trinity_list(self) -> List[str]: + return [str(alert) for alert in self.subscribed_alerts] if self.subscribed_alerts else [] + + async def _lazy_load(self, table: Table, consistent: bool = False) -> None: + if consistent or not self._loaded: + await self.get(table, consistent=consistent) + + async def put(self, table: Table) -> None: + await table.put_item( + item={ + self.primary_key_name: self.primary_key, + 'region': self.region, + 'subscribed_alerts': self._subscribed_alerts_as_trinity_list() + } + ) + + async def delete(self, table: Table) -> None: + if not self._loaded: + await self._lazy_load(table, consistent=True) + if self.subscribed_alerts: + for alert in self.subscribed_alerts: + await alert.remove_user(table, self) + await table.delete_item( + key={self.primary_key_name: self.primary_key}, + ) + + async def get(self, table: Table, consistent: bool = False) -> bool: + try: + response = await table.get_item( + key=self.key, + consistent_read=consistent + ) + except ItemNotFound: + return False + + self.subscribed_alerts = [] + for string_trinity in response['subscribed_alerts']: + self.subscribed_alerts.append(pdb.Alert.from_str(string_trinity)) + self.region = Region(response['region']) + return True diff --git a/token_bot/token_bot.py b/token_bot/token_bot.py new file mode 100644 index 0000000..d6a1dae --- /dev/null +++ b/token_bot/token_bot.py @@ -0,0 +1,25 @@ +import os +import logging + +import aiohttp +from dotenv import load_dotenv +from interactions import Client, Intents + + +class TokenBot: + def __init__(self): + load_dotenv() + print("#### WoW Token Bot Startup ####") + logging.basicConfig() + log = logging.getLogger("TokenBotLogger") + log.setLevel(logging.INFO) + self.bot = Client( + intents=Intents.DEFAULT, + asyncio_debug=True, + logger=log + ) + + def run(self): + self.bot.load_extension("token_bot.core") + self.bot.load_extension("token_bot.tracker") + self.bot.start(os.getenv("DISCORD_TOKEN")) diff --git a/token_bot/token_database/__init__.py b/token_bot/token_database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/token_bot/token_database/database.py b/token_bot/token_database/database.py new file mode 100644 index 0000000..4ecbb69 --- /dev/null +++ b/token_bot/token_database/database.py @@ -0,0 +1,42 @@ +from typing import Dict, List, LiteralString + +from token_bot.token_database.endpoints import Endpoints +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.exceptions import * +import aiohttp +import json + +from token_bot.token_database.region import Region + + +class Database: + def __init__(self, session: aiohttp.ClientSession): + self.data_url = "https://data.wowtoken.app/v2/" + self.session = session + + async def _get_data(self, endpoint: str) -> Dict | List: + url = f"{self.data_url}{endpoint}" + + success = False + tries = 0 + + while not success and tries < 3: + try: + return await self._external_call(url) + except TokenHttpException: + tries += 1 + return {} + + async def _external_call(self, url) -> Dict | List: + async with self.session.get(url) as resp: + await resp.text() + if resp.ok: + return await resp.json() + else: + raise TokenHttpException(resp.status) + + async def current(self, flavor: Flavor) -> dict: + return await self._get_data(f'current/{flavor.name.lower()}.json') + + async def history(self, flavor: Flavor, region: Region, relative_time: str = 'all'): + return await self._get_data(f'relative/{flavor.name.lower()}/{region.value.lower()}/{relative_time}.json') \ No newline at end of file diff --git a/token_bot/token_database/endpoints.py b/token_bot/token_database/endpoints.py new file mode 100644 index 0000000..9353857 --- /dev/null +++ b/token_bot/token_database/endpoints.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class Endpoints(str, Enum): + RETAIL_CURRENT = '/v2/current/retail.json' + CLASSIC_CURRENT = '/v2/current/classic.json' + RETAIL_HISTORY_ALL = '/v2/relative/retail/us/all.json' diff --git a/token_bot/token_database/exceptions.py b/token_bot/token_database/exceptions.py new file mode 100644 index 0000000..085b93a --- /dev/null +++ b/token_bot/token_database/exceptions.py @@ -0,0 +1,2 @@ +class TokenHttpException(Exception): + pass diff --git a/token_bot/token_database/flavor.py b/token_bot/token_database/flavor.py new file mode 100644 index 0000000..7782617 --- /dev/null +++ b/token_bot/token_database/flavor.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class Flavor(Enum): + RETAIL = 1 + CLASSIC = 2 + diff --git a/token_bot/token_database/region.py b/token_bot/token_database/region.py new file mode 100644 index 0000000..6e91929 --- /dev/null +++ b/token_bot/token_database/region.py @@ -0,0 +1,7 @@ +from enum import Enum + +class Region(str, Enum): + US = 'us' + EU = 'eu' + KR = 'kr' + TW = 'tw' diff --git a/token_bot/tracker.py b/token_bot/tracker.py new file mode 100644 index 0000000..6ea859f --- /dev/null +++ b/token_bot/tracker.py @@ -0,0 +1,265 @@ +import copy +import json +import logging +import os +from enum import Enum +from typing import Any, Type, Dict, List + +import aiohttp +from interactions import Extension, Permissions, SlashContext, OptionType, slash_option, component_callback, \ + ComponentContext, StringSelectMenu, Message +from interactions import check, is_owner, slash_command, slash_default_member_permission, listen +from interactions.api.events import Startup, MessageCreate +from interactions.api.events import Component +from interactions import Task, IntervalTrigger + +from token_bot.history_manager.history_manager import HistoryManager +from token_bot.persistant_database import database as pdb +from token_bot.token_database import database as tdb + +from token_bot.token_database.flavor import Flavor +from token_bot.token_database.region import Region + +from token_bot.persistant_database.user_schema import User +from token_bot.persistant_database.alert_schema import Alert +from token_bot.persistant_database.alert_type import AlertType +from token_bot.persistant_database.alert_category import AlertCategory + +from token_bot.controller.users import UsersController +from token_bot.controller.alerts import AlertsController +from token_bot.ui.action_row.tracker import ALERT_TYPE_ROW + +from token_bot.ui.select_menus.region_menu import REGION_MENU +from token_bot.ui.select_menus.flavor_menu import FLAVOR_MENU +from token_bot.ui.select_menus.alert_menu import HIGH_ALERT_MENU, LOW_ALERT_MENU + + +class Tracker(Extension): + def __init__(self, bot): + self._users: UsersController | None = None + self._alerts: AlertsController | None = None + self._tdb: tdb.Database | None = None + self._history_manager: HistoryManager | None = None + + + ################################### + # Task Functions # + ################################### + + @Task.create(IntervalTrigger(minutes=1)) + async def update_data(self): + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Updating Price") + users_alerts: Dict[User, List[Alert]] = {} + for flavor in Flavor: + for region in Region: + alerts = await self._history_manager.update_data(flavor, Region(region)) + for alert in alerts: + users = await self._alerts.get_users(alert, consistent=True) + for user in users: + if user not in users_alerts: + users_alerts[user] = [alert] + else: + users_alerts[user].append(alert) + for user in users_alerts: + discord_user = self.bot.get_user(user.user_id) + alert_message = str() + for alert in users_alerts[user]: + alert_message += f"{alert.to_human_string()}" + await discord_user.send(f"Hello, you requested to be sent an alert when the price of the World of Warcraft" + f"token reaches a certain value. The following alerts have been triggered: {alert_message}") + + + ################################### + # Slash Commands # + ################################### + + @listen(Startup) + async def on_start(self): + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initializing") + self._users = UsersController(aiohttp.ClientSession()) + self._alerts = AlertsController(aiohttp.ClientSession()) + self._tdb = tdb.Database(aiohttp.ClientSession()) + self._history_manager = HistoryManager(self._tdb) + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initialized") + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Historical Data") + await self._history_manager.load_data() + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Historical Data Finished") + self.bot.logger.log(logging.INFO, "TokenBot Tracker: Started") + self.update_data.start() + + + @slash_command() + async def register(self, ctx: SlashContext): + text = ("## Select a region to register with \n\n" + "Please note: \n" + "* You can only be registered with one region at a time \n" + "* Changing your region will remove all previous alerts you have signed up for") + await self._users.add(ctx.member.id) + await ctx.send(text, components=REGION_MENU, ephemeral=True) + + @slash_command() + async def remove_registration(self, ctx: SlashContext): + await self._users.delete(ctx.member.id) + await ctx.send("All alert subscriptions and user registration deleted", ephemeral=True) + + @slash_command() + async def delete(self, ctx: SlashContext): + await self._users.delete(ctx.member.id) + await ctx.send("Deletion Successful", ephemeral=True) + + @slash_command() + async def exists(self, ctx: SlashContext): + await ctx.send(str(await self._users.exists(ctx.member.id)), ephemeral=True) + + @slash_command() + async def add_alert(self, ctx: SlashContext): + if not await self._users.exists(ctx.member.id): + await ctx.send("You are not registered with any region\n" + "Please register with /register before adding alerts", + ephemeral=True) + return + user = await self._users.get(ctx.member.id) + + try: + flavor = await self.flavor_select_menu(ctx) + alert_category = await self.alert_category_select_menu(ctx) + match alert_category: + case AlertCategory.LOW: + alert_type = await self.low_alert_select_menu(ctx) + case AlertCategory.HIGH: + alert_type = await self.high_alert_select_menu(ctx) + case _: + raise NotImplementedError + + except TimeoutError: + return + + else: + alert = Alert(alert_type, flavor, user.region) + if not await self._users.is_subscribed(user, alert): + await self._users.add_alert(user, alert) + await self._alerts.add_user(user, alert) + await ctx.send("Successfully added alert", ephemeral=True) + + else: + await ctx.send("You are already subscribed to this alert", ephemeral=True) + + + @slash_command() + async def remove_alert(self, ctx: SlashContext): + pass + + @slash_command() + async def list_alerts(self, ctx: SlashContext): + await ctx.send(str(await self._users.list_alerts(ctx.member.id)), ephemeral=True) + + ################################### + # Callbacks Commands # + ################################### + + # TODO: export to a separate file if possible + + @component_callback('flavor_menu') + async def flavor_menu(self, ctx: ComponentContext): + await ctx.send(f"Selected Flavor: {ctx.values[0]}", ephemeral=True) + + @component_callback('high_alert_menu') + async def alert_menu(self, ctx: ComponentContext): + await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True) + + @component_callback('low_alert_menu') + async def alert_menu(self, ctx: ComponentContext): + await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True) + + @component_callback('region_menu') + async def region_menu(self, ctx: ComponentContext): + user = User(ctx.member.id, Region(ctx.values[0].lower()), subscribed_alerts=[]) + await self._users.add(user) + await ctx.send(f"Successfully registered with the {ctx.values[0]} region", ephemeral=True) + + @component_callback('high_alert_button') + async def high_alert_button(self, ctx: ComponentContext): + await ctx.send("You selected to add a High Price Alert", ephemeral=True) + + @component_callback('low_alert_button') + async def low_alert_button(self, ctx: ComponentContext): + await ctx.send("You selected to add a Low Price Alert", ephemeral=True) + + @component_callback('custom_alert_button') + async def custom_alert_button(self, ctx: ComponentContext): + await ctx.send("You selected to add a Custom Price Alert", ephemeral=True) + + ################################### + # Helper Functions # + ################################### + + async def flavor_select_menu(self, ctx: SlashContext) -> Type[Flavor]: + flavor_menu = copy.deepcopy(FLAVOR_MENU) + + flavor_message = await ctx.send( + "Select a flavor to add alerts for", + components=flavor_menu, + ephemeral=True) + try: + flavor_component: Component = await self.bot.wait_for_component(messages=flavor_message, + components=flavor_menu, timeout=30) + except TimeoutError: + flavor_menu.disabled = True + await flavor_message.edit(context=ctx, components=flavor_menu, content="Timed out") + raise TimeoutError + else: + flavor = Flavor[flavor_component.ctx.values[0].upper()] + flavor_menu.disabled = True + await flavor_message.edit(context=ctx, components=flavor_menu) + return flavor + + async def alert_category_select_menu(self, ctx: SlashContext) -> AlertCategory: + alert_type_button = copy.deepcopy(ALERT_TYPE_ROW) + alert_type_message = await ctx.send( + "Select an alert type to add", + components=alert_type_button, + ephemeral=True) + try: + alert_type_component: Component = await self.bot.wait_for_component(messages=alert_type_message, + components=alert_type_button, + timeout=30) + except TimeoutError: + for button in alert_type_button[0].components: + button.disabled = True + await alert_type_message.edit(context=ctx, components=alert_type_button, content="Timed out") + raise TimeoutError + else: + alert_type = AlertCategory.from_str(alert_type_component.ctx.custom_id) + for button in alert_type_button[0].components: + button.disabled = True + await alert_type_message.edit(context=ctx, components=alert_type_button) + return alert_type + + + async def _alert_select_menu_handler(self, ctx: SlashContext, menu: StringSelectMenu, message: Message) -> AlertType: + try: + component: Component = await self.bot.wait_for_component(messages=message, components=menu, timeout=30) + except TimeoutError: + menu.disabled = True + await message.edit(context=ctx, components=menu, content="Timed out") + raise TimeoutError + else: + menu.disabled = True + await message.edit(context=ctx, components=menu) + return AlertType.from_str(component.ctx.values[0]) + + async def high_alert_select_menu(self, ctx: SlashContext) -> AlertType: + high_menu = copy.deepcopy(HIGH_ALERT_MENU) + high_message = await ctx.send( + "Select a time range to add a High Alert for", + components=high_menu, + ephemeral=True) + return await self._alert_select_menu_handler(ctx, high_menu, high_message) + + async def low_alert_select_menu(self, ctx: SlashContext) -> AlertType: + low_menu = copy.deepcopy(LOW_ALERT_MENU) + low_message = await ctx.send( + "Select a time range to add a Low Alert for", + components=low_menu, + ephemeral=True) + return await self._alert_select_menu_handler(ctx, low_menu, low_message) diff --git a/token_bot/ui/action_row/tracker.py b/token_bot/ui/action_row/tracker.py new file mode 100644 index 0000000..f45ab51 --- /dev/null +++ b/token_bot/ui/action_row/tracker.py @@ -0,0 +1,11 @@ +from interactions import ActionRow + +from token_bot.ui.buttons.tracker.alert_category import HIGH_ALERT_BUTTON, LOW_ALERT_BUTTON, CUSTOM_ALERT_BUTTON + + +ALERT_TYPE_ROW: list[ActionRow] = [ + ActionRow( + HIGH_ALERT_BUTTON, + LOW_ALERT_BUTTON, + ) +] diff --git a/token_bot/ui/buttons/tracker/add.py b/token_bot/ui/buttons/tracker/add.py new file mode 100644 index 0000000..4aa89e6 --- /dev/null +++ b/token_bot/ui/buttons/tracker/add.py @@ -0,0 +1,67 @@ +from interactions import Button, ButtonStyle + +ATH_ADD_BUTTON = Button( + custom_id='ath_add_button', + style=ButtonStyle.GREEN, + label="All Time High" +) + +ATL_ADD_BUTTON = Button( + custom_id='atl_add_button', + style=ButtonStyle.RED, + label="All Time Low" +) + +DH_ADD_BUTTON = Button( + custom_id='dh_add_button', + style=ButtonStyle.GREEN, + label="Daily High" +) + +DL_ADD_BUTTON = Button( + custom_id='dl_add_button', + style=ButtonStyle.RED, + label="Daily Low" +) + +WH_ADD_BUTTON = Button( + custom_id='wh_add_button', + style=ButtonStyle.GREEN, + label="Weekly High" +) + +WL_ADD_BUTTON = Button( + custom_id='wl_add_button', + style=ButtonStyle.RED, + label="Weekly Low" +) + +MH_ADD_BUTTON = Button( + custom_id='mh_add_button', + style=ButtonStyle.GREEN, + label="Monthly High" +) + +ML_ADD_BUTTON = Button( + custom_id='ml_add_button', + style=ButtonStyle.RED, + label="Monthly Low" +) + +YH_ADD_BUTTON = Button( + custom_id='yh_add_button', + style=ButtonStyle.GREEN, + label="Yearly High" +) + +YL_ADD_BUTTON = Button( + custom_id='yl_add_button', + style=ButtonStyle.RED, + label="Yearly Low" +) + +SP_ADD_BUTTON = Button( + custom_id='sp_add_button', + style=ButtonStyle.GRAY, + label="Custom Limit Price" +) diff --git a/token_bot/ui/buttons/tracker/alert_category.py b/token_bot/ui/buttons/tracker/alert_category.py new file mode 100644 index 0000000..9212a04 --- /dev/null +++ b/token_bot/ui/buttons/tracker/alert_category.py @@ -0,0 +1,19 @@ +from interactions import Button, ButtonStyle + +HIGH_ALERT_BUTTON = Button( + custom_id='high_alert_button', + style=ButtonStyle.GREEN, + label="High Price Alert" +) + +LOW_ALERT_BUTTON = Button( + custom_id='low_alert_button', + style=ButtonStyle.RED, + label="Low Price Alert" +) + +CUSTOM_ALERT_BUTTON = Button( + custom_id='sp_add_button', + style=ButtonStyle.GRAY, + label="Custom Price Alert" +) diff --git a/token_bot/ui/buttons/tracker/registration.py b/token_bot/ui/buttons/tracker/registration.py new file mode 100644 index 0000000..9de1230 --- /dev/null +++ b/token_bot/ui/buttons/tracker/registration.py @@ -0,0 +1,19 @@ +from interactions import Button, ButtonStyle + +HIGH_ALERT = Button( + custom_id='high_alert_button', + style=ButtonStyle.GREEN, + label="Add High Alert" +) + +LOW_ALERT = Button( + custom_id='low_alert_button', + style=ButtonStyle.RED, + label="Add Low Alert" +) + +CUSTOM_ALERT = Button( + custom_id='custom_alert_button', + style=ButtonStyle.GRAY, + label="Add Custom Alert" +) \ No newline at end of file diff --git a/token_bot/ui/select_menus/alert_menu.py b/token_bot/ui/select_menus/alert_menu.py new file mode 100644 index 0000000..91c759c --- /dev/null +++ b/token_bot/ui/select_menus/alert_menu.py @@ -0,0 +1,15 @@ +from interactions import StringSelectMenu + +HIGH_ALERT_MENU = StringSelectMenu( + "Daily High", "Weekly High", "Monthly High", "Yearly High", "All Time High", + placeholder="Select a time period", + min_values=1, max_values=1, + custom_id='high_alert_menu' +) + +LOW_ALERT_MENU = StringSelectMenu( + "Daily Low", "Weekly Low", "Monthly Low", "Yearly Low", "All Time Low", + placeholder="Select a time period", + min_values=1, max_values=1, + custom_id='low_alert_menu' +) \ No newline at end of file diff --git a/token_bot/ui/select_menus/flavor_menu.py b/token_bot/ui/select_menus/flavor_menu.py new file mode 100644 index 0000000..61ed9b2 --- /dev/null +++ b/token_bot/ui/select_menus/flavor_menu.py @@ -0,0 +1,8 @@ +from interactions import StringSelectMenu + +FLAVOR_MENU = StringSelectMenu( + "Retail", "Classic", + placeholder="Select version of WoW", + min_values=1, max_values=1, + custom_id='flavor_menu' +) \ No newline at end of file diff --git a/token_bot/ui/select_menus/region_menu.py b/token_bot/ui/select_menus/region_menu.py new file mode 100644 index 0000000..508896d --- /dev/null +++ b/token_bot/ui/select_menus/region_menu.py @@ -0,0 +1,8 @@ +from interactions import StringSelectMenu + +REGION_MENU = StringSelectMenu( + "US", "EU", "KR", "TW", + placeholder="Select a region", + min_values=1, max_values=1, + custom_id='region_menu' +) \ No newline at end of file