Compare commits

...

38 Commits

Author SHA1 Message Date
509a7d8a74 Make tracebacks only in debug builds 2026-01-28 18:22:06 -08:00
28fbdab904 Fix listing of alerts 2026-01-28 18:17:52 -08:00
4cb9d4ab70 Version Bump 2026-01-28 17:50:33 -08:00
35ad75cd7c Bug fix
- Validate and enforce positive price for SPECIFIC_PRICE alerts
- improve error handling and alert squelching logic.
2026-01-28 17:49:28 -08:00
62b20cf4ab Version bump 2025-11-06 02:47:37 -08:00
ed79f4b65c Initial implementation of custom price triggers
- likely to have some bugs, but this is good enough for a preview release.
2025-11-06 02:46:03 -08:00
19eb0a4e24 Version bump 2025-11-06 00:23:05 -08:00
faab0d5f7e Improve alert handling, centralize aiohttp.ClientSession, and fix minor bugs 2025-11-06 00:22:21 -08:00
7e387b6cd7 Version bump 2025-11-04 22:09:17 -08:00
f18b129907 Potential fix for "Unknown integration" 2025-11-04 22:05:53 -08:00
6a4b1f117b Version bump 2025-11-04 21:07:12 -08:00
fc57a7d172 Dependency bumps 2025-11-04 21:06:52 -08:00
0d0fe8e685 - If you unregister and then list your alerts, the bot will be sad 2025-11-04 21:06:40 -08:00
c5bb53c0e7 - Add error handling for Forbidden exceptions when sending alerts
- Improve alert message formatting
2025-11-04 20:34:07 -08:00
2164e98730 General cleanup
- Moved alert removal reminder to bottom of message

- debug output of alerts to discord only on debug environments
2024-12-20 18:58:42 -08:00
f20a8c6476 Version bump 2024-12-16 14:45:49 -08:00
3fffcf86c9 Format timestamps to be in local time zone provided by Discord 2024-12-16 14:45:36 -08:00
84561948da Help code update 2024-12-14 20:58:01 -08:00
621bd16d71 Formatting 2024-12-14 20:57:53 -08:00
7c6b66660e Bump version 2024-12-14 20:43:22 -08:00
dfa5637a42 Log when alerts are being sent for restart safety
Maybe we could catch and hold signals here too?
2024-12-14 20:43:12 -08:00
9ddba0d9b1 Supress an error raised from deferring 2024-12-14 20:38:12 -08:00
ceaabe3dac Fix await for user_is_registered 2024-12-14 20:29:21 -08:00
a9e50c2c8e (minor) Text update to reflect new bot name 2024-12-10 00:39:12 -08:00
ab885fccff (minor) Formatting fix since I had to remove the pre-commit hook for now
There was some terrible funky interaction between PyCharm, Black, and the pre-commit hook where the pre-commit black would complain that the file formatted need fixing, but black in the IDE and CLI would pass with no issue.
2024-12-10 00:01:13 -08:00
aa58f775ab Bump version to match changes 2024-12-09 23:17:18 -08:00
4cd7e6284b Change flow to include use registration when adding an alert
reuse /register for changing the region registration

fix interaction timed out bug
2024-12-09 23:13:44 -08:00
719842c20e Add basic compose file for the short term 2024-12-08 18:37:38 -08:00
df0cd8b40e Properly scream when token database unreachable
This increases the retries before giving up and gives a backoff if it's failed once but if token data is unavailable scream loudly
2024-12-08 18:22:28 -08:00
5a34e6f3ed (minor) add missing newline 2024-12-08 18:16:15 -08:00
eef31b5631 Install pre-commit hooks 2024-12-08 18:15:06 -08:00
e7a9466092 Make all private variables actually private and provide them as properties instead 2024-12-08 18:13:48 -08:00
dd97d9b1f9 Include a link to the chart in a given alert render 2024-12-08 18:13:03 -08:00
cd23c8e350 Install pre-commit hooks 2024-12-08 18:10:01 -08:00
34badf17eb Once and for all reformat.
Will be using black code formatter
2024-12-08 17:07:26 -08:00
3a06464c29 General tracker.py improvements
Mostly speed-ups by awaiting multiple awaits where there's not a dependency on each other's results, and bounds checks
2024-12-08 16:48:05 -08:00
62c205aa35 correct return type in history_manager.py 2024-12-08 16:44:25 -08:00
c7c2589947 Use uvloop for faster performance 2024-12-08 16:44:06 -08:00
31 changed files with 773 additions and 378 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.env

5
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,5 @@
repos:
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black

View File

