Very Initial MVP

There is so much more to do, but I think it is time to commit this to VCS
This commit is contained in:
Emily Doherty 2024-11-30 03:27:32 -08:00
parent c1a3c73c1d
commit c78ced85ca
35 changed files with 1184 additions and 0 deletions

1
.gitignore vendored
View File

@ -160,3 +160,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/.tool-versions

8
.idea/.gitignore vendored Normal file
View File

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

9
Dockerfile Normal file
View File

@ -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"]

5
main.py Normal file
View File

@ -0,0 +1,5 @@
import token_bot.token_bot as token_bot
if __name__ == '__main__':
bot = token_bot.TokenBot()
bot.run()

24
requirements.txt Normal file
View File

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

0
token_bot/__init__.py Normal file
View File

View File

View File

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

View File

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

50
token_bot/core.py Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
from .alert_type import AlertType
from .user_schema import User
from .alert_schema import Alert

View File

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

View File

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

View File

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

View File

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

View File

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

25
token_bot/token_bot.py Normal file
View File

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

View File

View File

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

View File

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

View File

@ -0,0 +1,2 @@
class TokenHttpException(Exception):
pass

View File

@ -0,0 +1,7 @@
from enum import Enum
class Flavor(Enum):
RETAIL = 1
CLASSIC = 2

View File

@ -0,0 +1,7 @@
from enum import Enum
class Region(str, Enum):
US = 'us'
EU = 'eu'
KR = 'kr'
TW = 'tw'

265
token_bot/tracker.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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