commit c78ced85caf27dea2f325f627bcc73d4fe613f8a
parent c1a3c73c1d5f8348a5f73e9e8b643503934e2917
Author: Emily Doherty <git@emily.sh>
Date: Sat, 30 Nov 2024 03:27:32 -0800
Very Initial MVP
There is so much more to do, but I think it is time to commit this to VCS
Diffstat:
35 files changed, 1193 insertions(+), 0 deletions(-)
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/token_bot/controller/__init__.py b/token_bot/controller/__init__.py
diff --git a/token_bot/controller/alerts.py 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
@@ -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
@@ -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
diff --git a/token_bot/history_manager/history.py 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/token_bot/token_database/database.py 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
@@ -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
@@ -0,0 +1,2 @@
+class TokenHttpException(Exception):
+ pass
diff --git a/token_bot/token_database/flavor.py 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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