@@ -1,4 +1,4 @@
FROM python:3.12-bookworm AS base FROM python:3.12-trixie AS base
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -1,3 +1,15 @@
# wow-token-app-bot # wow-token-app-bot
An interactions.py Discord bot for getting and alerting on the WoW Token Price An interactions.py Discord bot for getting and alerting on the WoW Token Price
# Help
If you are a user of this bot and are looking for help on how to use it, you can find a small tutorial [here](https://blog.emily.sh/token-bot/)
# Contributing
1. Clone the project
2. Install the requirements `pip install -r requirements.txt`
3. Install pre-commit hooks `pre-commit install`
4. To run this application you will need to have created DynamoDB tables and have a discord app token to use
- You will need to specify at least ALERTS_TABLE, USERS_TABLE, DISCORD_TOKEN, and AWS_REGION

7
compose.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
token-bot:
image: wowtoken-app/token-bot
build: ./
restart: always
env_file:
.env

View File

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

View File

@@ -1,24 +1,42 @@
aiodynamo==24.1 aiodynamo==24.7
aiohttp==3.9.5 aiohappyeyeballs==2.6.1
aiosignal==1.3.1 aiohttp==3.13.2
anyio==4.4.0 aiosignal==1.4.0
attrs==23.2.0 anyio==4.11.0
certifi==2024.7.4 attrs==25.4.0
croniter==2.0.5 black==25.9.0
discord-py-interactions==5.12.1 certifi==2025.10.5
cfgv==3.4.0
click==8.3.0
croniter==6.0.0
discord-py-interactions==5.15.0
discord-typings==0.9.0 discord-typings==0.9.0
emoji==2.12.1 distlib==0.4.0
frozenlist==1.4.1 emoji==2.15.0
h11==0.14.0 filelock==3.20.0
httpcore==1.0.5 frozenlist==1.8.0
httpx==0.27.0 h11==0.16.0
idna==3.7 httpcore==1.0.9
multidict==6.0.5 httpx==0.28.1
identify==2.6.15
idna==3.11
multidict==6.7.0
mypy_extensions==1.1.0
nodeenv==1.9.1
packaging==25.0
pathspec==0.12.1
platformdirs==4.5.0
pre_commit==4.3.0
propcache==0.4.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.0.1 python-dotenv==1.2.1
pytz==2024.1 pytokens==0.2.0
six==1.16.0 pytz==2025.2
PyYAML==6.0.3
six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
tomli==2.0.1 tomli==2.3.0
typing_extensions==4.12.2 typing_extensions==4.15.0
yarl==1.9.4 uvloop==0.22.1
virtualenv==20.35.4
yarl==1.22.0

View File

@@ -11,7 +11,9 @@ from token_bot.persistant_database import database as pdb
class AlertsController: class AlertsController:
def __init__(self, session: aiohttp.ClientSession): def __init__(self, session: aiohttp.ClientSession):
self._pdb: pdb.Database = pdb.Database(session) self._pdb: pdb.Database = pdb.Database(session)
self.table = aiodynamo.client.Table = self._pdb.client.table(os.getenv('ALERTS_TABLE')) self.table: aiodynamo.client.Table = self._pdb.client.table(
os.getenv("ALERTS_TABLE")
)
@staticmethod @staticmethod
def _user_to_obj(user: int | User) -> User: def _user_to_obj(user: int | User) -> User:
@@ -40,4 +42,17 @@ class AlertsController:
user = self._user_to_obj(user) user = self._user_to_obj(user)
await alert.remove_user(self.table, user) await alert.remove_user(self.table, user)
async def get_all_by_type(self, alert_type: int) -> List[Alert]:
"""Query all alerts of a specific type from the database."""
from aiodynamo.expressions import F
alerts = []
async for item in self.table.query(
key_condition=F("alert").equals(alert_type)
):
alert = Alert.from_item(
primary_key=item["alert"],
sort_key=item["flavor-region-price"],
users=item.get("users", [])
)
alerts.append(alert)
return alerts

View File

@@ -11,8 +11,9 @@ from token_bot.persistant_database.user_schema import User
class UsersController: class UsersController:
def __init__(self, session: aiohttp.ClientSession): def __init__(self, session: aiohttp.ClientSession):
self._pdb: pdb.Database = pdb.Database(session) self._pdb: pdb.Database = pdb.Database(session)
self.table: aiodynamo.client.Table = self._pdb.client.table(os.getenv('USERS_TABLE')) self.table: aiodynamo.client.Table = self._pdb.client.table(
os.getenv("USERS_TABLE")
)
@staticmethod @staticmethod
def _user_to_obj(user: int | User) -> User: def _user_to_obj(user: int | User) -> User:
@@ -67,4 +68,3 @@ class UsersController:
await user.get(self.table) await user.get(self.table)
user.subscribed_alerts.remove(alert) user.subscribed_alerts.remove(alert)
await user.put(self.table) await user.put(self.table)

View File

@@ -8,7 +8,7 @@ from interactions.api.events import Startup
from token_bot.token_database import database as pdb from token_bot.token_database import database as pdb
from token_bot.token_database import database as tdb from token_bot.token_database import database as tdb
VERSION = "0.9.0" VERSION = "0.9.12"
class Core(Extension): class Core(Extension):
@@ -18,15 +18,14 @@ class Core(Extension):
@listen(Startup) @listen(Startup)
async def on_start(self): async def on_start(self):
self.bot.logger.log(logging.INFO,"TokenBot Core ready") self.bot.logger.log(logging.INFO, "TokenBot Core ready")
self.bot.logger.log(logging.INFO,f"This is bot version {VERSION}") self.bot.logger.log(logging.INFO, f"This is bot version {VERSION}")
self._tdb = tdb.Database(aiohttp.ClientSession()) self._tdb = tdb.Database(aiohttp.ClientSession())
@slash_command()
@check(is_owner())
async def version(self, ctx):
await ctx.send(f"This is bot version {VERSION}", ephemeral=True)
@slash_command() @slash_command()
async def help(self, ctx): async def help(self, ctx):
await ctx.send(f"This is bot help command", ephemeral=True) await ctx.send(
f"For help on using GoblinBot, please visit the help page found "
f"[here](https://blog.emily.sh/token-bot/#getting-started)",
ephemeral=True,
)

View File

@@ -9,14 +9,19 @@ from token_bot.token_database.region import Region
class History: class History:
def __init__(self, flavor: Flavor, region: Region): def __init__(self, flavor: Flavor, region: Region):
self._flavor : Flavor = flavor self._flavor: Flavor = flavor
self._region : Region = region self._region: Region = region
self._history : List[Tuple[datetime, int]] = [] self._history: List[Tuple[datetime, int]] = []
self._last_price_movement : int = 0 self._last_price_movement: int = 0
self._latest_price_datum : Tuple[datetime.datetime, int] | None = None self._latest_price_datum: Tuple[datetime.datetime, int] | None = None
self._update_triggers : List[UpdateTrigger] = [] self._update_triggers: List[UpdateTrigger] = []
# Create triggers for all non-custom alert types
for alert_type in AlertType: for alert_type in AlertType:
self._update_triggers.append(UpdateTrigger(Alert(alert_type, flavor, self._region))) if alert_type != AlertType.SPECIFIC_PRICE:
self._update_triggers.append(
UpdateTrigger(Alert(alert_type, flavor, self._region))
)
# SPECIFIC_PRICE triggers are created on-demand as they have unique prices
@property @property
def flavor(self) -> Flavor: def flavor(self) -> Flavor:
@@ -53,10 +58,21 @@ class History:
self._history.append(datum) self._history.append(datum)
return await self._process_update_triggers() return await self._process_update_triggers()
async def find_update_trigger_from_alert(self, alert: Alert) -> UpdateTrigger: async def find_update_trigger_from_alert(self, alert: Alert, initial_import: bool = False) -> UpdateTrigger:
for trigger in self._update_triggers: for trigger in self._update_triggers:
if trigger.alert == alert: if trigger.alert == alert:
return trigger return trigger
# If not found and it's a SPECIFIC_PRICE alert, create it on-demand
if alert.alert_type == AlertType.SPECIFIC_PRICE:
new_trigger = UpdateTrigger(alert)
if initial_import:
new_trigger.squelched = True
self._update_triggers.append(new_trigger)
# Initialize the trigger with current history
if self._latest_price_datum is not None:
new_trigger.check_and_update(self._latest_price_datum, self._history)
new_trigger.squelched = False
return new_trigger
raise ValueError raise ValueError

View File

@@ -9,23 +9,31 @@ from token_bot.token_database.region import Region
class HistoryManager: class HistoryManager:
HIGH_FIDELITY_PERIOD = '72h' HIGH_FIDELITY_PERIOD = "72h"
def __init__(self, token_db: tdb.Database): def __init__(self, token_db: tdb.Database):
self._history : Dict[Flavor, Dict[Region, History]] = {} self._history: Dict[Flavor, Dict[Region, History]] = {}
self._tdb : tdb.Database = token_db self._tdb: tdb.Database = token_db
for flavor in Flavor: for flavor in Flavor:
self._history[flavor] = {} self._history[flavor] = {}
for region in Region: for region in Region:
self._history[flavor][Region(region)] = History(flavor, Region(region)) self._history[flavor][Region(region)] = History(flavor, Region(region))
async def _retrieve_data(
async def _retrieve_data(self, flavor: Flavor, region: Region) -> List[Tuple[datetime.datetime, int]]: self, flavor: Flavor, region: Region
high_fidelity_time = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(hours=72) ) -> 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) all_history = await self._tdb.history(flavor, region)
high_fidelity_history = await self._tdb.history(flavor, region, self.HIGH_FIDELITY_PERIOD) high_fidelity_history = await self._tdb.history(
flavor, region, self.HIGH_FIDELITY_PERIOD
)
final_response = [] final_response = []
def _convert_to_datetime(data: Tuple[str, int]): def _convert_to_datetime(
data: Tuple[str, int]
) -> Tuple[datetime.datetime, int]:
return datetime.datetime.fromisoformat(data[0]), data[1] return datetime.datetime.fromisoformat(data[0]), data[1]
for data_point in all_history: for data_point in all_history:
@@ -33,11 +41,12 @@ class HistoryManager:
if datetime_tuple[0] < high_fidelity_time: if datetime_tuple[0] < high_fidelity_time:
final_response.append(datetime_tuple) final_response.append(datetime_tuple)
final_response.extend(_convert_to_datetime(data_point) for data_point in high_fidelity_history) final_response.extend(
_convert_to_datetime(data_point) for data_point in high_fidelity_history
)
return final_response return final_response
async def load_data(self): async def load_data(self):
for flavor in Flavor: for flavor in Flavor:
for r in Region: for r in Region:
@@ -48,6 +57,15 @@ class HistoryManager:
await history.add_price(item) await history.add_price(item)
self._history[flavor][region] = history self._history[flavor][region] = history
async def load_custom_alerts(self, custom_alerts: List[Alert]):
"""Load custom price alerts and initialize their triggers with historical data."""
for alert in custom_alerts:
history = self._history[alert.flavor][alert.region]
# This will create the trigger on-demand via find_update_trigger_from_alert
trigger = await history.find_update_trigger_from_alert(alert)
# Process all historical data through this trigger to initialize its state
for datum in history.history:
trigger.check_and_update(datum, history.history)
async def update_data(self, flavor: Flavor, region: Region) -> List[Alert]: async def update_data(self, flavor: Flavor, region: Region) -> List[Alert]:
history = self._history[flavor][region] history = self._history[flavor][region]
@@ -55,12 +73,11 @@ class HistoryManager:
current_region_data = current_price_data[region.value.lower()] current_region_data = current_price_data[region.value.lower()]
datum = ( datum = (
datetime.datetime.fromisoformat(current_region_data[0]), datetime.datetime.fromisoformat(current_region_data[0]),
current_region_data[1] current_region_data[1],
) )
if datum != history.last_price_datum: if datum != history.last_price_datum:
return await history.add_price(datum) return await history.add_price(datum)
return [] return []
def get_history(self, flavor, region) -> History: def get_history(self, flavor, region) -> History:
return self._history[flavor][region] return self._history[flavor][region]

View File

@@ -8,10 +8,10 @@ from token_bot.token_database.flavor import Flavor
class UpdateTrigger: class UpdateTrigger:
def __init__(self, alert: Alert): def __init__(self, alert: Alert):
self._alert : Alert = alert self._alert: Alert = alert
self._last_trigger : Tuple[datetime.datetime, int] | None = None self._last_trigger: Tuple[datetime.datetime, int] | None = None
self._last_alerting: Tuple[datetime.datetime, int] | None = None self._last_alerting: Tuple[datetime.datetime, int] | None = None
self._squelched : bool = False self._squelched: bool = False
@property @property
def alert(self) -> Alert: def alert(self) -> Alert:
@@ -29,20 +29,39 @@ class UpdateTrigger:
def squelched(self): def squelched(self):
return self._squelched return self._squelched
def _find_next_trigger(self, comparison_operator: Callable, starting_point: datetime.datetime, history: List[Tuple[datetime.datetime, int]]): @squelched.setter
candidate_datum : Tuple[datetime.datetime, int] | None = None def squelched(self, value):
self._squelched = value
def _find_next_trigger(
self,
comparison_operator: Callable,
starting_point: datetime.datetime,
history: List[Tuple[datetime.datetime, int]],
):
candidate_datum: Tuple[datetime.datetime, int] | None = None
for datum in history: for datum in history:
if datum[0] > starting_point and datum != history[-1]: if datum[0] > starting_point and datum != history[-1]:
if candidate_datum is None or comparison_operator(datum[1], candidate_datum[1]): if candidate_datum is None or comparison_operator(
datum[1], candidate_datum[1]
):
candidate_datum = datum candidate_datum = datum
self._last_trigger = candidate_datum self._last_trigger = candidate_datum
def check_and_update(self, new_datum: Tuple[datetime.datetime, int], history: List[Tuple[datetime.datetime, int]]) -> bool: def check_and_update(
self,
new_datum: Tuple[datetime.datetime, int],
history: List[Tuple[datetime.datetime, int]],
) -> bool:
match self.alert.flavor: match self.alert.flavor:
case Flavor.RETAIL: case Flavor.RETAIL:
start_time = datetime.datetime.fromisoformat('2020-11-15 00:00:01.000000000+00:00') start_time = datetime.datetime.fromisoformat(
"2020-11-15 00:00:01.000000000+00:00"
)
case Flavor.CLASSIC: case Flavor.CLASSIC:
start_time = datetime.datetime.fromisoformat('2023-05-23 00:00:01.000000000+00:00') start_time = datetime.datetime.fromisoformat(
"2023-05-23 00:00:01.000000000+00:00"
)
case _: case _:
raise NotImplementedError raise NotImplementedError
@@ -78,12 +97,49 @@ class UpdateTrigger:
case AlertType.ALL_TIME_HIGH: case AlertType.ALL_TIME_HIGH:
time_range = now - start_time time_range = now - start_time
comparison_operator = operator.gt comparison_operator = operator.gt
case AlertType.SPECIFIC_PRICE:
# For custom price alerts, check if the price crosses the threshold
# We alert when price moves from below to above (or vice versa)
target_price = self._alert.price
if self._last_trigger is None:
# First time - initialize tracking
self._last_trigger = new_datum
if new_datum[1] >= target_price:
# Price already at/above target - alert and squelch
self._last_alerting = new_datum
self._squelched = True
return True
return False
else:
# Check if we crossed the threshold
old_price = self._last_trigger[1]
new_price = new_datum[1]
# Alert if we cross the threshold in either direction
crossed_up = old_price < target_price <= new_price
crossed_down = old_price >= target_price > new_price
# Always update last_trigger for tracking
self._last_trigger = new_datum
if crossed_up or crossed_down:
# We're crossing the threshold
if self._squelched:
# Currently squelched - this crossing unsquelches us
# but doesn't alert (prevents rapid-fire alerts on oscillation)
self._squelched = False
return False
else:
# Not squelched - send alert and squelch
self._last_alerting = new_datum
self._squelched = True
return True
return False
case _: case _:
# TODO: The logic here is certainly wrong for Custom
time_range = datetime.timedelta(days=int(365.25 * 6)) time_range = datetime.timedelta(days=int(365.25 * 6))
comparison_operator = operator.eq comparison_operator = operator.eq
if new_datum[0] > now - time_range: if new_datum[0] > now - time_range and self._alert.alert_type != AlertType.SPECIFIC_PRICE:
if self._last_trigger is None: if self._last_trigger is None:
self._last_trigger = new_datum self._last_trigger = new_datum
self._last_alerting = new_datum self._last_alerting = new_datum

View File

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

View File

@@ -7,7 +7,9 @@ class AlertCategory(Enum):
CUSTOM = 3 CUSTOM = 3
@staticmethod @staticmethod
def from_str(category: str): # It gets mad when I use the Type[AlertCategory] as a type hint def from_str(
category: str,
): # It gets mad when I use the Type[AlertCategory] as a type hint
match category: match category:
case "high_alert_button": case "high_alert_button":
return AlertCategory.HIGH return AlertCategory.HIGH

View File

@@ -11,29 +11,36 @@ import token_bot.persistant_database as pdb
class Alert: class Alert:
def __init__(self, alert: pdb.AlertType, flavor: Flavor, region: Region, price: int = 0) -> None: def __init__(
self, alert: pdb.AlertType, flavor: Flavor, region: Region, price: int = 0
) -> None:
# Validate price for SPECIFIC_PRICE alerts
if alert == AlertType.SPECIFIC_PRICE:
if price is None or price <= 0:
raise ValueError("SPECIFIC_PRICE alerts require a positive price value")
# AlertType is the Primary Key # AlertType is the Primary Key
self.alert_type: pdb.AlertType = alert self._alert_type: pdb.AlertType = alert
# Flavor (Retail, Classic) is the Sort Key # Flavor (Retail, Classic) is the Sort Key
self.flavor: Flavor = flavor self._flavor: Flavor = flavor
self.region: Region = region self._region: Region = region
self.price: int = price self._price: int = price
self._loaded: bool = False self._loaded: bool = False
self.users: List[pdb.User] = [] self._users: List[pdb.User] = []
@classmethod @classmethod
def from_item(cls, primary_key: int, sort_key: str, users: List[int]) -> 'Alert': def from_item(cls, primary_key: int, sort_key: str, users: List[int]) -> "Alert":
alert_type = pdb.AlertType(primary_key) alert_type = pdb.AlertType(primary_key)
flavor_repr, region_repr, price_repr = sort_key.split('-') flavor_repr, region_repr, price_repr = sort_key.split("-")
flavor = Flavor(int(flavor_repr)) flavor = Flavor(int(flavor_repr))
region = Region(region_repr) region = Region(region_repr)
price = int(price_repr) price = int(price_repr)
return cls(alert_type, flavor, region, price) return cls(alert_type, flavor, region, price)
@classmethod @classmethod
def from_str(cls, string_trinity: str) -> 'Alert': def from_str(cls, string_trinity: str) -> "Alert":
alert_repr, flavor_repr, region_repr, price_repr = string_trinity.split('-') alert_repr, flavor_repr, region_repr, price_repr = string_trinity.split("-")
if len(string_trinity.split('-')) != 4: if len(string_trinity.split("-")) != 4:
raise ValueError raise ValueError
alert = pdb.AlertType(int(alert_repr)) alert = pdb.AlertType(int(alert_repr))
flavor = Flavor(int(flavor_repr)) flavor = Flavor(int(flavor_repr))
@@ -61,20 +68,46 @@ class Alert:
def key(self) -> dict[str, str | int]: def key(self) -> dict[str, str | int]:
return { return {
self.primary_key_name: self.primary_key, self.primary_key_name: self.primary_key,
self.sort_key_name: self.sort_key self.sort_key_name: self.sort_key,
} }
@property
def alert_type(self) -> pdb.AlertType:
return self._alert_type
@property
def flavor(self) -> Flavor:
return self._flavor
@property
def region(self) -> Region:
return self._region
@property
def price(self) -> int:
return self._price
@property
def users(self) -> List[pdb.User]:
return self._users
def __str__(self): def __str__(self):
return f"{self.alert_type.value}-{self.flavor.value}-{self.region.value}-{self.price}" return f"{self.alert_type.value}-{self.flavor.value}-{self.region.value}-{self.price}"
def __eq__(self, other): def __eq__(self, other):
return self.alert_type == other.alert_type and self.flavor == other.flavor and self.price == other.price return (
self.alert_type == other.alert_type
and self.flavor == other.flavor
and self.region == other.region
and self.price == other.price
)
def to_human_string(self): def to_human_string(self):
if self.alert_type == AlertType.SPECIFIC_PRICE: if self.alert_type == AlertType.SPECIFIC_PRICE:
raise NotImplementedError price_gold = self.price
return f"Custom Price: {format(price_gold, ',')}g"
else: else:
alert_type_str = ' '.join(self.alert_type.name.split("_")) alert_type_str = " ".join(self.alert_type.name.split("_"))
return f"{alert_type_str.title()}" return f"{alert_type_str.title()}"
async def _lazy_load(self, table: Table, consistent: bool = False) -> None: async def _lazy_load(self, table: Table, consistent: bool = False) -> None:
@@ -95,19 +128,16 @@ class Alert:
item={ item={
self.primary_key_name: self.primary_key, self.primary_key_name: self.primary_key,
self.sort_key_name: self.sort_key, self.sort_key_name: self.sort_key,
'users': user_ids "users": user_ids,
} }
) )
async def get(self, table: Table, consistent: bool = False) -> bool: async def get(self, table: Table, consistent: bool = False) -> bool:
try: try:
response = await table.get_item( response = await table.get_item(key=self.key, consistent_read=consistent)
key=self.key,
consistent_read=consistent
)
except ItemNotFound: except ItemNotFound:
return False return False
self.users = [pdb.User(int(user_id)) for user_id in response['users']] self._users = [pdb.User(int(user_id)) for user_id in response["users"]]
self._loaded = True self._loaded = True
return True return True
@@ -116,13 +146,17 @@ class Alert:
return self.users return self.users
async def add_user(self, table: Table, user: pdb.User, consistent: bool = False) -> None: async def add_user(
self, table: Table, user: pdb.User, consistent: bool = False
) -> None:
await self._lazy_load(table, consistent=consistent) await self._lazy_load(table, consistent=consistent)
if user not in self.users: if user not in self.users:
await self._append_user(table=table, user=user) await self._append_user(table=table, user=user)
async def remove_user(self, table: Table, user: pdb.User, consistent: bool = True) -> None: async def remove_user(
self, table: Table, user: pdb.User, consistent: bool = True
) -> None:
await self._lazy_load(table, consistent=consistent) await self._lazy_load(table, consistent=consistent)
if user in self.users: if user in self.users:

View File

@@ -1,5 +1,6 @@
from enum import Enum from enum import Enum
class AlertType(Enum): class AlertType(Enum):
ALL_TIME_HIGH = 1 ALL_TIME_HIGH = 1
ALL_TIME_LOW = 2 ALL_TIME_LOW = 2
@@ -37,4 +38,7 @@ class AlertType(Enum):
case "All Time Low": case "All Time Low":
return AlertType.ALL_TIME_LOW return AlertType.ALL_TIME_LOW
case _: case _:
# Check if it's a custom price format like "Custom Price: 250,000g"
if category.startswith("Custom Price"):
return AlertType.SPECIFIC_PRICE
return AlertType.SPECIFIC_PRICE return AlertType.SPECIFIC_PRICE

View File

@@ -8,5 +8,6 @@ from aiodynamo.http.aiohttp import AIOHTTP
class Database: class Database:
def __init__(self, session: aiohttp.ClientSession): def __init__(self, session: aiohttp.ClientSession):
self.client = Client(AIOHTTP(session), Credentials.auto(), os.getenv('AWS_REGION')) self.client = Client(
AIOHTTP(session), Credentials.auto(), os.getenv("AWS_REGION")
)

View File

@@ -8,7 +8,12 @@ from token_bot.token_database.region import Region
class User: class User:
def __init__(self, user_id: int, region: Region = None, subscribed_alerts: List['pdb.Alert'] = None) -> None: def __init__(
self,
user_id: int,
region: Region = None,
subscribed_alerts: List["pdb.Alert"] = None,
) -> None:
self.user_id: int = user_id self.user_id: int = user_id
self._loaded: bool = False self._loaded: bool = False
self.region: Region = region self.region: Region = region
@@ -21,7 +26,9 @@ class User:
return hash(self.user_id) return hash(self.user_id)
@classmethod @classmethod
def from_item(cls, primary_key: int, region: Region, subscribed_alerts: List[str]) -> 'User': 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] alerts = [pdb.Alert.from_str(alert_str) for alert_str in subscribed_alerts]
return cls(primary_key, region, alerts) return cls(primary_key, region, alerts)
@@ -31,17 +38,18 @@ class User:
@property @property
def primary_key_name(self) -> str: def primary_key_name(self) -> str:
return 'user_id' return "user_id"
@property @property
def key(self) -> Dict[str, str]: def key(self) -> Dict[str, str]:
return { return {self.primary_key_name: self.primary_key}
self.primary_key_name: self.primary_key
}
def _subscribed_alerts_as_trinity_list(self) -> List[str]: def _subscribed_alerts_as_trinity_list(self) -> List[str]:
return [str(alert) for alert in self.subscribed_alerts] if self.subscribed_alerts else [] 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: async def _lazy_load(self, table: Table, consistent: bool = False) -> None:
if consistent or not self._loaded: if consistent or not self._loaded:
@@ -51,8 +59,8 @@ class User:
await table.put_item( await table.put_item(
item={ item={
self.primary_key_name: self.primary_key, self.primary_key_name: self.primary_key,
'region': self.region, "region": self.region,
'subscribed_alerts': self._subscribed_alerts_as_trinity_list() "subscribed_alerts": self._subscribed_alerts_as_trinity_list(),
} }
) )
@@ -65,26 +73,27 @@ class User:
async def get(self, table: Table, consistent: bool = False) -> bool: async def get(self, table: Table, consistent: bool = False) -> bool:
try: try:
response = await table.get_item( response = await table.get_item(key=self.key, consistent_read=consistent)
key=self.key,
consistent_read=consistent
)
except ItemNotFound: except ItemNotFound:
return False return False
self.subscribed_alerts = [] self.subscribed_alerts = []
for string_trinity in response['subscribed_alerts']: for string_trinity in response["subscribed_alerts"]:
self.subscribed_alerts.append(pdb.Alert.from_str(string_trinity)) self.subscribed_alerts.append(pdb.Alert.from_str(string_trinity))
self.region = Region(response['region']) self.region = Region(response["region"])
return True return True
async def add_alert(self, table: Table, alert: 'pdb.Alert', consistent: bool = False) -> None: async def add_alert(
self, table: Table, alert: "pdb.Alert", consistent: bool = False
) -> None:
await self._lazy_load(table, consistent=consistent) await self._lazy_load(table, consistent=consistent)
if alert not in self.subscribed_alerts: if alert not in self.subscribed_alerts:
self.subscribed_alerts.append(alert) self.subscribed_alerts.append(alert)
await self.put(table) await self.put(table)
async def remove_alert(self, table: Table, alert: 'pdb.Alert', consistent: bool = True) -> None: async def remove_alert(
self, table: Table, alert: "pdb.Alert", consistent: bool = True
) -> None:
await self._lazy_load(table, consistent=consistent) await self._lazy_load(table, consistent=consistent)
if alert in self.subscribed_alerts: if alert in self.subscribed_alerts:
self.subscribed_alerts.remove(alert) self.subscribed_alerts.remove(alert)

View File

@@ -10,15 +10,17 @@ class TokenBot:
load_dotenv() load_dotenv()
print("#### WoW Token Bot Startup ####") print("#### WoW Token Bot Startup ####")
logging.basicConfig( logging.basicConfig(
format='%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s', format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
datefmt='%Y-%m-%d %H:%M:%S', datefmt="%Y-%m-%d %H:%M:%S",
) )
log = logging.getLogger("TokenBotLogger") log = logging.getLogger("TokenBotLogger")
log.setLevel(logging.INFO) log.setLevel(logging.INFO)
is_debug = os.getenv("ENV") == "DEBUG"
self.bot = Client( self.bot = Client(
intents=Intents.DEFAULT, intents=Intents.DEFAULT,
asyncio_debug=True, asyncio_debug=is_debug,
logger=log send_command_tracebacks=is_debug,
logger=log,
) )
def run(self): def run(self):

View File

@@ -1,3 +1,4 @@
import asyncio
from typing import Dict, List from typing import Dict, List
import aiohttp import aiohttp
@@ -17,13 +18,16 @@ class Database:
success = False success = False
tries = 0 tries = 0
backoff = 0.1
while not success and tries < 3: while not success and tries <= 5:
if tries > 1:
await asyncio.sleep(backoff * tries)
try: try:
return await self._external_call(url) return await self._external_call(url)
except TokenHttpException: except TokenHttpException:
tries += 1 tries += 1
return {} raise TokenHttpException
async def _external_call(self, url) -> Dict | List: async def _external_call(self, url) -> Dict | List:
async with self.session.get(url) as resp: async with self.session.get(url) as resp:
@@ -34,7 +38,9 @@ class Database:
raise TokenHttpException(resp.status) raise TokenHttpException(resp.status)
async def current(self, flavor: Flavor) -> dict: async def current(self, flavor: Flavor) -> dict:
return await self._get_data(f'current/{flavor.name.lower()}.json') return await self._get_data(f"current/{flavor.name.lower()}.json")
async def history(self, flavor: Flavor, region: Region, relative_time: str = 'all'): 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') return await self._get_data(
f"relative/{flavor.name.lower()}/{region.value.lower()}/{relative_time}.json"
)

View File

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

View File

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

View File

@@ -2,15 +2,32 @@ import asyncio
import copy import copy
import datetime import datetime
import logging import logging
import os
from typing import Type, Dict, List from typing import Type, Dict, List
import aiohttp import aiohttp
from interactions import Extension, SlashContext, component_callback, \ from interactions import (
ComponentContext, StringSelectMenu, Message, Embed, EmbedField, is_owner, check, StringSelectOption Extension,
SlashContext,
component_callback,
ComponentContext,
StringSelectMenu,
Message,
Embed,
EmbedField,
is_owner,
check,
StringSelectOption, integration_types,
Modal,
ShortText,
modal_callback,
ModalContext,
)
from interactions import Task, IntervalTrigger from interactions import Task, IntervalTrigger
from interactions import slash_command, listen from interactions import slash_command, listen
from interactions.api.events import Component from interactions.api.events import Component
from interactions.api.events import Startup from interactions.api.events import Startup
from interactions.client.errors import Forbidden
from token_bot.controller.alerts import AlertsController from token_bot.controller.alerts import AlertsController
from token_bot.controller.users import UsersController from token_bot.controller.users import UsersController
@@ -27,8 +44,10 @@ from token_bot.ui.select_menus.alert_menu import HIGH_ALERT_MENU, LOW_ALERT_MENU
from token_bot.ui.select_menus.flavor_menu import FLAVOR_MENU from token_bot.ui.select_menus.flavor_menu import FLAVOR_MENU
from token_bot.ui.select_menus.region_menu import REGION_MENU from token_bot.ui.select_menus.region_menu import REGION_MENU
#### Static Helper Functions #### Static Helper Functions
async def gather_alerts_by_flavor(alerts: List[Alert]) -> Dict[Flavor, List[Alert]]: async def gather_alerts_by_flavor(alerts: List[Alert]) -> Dict[Flavor, List[Alert]]:
alerts_by_flavor = {} alerts_by_flavor = {}
for alert in alerts: for alert in alerts:
@@ -39,14 +58,13 @@ async def gather_alerts_by_flavor(alerts: List[Alert]) -> Dict[Flavor, List[Aler
return alerts_by_flavor return alerts_by_flavor
class Tracker(Extension): class Tracker(Extension):
def __init__(self, bot): def __init__(self, bot):
self._users: UsersController | None = None self._users: UsersController | None = None
self._alerts: AlertsController | None = None self._alerts: AlertsController | None = None
self._tdb: tdb.Database | None = None self._tdb: tdb.Database | None = None
self._history_manager: HistoryManager | None = None self._history_manager: HistoryManager | None = None
self._session: aiohttp.ClientSession | None = None
################################### ###################################
# Task Functions # # Task Functions #
@@ -66,22 +84,48 @@ class Tracker(Extension):
users_alerts[user] = [alert] users_alerts[user] = [alert]
else: else:
users_alerts[user].append(alert) users_alerts[user].append(alert)
for user in users_alerts: if users_alerts:
discord_user = await self.bot.fetch_user(user.user_id) self.bot.logger.log(
embeds = [Embed( logging.INFO, "TokenBot Tracker: Processing User Alerts"
title="TokenBot Tracker Alert Triggered", )
color=0xb10000, for user in users_alerts:
description=f"Hello, you requested to be sent an alert when the price of the World of Warcraft " discord_user = await self.bot.fetch_user(user.user_id)
f"token reaches a certain value.\n\n" alerts_by_flavor = await gather_alerts_by_flavor(users_alerts[user])
f"As a reminder, you can remove an alert via ```/remove-alert```\n" alert_tally = 0
f"or you can remove all registrations via ```/remove-registration```\n\n" for flavor in alerts_by_flavor:
)] for _ in alerts_by_flavor[flavor]:
alerts_by_flavor = await gather_alerts_by_flavor(users_alerts[user]) alert_tally += 1
for flavor in alerts_by_flavor: alert_word = "alert" if alert_tally == 1 else "alerts"
embeds.append(await self.render_alert_flavor(alerts_by_flavor[flavor], user=user)) embeds = [
Embed(
await discord_user.send(embeds=embeds) title="GoblinBot Tracker Alert Triggered",
color=0xB10000,
description=f"You requested to be alerted on the WoW token price. You have {alert_tally} {alert_word}\n\n",
)
]
for flavor in alerts_by_flavor:
embeds.append(
await self._render_alert_flavor(
alerts_by_flavor[flavor], user=user
)
)
embeds.append(
Embed(
title="",
color=0xB10000,
description=f"You can remove an alert via ```/remove-alert```\n"
f"or you can remove all alerts and user data via ```/remove-registration```\n",
)
)
try:
await discord_user.send(embeds=embeds)
except Forbidden:
self.bot.logger.log(
logging.ERROR, f"User: {discord_user.id} has no permissions to send alerts, skipping")
self.bot.logger.log(
logging.INFO, "TokenBot Tracker: Done Processing User Alerts"
)
################################### ###################################
# Slash Commands # # Slash Commands #
@@ -90,36 +134,55 @@ class Tracker(Extension):
@listen(Startup) @listen(Startup)
async def on_start(self): async def on_start(self):
self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initializing") self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initializing")
self._users = UsersController(aiohttp.ClientSession()) # Create a single shared ClientSession for all components
self._alerts = AlertsController(aiohttp.ClientSession()) self._session = aiohttp.ClientSession()
self._tdb = tdb.Database(aiohttp.ClientSession()) self._users = UsersController(self._session)
self._alerts = AlertsController(self._session)
self._tdb = tdb.Database(self._session)
self._history_manager = HistoryManager(self._tdb) self._history_manager = HistoryManager(self._tdb)
self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initialized") self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initialized")
self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Historical Data") self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Historical Data")
await self._history_manager.load_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: Loading Historical Data Finished"
)
self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Custom Price Alerts")
# Load all SPECIFIC_PRICE alerts from database (AlertType.SPECIFIC_PRICE = 11)
custom_alerts = await self._alerts.get_all_by_type(AlertType.SPECIFIC_PRICE.value)
await self._history_manager.load_custom_alerts(custom_alerts)
self.bot.logger.log(
logging.INFO, f"TokenBot Tracker: Loaded {len(custom_alerts)} Custom Price Alerts"
)
self.bot.logger.log(logging.INFO, "TokenBot Tracker: Started") self.bot.logger.log(logging.INFO, "TokenBot Tracker: Started")
self.update_data.start() self.update_data.start()
def extension_unload(self):
"""Clean up resources when the extension is unloaded"""
if self._session and not self._session.closed:
asyncio.create_task(self._session.close())
self.bot.logger.log(logging.INFO, "TokenBot Tracker: ClientSession closed")
@slash_command( @slash_command(
name="register", name="register",
description="Register with TokenBot for alerts on token price changes." description="Register with a new GoblinBot Region for alerts on token price changes.",
) )
@integration_types(guild=True, user=True)
async def register(self, ctx: SlashContext): async def register(self, ctx: SlashContext):
text = ("## Select a region to register with \n\n" text = (
"Please note: \n" "## Select a region to register with \n\n"
"* You can only be registered with one region at a time \n" "Please note: \n"
"* Changing your region will remove all previous alerts you have signed up for \n" "* You can only be registered with one region at a time \n"
"* You can remove all alerts and registration using ```/remove-registration```") "* Changing your region will remove all previous alerts you have signed up for \n"
"* You can remove all alerts and user data using ```/remove-registration```"
)
menu = copy.deepcopy(REGION_MENU) menu = copy.deepcopy(REGION_MENU)
await ctx.send(text, components=menu, ephemeral=True) await ctx.send(text, components=menu, ephemeral=True)
@slash_command( @slash_command(
name="remove-registration", name="remove-registration",
description="Remove all alerts and registration from TokenBot" description="Remove all alerts and registration from GoblinBot",
) )
@integration_types(guild=True, user=True)
async def remove_registration(self, ctx: SlashContext): async def remove_registration(self, ctx: SlashContext):
if await self._users.exists(ctx.user.id): if await self._users.exists(ctx.user.id):
user = await self._users.get(ctx.user.id) user = await self._users.get(ctx.user.id)
@@ -127,96 +190,89 @@ class Tracker(Extension):
await self._alerts.remove_user(alert, user) await self._alerts.remove_user(alert, user)
await self._users.delete(ctx.user.id) await self._users.delete(ctx.user.id)
await ctx.send("All alert subscriptions and user registration deleted", ephemeral=True) await ctx.send("All alert subscriptions and user data deleted", ephemeral=True)
@slash_command(description="The current retail token cost")
@slash_command( @integration_types(guild=True, user=True)
name="exists",
description="Check if you are registered with TokenBot"
)
@check(is_owner())
async def exists(self, ctx: SlashContext):
await ctx.send(str(await self._users.exists(ctx.user.id)), ephemeral=True)
@slash_command(
description="The current retail token cost"
)
async def current(self, ctx: SlashContext): async def current(self, ctx: SlashContext):
current_str = await self.get_current_token(ctx, tdb.Flavor.RETAIL) current_str = await self.get_current_token(ctx, tdb.Flavor.RETAIL)
await ctx.send(current_str, ephemeral=True) await ctx.send(current_str, ephemeral=True)
@slash_command(description="The current classic token cost")
@slash_command( @integration_types(guild=True, user=True)
description="The current classic token cost"
)
async def current_classic(self, ctx: SlashContext): async def current_classic(self, ctx: SlashContext):
current_str = await self.get_current_token(ctx, tdb.Flavor.CLASSIC) current_str = await self.get_current_token(ctx, tdb.Flavor.CLASSIC)
await ctx.send(current_str, ephemeral=True) await ctx.send(current_str, ephemeral=True)
@slash_command(name="add-alert", description="Add an alert listener")
@slash_command( @integration_types(guild=True, user=True)
name="add-alert",
description="List all alerts you have signed up for"
)
async def add_alert(self, ctx: SlashContext): async def add_alert(self, ctx: SlashContext):
if not await self._users.exists(ctx.user.id): if not await self._users.exists(ctx.user.id):
await ctx.send("You are not registered with any region\n" try:
"Please register with /register before adding alerts", await self.region_select_menu(ctx)
ephemeral=True) except TimeoutError:
return return
user = await self._users.get(ctx.user.id) user = await self._users.get(ctx.user.id)
try: try:
flavor = await self.flavor_select_menu(ctx) flavor = await self.flavor_select_menu(ctx)
alert_category = await self.alert_category_select_menu(ctx) alert_category, price = await self.alert_category_select_menu(ctx)
match alert_category: match alert_category:
case AlertCategory.LOW: case AlertCategory.LOW:
alert_type = await self.low_alert_select_menu(ctx) alert_type = await self.low_alert_select_menu(ctx)
case AlertCategory.HIGH: case AlertCategory.HIGH:
alert_type = await self.high_alert_select_menu(ctx) alert_type = await self.high_alert_select_menu(ctx)
case _: case AlertCategory.CUSTOM:
raise NotImplementedError alert_type = AlertType.SPECIFIC_PRICE
except TimeoutError: except (TimeoutError, ValueError):
return return
else: else:
alert = Alert(alert_type, flavor, user.region) alert = Alert(alert_type, flavor, user.region, price)
if not await self._users.is_subscribed(user, alert): if not await self._users.is_subscribed(user, alert):
await self._users.add_alert(user, alert) await asyncio.gather(
await self._alerts.add_user(alert, user) self._users.add_alert(user, alert),
self._alerts.add_user(alert, user),
)
await ctx.send("Successfully added alert", ephemeral=True) await ctx.send("Successfully added alert", ephemeral=True)
else: else:
await ctx.send("You are already subscribed to this alert", ephemeral=True) await ctx.send(
"You are already subscribed to this alert", ephemeral=True
)
@slash_command( @slash_command(
name="remove-alert", name="remove-alert", description="Remove an alert you have signed up for"
description="Remove an alert you have signed up for"
) )
@integration_types(guild=True, user=True)
async def remove_alert(self, ctx: SlashContext): async def remove_alert(self, ctx: SlashContext):
if not await self._user_is_registered(ctx):
return
user = await self._users.get(ctx.user.id) user = await self._users.get(ctx.user.id)
alerts = await self._users.list_alerts(user)
if len(alerts) == 0:
await ctx.send("You do not have any alerts registered", ephemeral=True)
return
try: try:
alert = await self.remove_alert_select_menu(ctx, user) alert = await self.remove_alert_select_menu(ctx, user)
except TimeoutError: except TimeoutError:
return return
else: else:
await self._users.remove_alert(user, alert) await asyncio.gather(
await self._alerts.remove_user(alert, user) self._users.remove_alert(user, alert),
self._alerts.remove_user(alert, user),
)
await ctx.send("Successfully removed alert", ephemeral=True) await ctx.send("Successfully removed alert", ephemeral=True)
@slash_command( @slash_command(
name="list-alerts", name="list-alerts", description="List all alerts you have signed up for"
description="List all alerts you have signed up for"
) )
@integration_types(guild=True, user=True)
async def list_alerts(self, ctx: SlashContext): async def list_alerts(self, ctx: SlashContext):
if not await self._users.exists(ctx.user.id): if not await self._user_is_registered(ctx):
await ctx.send("You are not registered with any region\n"
"Please register with /register before adding alerts",
ephemeral=True)
return return
user = await self._users.get(ctx.user.id) user = await self._users.get(ctx.user.id)
alerts = await self._users.list_alerts(user) alerts = await self._users.list_alerts(user)
@@ -224,57 +280,36 @@ class Tracker(Extension):
await ctx.send("You do not have any alerts registered", ephemeral=True) await ctx.send("You do not have any alerts registered", ephemeral=True)
return return
alerts_str = f"You have {len(alerts)} out of 25 maximum alerts registered" alerts_str = f"You have {len(alerts)} out of 25 maximum alerts registered"
embeds = [Embed( embeds = [
title="List of TokenBot Tracker Alerts", Embed(
color=0x0000b1, title="List of GoblinBot Tracker Alerts",
description=alerts_str color=0x0000B1,
)] description=alerts_str,
)
]
alerts_by_flavor = await gather_alerts_by_flavor(alerts) alerts_by_flavor = await gather_alerts_by_flavor(alerts)
for flavor in alerts_by_flavor: for flavor in alerts_by_flavor:
embeds.append(await self.render_alert_flavor(alerts_by_flavor[flavor], user=user)) embeds.append(
await self._render_alert_flavor(alerts_by_flavor[flavor], user=user)
)
await ctx.send(embeds=embeds, ephemeral=True) await ctx.send(embeds=embeds, ephemeral=True)
################################### ###################################
# Callbacks Commands # # Callbacks Commands #
################################### ###################################
@component_callback('flavor_menu') # Note: Callbacks for flavor_menu, high_alert_menu, low_alert_menu, and alert buttons
async def flavor_menu(self, ctx: ComponentContext): # are disabled because they interfere with wait_for_component manual handling
await ctx.send(f"Selected Flavor: {ctx.values[0]}", ephemeral=True)
@component_callback('high_alert_menu') @component_callback("region_menu")
async def alert_menu(self, ctx: ComponentContext): async def region_menu_cb(self, ctx: ComponentContext):
await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True) discord_user = await self.bot.fetch_user(ctx.user.id)
await discord_user.send(
@component_callback('low_alert_menu') "You have successfully registered your region with GoblinBot!\n"
async def alert_menu(self, ctx: ComponentContext): "Most interactions will happen in direct messages with GoblinBot here.\n"
await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True) "You can remove your user data and alerts at any time using ```/remove-registration```\n"
)
@component_callback('remove_alert_menu') await ctx.defer(edit_origin=True, suppress_error=True)
async def remove_alert_menu(self, ctx: ComponentContext):
await ctx.send(f"You have selected to remove the following alert: {ctx.values[0].title()}", ephemeral=True)
@component_callback('region_menu')
async def region_menu(self, ctx: ComponentContext):
user = User(ctx.user.id, Region(ctx.values[0].lower()), subscribed_alerts=[])
await self._users.add(user)
discord_user = await self.bot.fetch_user(user.user_id)
await discord_user.send("You have successfully registered with TokenBot!\n"
"Most interactions will happen in direct messages with TokenBot here.\n"
"You can remove your registration and alerts at any time using ```/remove-registration```\n")
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 # # Helper Functions #
@@ -282,36 +317,47 @@ class Tracker(Extension):
async def get_current_token(self, ctx: SlashContext, flavor: Flavor) -> str: async def get_current_token(self, ctx: SlashContext, flavor: Flavor) -> str:
user: User = await self._users.get(ctx.user.id) user: User = await self._users.get(ctx.user.id)
if user.region is None:
return (
f"Please register with a region before attempting to list alerts using\n"
"```/register```"
)
region = user.region.name region = user.region.name
region_history = self._history_manager.get_history(flavor, user.region) region_history = self._history_manager.get_history(flavor, user.region)
price_movement_str = format(region_history.last_price_movement, ',') price_movement_str = format(region_history.last_price_movement, ",")
if region_history.last_price_movement > 0: if region_history.last_price_movement > 0:
price_movement_str = f"+{price_movement_str}" price_movement_str = f"+{price_movement_str}"
return (f"Last Price Value for {region}: {format(region_history.last_price_datum[1], ",")}\n" return (
f"Last Update Time: {region_history.last_price_datum[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n" f"Last Price Value for {region}: {format(region_history.last_price_datum[1], ",")}\n"
f"Last Price Movement: {price_movement_str}") f"Last Update Time: <t:{int(region_history.last_price_datum[0].timestamp())}:F> local time\n"
f"Last Price Movement: {price_movement_str}"
)
async def remove_alert_select_menu(self, ctx: SlashContext, user: User): async def remove_alert_select_menu(self, ctx: SlashContext, user: User):
alerts_by_flavor = await gather_alerts_by_flavor(user.subscribed_alerts) alerts_by_flavor = await gather_alerts_by_flavor(user.subscribed_alerts)
select_options: List[StringSelectOption] = [] select_options: List[StringSelectOption] = []
for flavor in alerts_by_flavor: for flavor in alerts_by_flavor:
for alert in alerts_by_flavor[flavor]: for alert in alerts_by_flavor[flavor]:
select_options.append(StringSelectOption( select_options.append(
label=f"{alert.flavor.name.lower().title()} {alert.to_human_string()}", StringSelectOption(
value=f"{alert.flavor.name.lower()} {alert.to_human_string()}" label=f"{alert.flavor.name.lower().title()} {alert.to_human_string()}",
)) value=f"{alert.flavor.name.lower()} {alert.to_human_string()}",
)
)
menu = StringSelectMenu( menu = StringSelectMenu(
select_options, select_options,
placeholder="Select an alert to remove", placeholder="Select an alert to remove",
custom_id="remove_alert_menu" custom_id="remove_alert_menu",
)
message = await ctx.send(
"Select an alert to remove", components=menu, ephemeral=True
) )
message = await ctx.send("Select an alert to remove", components=menu, ephemeral=True)
try: try:
alert_component: Component = await self.bot.wait_for_component(messages=message, alert_component: Component = await self.bot.wait_for_component(
components=menu, timeout=30) messages=message, components=menu, timeout=30
)
except TimeoutError: except TimeoutError:
menu.disabled = True menu.disabled = True
await message.edit(context=ctx, components=menu, content="Timed out") await message.edit(context=ctx, components=menu, content="Timed out")
@@ -321,115 +367,274 @@ class Tracker(Extension):
await message.edit(context=ctx, components=menu) await message.edit(context=ctx, components=menu)
selection_split = alert_component.ctx.values[0].split(" ") selection_split = alert_component.ctx.values[0].split(" ")
flavor = Flavor[selection_split[0].upper()] flavor = Flavor[selection_split[0].upper()]
alert_type = AlertType.from_str(' '.join(selection_split[1:])) alert_type_str = " ".join(selection_split[1:])
return Alert(alert_type, flavor, user.region) alert_type = AlertType.from_str(alert_type_str)
# Parse price for custom alerts
price = 0
if alert_type == AlertType.SPECIFIC_PRICE:
# Extract price from "Custom Price: 250,000g"
price_part = alert_type_str.split(": ")[1].rstrip("g").replace(",", "")
price_gold = int(price_part)
# Convert gold to copper
price = price_gold
return Alert(alert_type, flavor, user.region, price)
async def region_select_menu(self, ctx: SlashContext, user: User | None = None):
region_menu = copy.deepcopy(REGION_MENU)
region_menu_str = str()
if user is None:
region_menu_str += "You are not currently registered with a region, please select a region to register with.\n"
region_menu_str += (
"* You can only be registered with one region at a time.\n"
"* Registering for a new region will remove your old region's registration.\n"
)
region_message = await ctx.send(
region_menu_str,
components=region_menu,
ephemeral=True,
)
try:
region_component = await self.bot.wait_for_component(
messages=region_message, components=region_menu, timeout=30
)
except TimeoutError:
region_menu.disabled = True
await region_message.edit(
context=ctx, components=region_menu, content="Timed out"
)
raise TimeoutError
else:
# Acknowledge the component interaction to avoid 404 Unknown Interaction
await region_component.ctx.defer(edit_origin=True, suppress_error=True)
region_menu.disabled = True
region = Region(region_component.ctx.values[0].lower())
user = User(ctx.user.id, region, subscribed_alerts=[])
await asyncio.gather(
self._users.add(user),
region_message.edit(context=ctx, components=region_menu),
)
return region
async def flavor_select_menu(self, ctx: SlashContext) -> Type[Flavor]: async def flavor_select_menu(self, ctx: SlashContext) -> Type[Flavor]:
flavor_menu = copy.deepcopy(FLAVOR_MENU) flavor_menu = copy.deepcopy(FLAVOR_MENU)
flavor_message = await ctx.send( flavor_message = await ctx.send(
"Select a flavor to add alerts for", "Select a flavor to add alerts for", components=flavor_menu, ephemeral=True
components=flavor_menu, )
ephemeral=True)
try: try:
flavor_component: Component = await self.bot.wait_for_component(messages=flavor_message, flavor_component: Component = await self.bot.wait_for_component(
components=flavor_menu, timeout=30) messages=flavor_message, components=flavor_menu, timeout=30
)
except TimeoutError: except TimeoutError:
flavor_menu.disabled = True flavor_menu.disabled = True
await flavor_message.edit(context=ctx, components=flavor_menu, content="Timed out") await flavor_message.edit(
context=ctx, components=flavor_menu, content="Timed out"
)
raise TimeoutError raise TimeoutError
else: else:
# Acknowledge the component interaction to avoid 404 Unknown Interaction
await flavor_component.ctx.defer(edit_origin=True, suppress_error=True)
flavor = Flavor[flavor_component.ctx.values[0].upper()] flavor = Flavor[flavor_component.ctx.values[0].upper()]
flavor_menu.disabled = True flavor_menu.disabled = True
await flavor_message.edit(context=ctx, components=flavor_menu) await flavor_message.edit(context=ctx, components=flavor_menu)
return flavor return flavor
async def alert_category_select_menu(self, ctx: SlashContext) -> tuple[AlertCategory, int]:
async def alert_category_select_menu(self, ctx: SlashContext) -> AlertCategory:
alert_type_button = copy.deepcopy(ALERT_TYPE_ROW) alert_type_button = copy.deepcopy(ALERT_TYPE_ROW)
alert_type_message = await ctx.send( alert_type_message = await ctx.send(
"Select an alert type to add", "Select an alert type to add", components=alert_type_button, ephemeral=True
components=alert_type_button, )
ephemeral=True)
try: try:
alert_type_component: Component = await self.bot.wait_for_component(messages=alert_type_message, alert_type_component: Component = await self.bot.wait_for_component(
components=alert_type_button, messages=alert_type_message, components=alert_type_button, timeout=30
timeout=30) )
except TimeoutError: except TimeoutError:
for button in alert_type_button[0].components: for button in alert_type_button[0].components:
button.disabled = True button.disabled = True
await alert_type_message.edit(context=ctx, components=alert_type_button, content="Timed out") await alert_type_message.edit(
context=ctx, components=alert_type_button, content="Timed out"
)
raise TimeoutError raise TimeoutError
else: else:
alert_type = AlertCategory.from_str(alert_type_component.ctx.custom_id) alert_type = AlertCategory.from_str(alert_type_component.ctx.custom_id)
# If custom alert, send modal as response to button press
if alert_type == AlertCategory.CUSTOM:
price = await self.custom_price_modal(alert_type_component.ctx)
else:
# Acknowledge the component interaction to avoid 404 Unknown Interaction
await alert_type_component.ctx.defer(edit_origin=True, suppress_error=True)
price = 0
for button in alert_type_button[0].components: for button in alert_type_button[0].components:
button.disabled = True button.disabled = True
await alert_type_message.edit(context=ctx, components=alert_type_button) await alert_type_message.edit(context=ctx, components=alert_type_button)
return alert_type return alert_type, price
async def _alert_select_menu_handler(
async def _alert_select_menu_handler(self, ctx: SlashContext, menu: StringSelectMenu, message: Message) -> AlertType: self, ctx: SlashContext, menu: StringSelectMenu, message: Message
) -> AlertType:
try: try:
component: Component = await self.bot.wait_for_component(messages=message, components=menu, timeout=30) component: Component = await self.bot.wait_for_component(
messages=message, components=menu, timeout=30
)
except TimeoutError: except TimeoutError:
menu.disabled = True menu.disabled = True
await message.edit(context=ctx, components=menu, content="Timed out") await message.edit(context=ctx, components=menu, content="Timed out")
raise TimeoutError raise TimeoutError
else: else:
menu.disabled = True menu.disabled = True
await component.ctx.defer(edit_origin=True, suppress_error=True)
await message.edit(context=ctx, components=menu) await message.edit(context=ctx, components=menu)
return AlertType.from_str(component.ctx.values[0]) return AlertType.from_str(component.ctx.values[0])
async def high_alert_select_menu(self, ctx: SlashContext) -> AlertType: async def high_alert_select_menu(self, ctx: SlashContext) -> AlertType:
high_menu = copy.deepcopy(HIGH_ALERT_MENU) high_menu = copy.deepcopy(HIGH_ALERT_MENU)
high_message = await ctx.send( high_message = await ctx.send(
"Select a time range to add a High Alert for", "Select a time range to add a High Alert for",
components=high_menu, components=high_menu,
ephemeral=True) ephemeral=True,
)
return await self._alert_select_menu_handler(ctx, high_menu, high_message) return await self._alert_select_menu_handler(ctx, high_menu, high_message)
async def low_alert_select_menu(self, ctx: SlashContext) -> AlertType: async def low_alert_select_menu(self, ctx: SlashContext) -> AlertType:
low_menu = copy.deepcopy(LOW_ALERT_MENU) low_menu = copy.deepcopy(LOW_ALERT_MENU)
low_message = await ctx.send( low_message = await ctx.send(
"Select a time range to add a Low Alert for", "Select a time range to add a Low Alert for",
components=low_menu, components=low_menu,
ephemeral=True) ephemeral=True,
)
return await self._alert_select_menu_handler(ctx, low_menu, low_message) return await self._alert_select_menu_handler(ctx, low_menu, low_message)
async def custom_price_modal(self, ctx: ComponentContext) -> int:
modal = Modal(
ShortText(
label="Price (in gold)",
custom_id="price_input",
placeholder="e.g., 250000 for 250k gold",
required=True,
),
title="Custom Price Alert",
custom_id="custom_price_modal",
)
await ctx.send_modal(modal)
async def render_alert_flavor(self, alerts: List[Alert], user: User | None = None) -> Embed: try:
modal_ctx: ModalContext = await self.bot.wait_for_modal(modal, timeout=300)
except TimeoutError:
await ctx.send("Modal timed out", ephemeral=True)
raise TimeoutError
else:
price_str = modal_ctx.responses["price_input"]
try:
price_gold = int(price_str.replace(",", "").replace(" ", "").replace("g", ""))
except ValueError:
await modal_ctx.send("Invalid price. Please enter a valid number.", ephemeral=True)
raise
if price_gold <= 0:
await modal_ctx.send("Price must be greater than 0", ephemeral=True)
raise ValueError("Price must be greater than 0")
await modal_ctx.send(f"Custom price alert set for {format(price_gold, ',')}g", ephemeral=True)
return price_gold
async def _user_is_registered(self, ctx: SlashContext) -> bool:
if not await self._users.exists(ctx.user.id):
await ctx.send(
"You are not registered with any region\n"
"Please add an alert to get started ```/add-alert```",
ephemeral=True,
)
return False
return True
async def _render_alert_flavor(
self, alerts: List[Alert], user: User | None = None
) -> Embed:
region = alerts[0].region region = alerts[0].region
flavor = alerts[0].flavor flavor = alerts[0].flavor
fields: List[EmbedField] = [] fields: List[EmbedField] = []
for alert in alerts: for alert in alerts:
history = self._history_manager.get_history(alert.flavor, alert.region) history = self._history_manager.get_history(alert.flavor, alert.region)
trigger = await history.find_update_trigger_from_alert(alert) trigger = await history.find_update_trigger_from_alert(alert)
if trigger.last_trigger is not None: if trigger.last_alerting is not None:
alert_str = (f"Last Alerting Price Value: {format(trigger.last_alerting[1], ",")}\n" alert_str = (
f"Last Alerting Time: {trigger.last_alerting[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n") f"Last Alerting Price Value: {format(trigger.last_alerting[1], ",")}\n"
if user is not None and user.user_id == 265678699435655169: f"Last Alerting Time: <t:{int(trigger.last_alerting[0].timestamp())}:F> local time\n"
alert_str += (f"\nShowing you some internals since you are the bot owner:\n" f"[Link to this Chart]({self._render_token_url(alert)})\n"
f"```history.last_price_datum:\n" )
f"\t{history.last_price_datum[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n" if os.getenv("ENV") == "DEBUG":
f"\t{history.last_price_datum[1]}\n" alert_str += (
f"trigger.last_alerting:\n" f"\nShowing you some internals since this is a DEBUG build:\n"
f"\t{trigger.last_alerting[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n" f"```history.last_price_datum:\n"
f"\t{trigger.last_alerting[1]}\n" f"\t{history.last_price_datum[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
f"trigger.last_trigger:\n" f"\t{history.last_price_datum[1]}\n"
f"\t{trigger.last_trigger[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n" f"trigger.last_alerting:\n"
f"\t{trigger.last_trigger[1]}\n" f"\t{trigger.last_alerting[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
f"trigger.squelched:\n\t{trigger.squelched}```") f"\t{trigger.last_alerting[1]}\n"
f"trigger.last_trigger:\n"
f"\t{trigger.last_trigger[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
f"\t{trigger.last_trigger[1]}\n"
f"trigger.squelched:\n\t{trigger.squelched}```"
)
else: else:
alert_str = "You should only be seeing this if the bot has not finished importing history at startup." # For custom price alerts, show current status vs threshold
if alert.alert_type == AlertType.SPECIFIC_PRICE:
current_price = history.last_price_datum[1]
target_price_gold = alert.price
current_price_gold = current_price
alert_str = (
f"Threshold has never been crossed\n"
f"Current Price: {format(current_price_gold, ',')}g\n"
f"Target Price: {format(target_price_gold, ',')}g\n"
f"[Link to this Chart]({self._render_token_url(alert, time_range='72h')})\n"
)
else:
alert_str = "You should only be seeing this if the bot has not finished importing history at startup."
fields.append( fields.append(
EmbedField( EmbedField(
name=f"{alert.to_human_string()} Alert", value=alert_str, inline=False)) name=f"{alert.to_human_string()} Alert",
value=alert_str,
inline=False,
)
)
embed = Embed( embed = Embed(
title=f"Alerts for {region.name} {flavor.name.lower().title()}", title=f"Alerts for {region.name} {flavor.name.lower().title()}",
color=0xb10000, color=0xB10000,
fields=fields fields=fields,
) )
return embed return embed
def _render_token_url(self, alert: Alert, time_range: str | None = None) -> str:
match alert.flavor:
case Flavor.CLASSIC:
url = "https://classic.wowtoken.app/?"
case Flavor.RETAIL:
url = "https://wowtoken.app/?"
case _:
raise NotImplementedError
url += f"region={alert.region.value}&"
# If time_range is explicitly provided, use it
if time_range:
url += f"time={time_range}&"
else:
# Otherwise, determine time range based on alert type
match alert.alert_type:
case AlertType.WEEKLY_LOW | AlertType.WEEKLY_HIGH:
url += "time=168h&"
case AlertType.MONTHLY_LOW | AlertType.MONTHLY_HIGH:
url += "time=720h&"
case AlertType.YEARLY_LOW | AlertType.YEARLY_HIGH:
url += "time=1y&"
case AlertType.ALL_TIME_LOW | AlertType.ALL_TIME_HIGH:
url += "time=all&"
case _:
url += "time=72h&"
return url

View File

@@ -1,10 +1,15 @@
from interactions import ActionRow from interactions import ActionRow
from token_bot.ui.buttons.tracker.alert_category import HIGH_ALERT_BUTTON, LOW_ALERT_BUTTON from token_bot.ui.buttons.tracker.alert_category import (
HIGH_ALERT_BUTTON,
LOW_ALERT_BUTTON,
CUSTOM_ALERT_BUTTON,
)
ALERT_TYPE_ROW: list[ActionRow] = [ ALERT_TYPE_ROW: list[ActionRow] = [
ActionRow( ActionRow(
HIGH_ALERT_BUTTON, HIGH_ALERT_BUTTON,
LOW_ALERT_BUTTON, LOW_ALERT_BUTTON,
CUSTOM_ALERT_BUTTON,
) )
] ]

View File

@@ -1,67 +1,45 @@
from interactions import Button, ButtonStyle from interactions import Button, ButtonStyle
ATH_ADD_BUTTON = Button( ATH_ADD_BUTTON = Button(
custom_id='ath_add_button', custom_id="ath_add_button", style=ButtonStyle.GREEN, label="All Time High"
style=ButtonStyle.GREEN,
label="All Time High"
) )
ATL_ADD_BUTTON = Button( ATL_ADD_BUTTON = Button(
custom_id='atl_add_button', custom_id="atl_add_button", style=ButtonStyle.RED, label="All Time Low"
style=ButtonStyle.RED,
label="All Time Low"
) )
DH_ADD_BUTTON = Button( DH_ADD_BUTTON = Button(
custom_id='dh_add_button', custom_id="dh_add_button", style=ButtonStyle.GREEN, label="Daily High"
style=ButtonStyle.GREEN,
label="Daily High"
) )
DL_ADD_BUTTON = Button( DL_ADD_BUTTON = Button(
custom_id='dl_add_button', custom_id="dl_add_button", style=ButtonStyle.RED, label="Daily Low"
style=ButtonStyle.RED,
label="Daily Low"
) )
WH_ADD_BUTTON = Button( WH_ADD_BUTTON = Button(
custom_id='wh_add_button', custom_id="wh_add_button", style=ButtonStyle.GREEN, label="Weekly High"
style=ButtonStyle.GREEN,
label="Weekly High"
) )
WL_ADD_BUTTON = Button( WL_ADD_BUTTON = Button(
custom_id='wl_add_button', custom_id="wl_add_button", style=ButtonStyle.RED, label="Weekly Low"
style=ButtonStyle.RED,
label="Weekly Low"
) )
MH_ADD_BUTTON = Button( MH_ADD_BUTTON = Button(
custom_id='mh_add_button', custom_id="mh_add_button", style=ButtonStyle.GREEN, label="Monthly High"
style=ButtonStyle.GREEN,
label="Monthly High"
) )
ML_ADD_BUTTON = Button( ML_ADD_BUTTON = Button(
custom_id='ml_add_button', custom_id="ml_add_button", style=ButtonStyle.RED, label="Monthly Low"
style=ButtonStyle.RED,
label="Monthly Low"
) )
YH_ADD_BUTTON = Button( YH_ADD_BUTTON = Button(
custom_id='yh_add_button', custom_id="yh_add_button", style=ButtonStyle.GREEN, label="Yearly High"
style=ButtonStyle.GREEN,
label="Yearly High"
) )
YL_ADD_BUTTON = Button( YL_ADD_BUTTON = Button(
custom_id='yl_add_button', custom_id="yl_add_button", style=ButtonStyle.RED, label="Yearly Low"
style=ButtonStyle.RED,
label="Yearly Low"
) )
SP_ADD_BUTTON = Button( SP_ADD_BUTTON = Button(
custom_id='sp_add_button', custom_id="sp_add_button", style=ButtonStyle.GRAY, label="Custom Limit Price"
style=ButtonStyle.GRAY,
label="Custom Limit Price"
) )

View File

@@ -1,19 +1,13 @@
from interactions import Button, ButtonStyle from interactions import Button, ButtonStyle
HIGH_ALERT_BUTTON = Button( HIGH_ALERT_BUTTON = Button(
custom_id='high_alert_button', custom_id="high_alert_button", style=ButtonStyle.GREEN, label="High Price Alert"
style=ButtonStyle.GREEN,
label="High Price Alert"
) )
LOW_ALERT_BUTTON = Button( LOW_ALERT_BUTTON = Button(
custom_id='low_alert_button', custom_id="low_alert_button", style=ButtonStyle.RED, label="Low Price Alert"
style=ButtonStyle.RED,
label="Low Price Alert"
) )
CUSTOM_ALERT_BUTTON = Button( CUSTOM_ALERT_BUTTON = Button(
custom_id='sp_add_button', custom_id="sp_add_button", style=ButtonStyle.GRAY, label="Custom Price Alert"
style=ButtonStyle.GRAY,
label="Custom Price Alert"
) )

View File

@@ -1,19 +1,13 @@
from interactions import Button, ButtonStyle from interactions import Button, ButtonStyle
HIGH_ALERT = Button( HIGH_ALERT = Button(
custom_id='high_alert_button', custom_id="high_alert_button", style=ButtonStyle.GREEN, label="Add High Alert"
style=ButtonStyle.GREEN,
label="Add High Alert"
) )
LOW_ALERT = Button( LOW_ALERT = Button(
custom_id='low_alert_button', custom_id="low_alert_button", style=ButtonStyle.RED, label="Add Low Alert"
style=ButtonStyle.RED,
label="Add Low Alert"
) )
CUSTOM_ALERT = Button( CUSTOM_ALERT = Button(
custom_id='custom_alert_button', custom_id="custom_alert_button", style=ButtonStyle.GRAY, label="Add Custom Alert"
style=ButtonStyle.GRAY,
label="Add Custom Alert"
) )

View File

@@ -1,15 +1,25 @@
from interactions import StringSelectMenu from interactions import StringSelectMenu
HIGH_ALERT_MENU = StringSelectMenu( HIGH_ALERT_MENU = StringSelectMenu(
"Daily High", "Weekly High", "Monthly High", "Yearly High", "All Time High", "Daily High",
"Weekly High",
"Monthly High",
"Yearly High",
"All Time High",
placeholder="Select a time period", placeholder="Select a time period",
min_values=1, max_values=1, min_values=1,
custom_id='high_alert_menu' max_values=1,
custom_id="high_alert_menu",
) )
LOW_ALERT_MENU = StringSelectMenu( LOW_ALERT_MENU = StringSelectMenu(
"Daily Low", "Weekly Low", "Monthly Low", "Yearly Low", "All Time Low", "Daily Low",
"Weekly Low",
"Monthly Low",
"Yearly Low",
"All Time Low",
placeholder="Select a time period", placeholder="Select a time period",
min_values=1, max_values=1, min_values=1,
custom_id='low_alert_menu' max_values=1,
custom_id="low_alert_menu",
) )

View File

@@ -1,8 +1,10 @@
from interactions import StringSelectMenu from interactions import StringSelectMenu
FLAVOR_MENU = StringSelectMenu( FLAVOR_MENU = StringSelectMenu(
"Retail", "Classic", "Retail",
"Classic",
placeholder="Select version of WoW", placeholder="Select version of WoW",
min_values=1, max_values=1, min_values=1,
custom_id='flavor_menu' max_values=1,
custom_id="flavor_menu",
) )

View File

@@ -1,8 +1,12 @@
from interactions import StringSelectMenu from interactions import StringSelectMenu
REGION_MENU = StringSelectMenu( REGION_MENU = StringSelectMenu(
"US", "EU", "KR", "TW", "US",
"EU",
"KR",
"TW",
placeholder="Select a region", placeholder="Select a region",
min_values=1, max_values=1, min_values=1,
custom_id='region_menu' max_values=1,
custom_id="region_menu",
) )