commit ed79f4b65c146c87484352c6ae53660700fe23c8
parent 19eb0a4e24c8d5bd48d87b0edcd00d854ff5edc3
Author: Emily Doherty <git@emily.sh>
Date: Thu, 6 Nov 2025 02:46:03 -0800
Initial implementation of custom price triggers
- likely to have some bugs, but this is good enough for a preview release.
Diffstat:
8 files changed, 197 insertions(+), 58 deletions(-)
diff --git a/token_bot/controller/alerts.py b/token_bot/controller/alerts.py
@@ -41,3 +41,18 @@ class AlertsController:
alert = self._alert_to_obj(alert)
user = self._user_to_obj(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
diff --git a/token_bot/history_manager/history.py b/token_bot/history_manager/history.py
@@ -15,10 +15,13 @@ class History:
self._last_price_movement: int = 0
self._latest_price_datum: Tuple[datetime.datetime, int] | None = None
self._update_triggers: List[UpdateTrigger] = []
+ # Create triggers for all non-custom alert types
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
def flavor(self) -> Flavor:
@@ -55,8 +58,21 @@ class History:
self._history.append(datum)
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:
if trigger.alert == alert:
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
diff --git a/token_bot/history_manager/history_manager.py b/token_bot/history_manager/history_manager.py
@@ -57,6 +57,16 @@ class HistoryManager:
await history.add_price(item)
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]:
history = self._history[flavor][region]
current_price_data = await self._tdb.current(flavor)
diff --git a/token_bot/history_manager/update_trigger.py b/token_bot/history_manager/update_trigger.py
@@ -29,6 +29,10 @@ class UpdateTrigger:
def squelched(self):
return self._squelched
+ @squelched.setter
+ def squelched(self, value):
+ self._squelched = value
+
def _find_next_trigger(
self,
comparison_operator: Callable,
@@ -93,12 +97,47 @@ class UpdateTrigger:
case AlertType.ALL_TIME_HIGH:
time_range = now - start_time
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 - check if current price crossed the threshold
+ if new_datum[1] >= target_price:
+ self._last_trigger = new_datum
+ self._last_alerting = new_datum
+ 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
+
+ if crossed_up or crossed_down:
+ self._last_trigger = new_datum
+ self._last_alerting = new_datum
+ was_squelched = self._squelched
+ self._squelched = True
+ return not was_squelched
+ elif self._squelched:
+ # Update last_trigger but don't alert (we're tracking current price)
+ self._last_trigger = new_datum
+ # If we moved away from threshold, allow next crossing to alert
+ if (crossed_up and new_price < target_price) or (crossed_down and new_price >= target_price):
+ self._squelched = False
+ else:
+ # Just update the last trigger for tracking
+ self._last_trigger = new_datum
+ return False
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 new_datum[0] > now - time_range and self._alert.alert_type != AlertType.SPECIFIC_PRICE:
if self._last_trigger is None:
self._last_trigger = new_datum
self._last_alerting = new_datum
diff --git a/token_bot/persistant_database/alert_schema.py b/token_bot/persistant_database/alert_schema.py
@@ -99,7 +99,8 @@ class Alert:
def to_human_string(self):
if self.alert_type == AlertType.SPECIFIC_PRICE:
- raise NotImplementedError
+ price_gold = self.price
+ return f"Custom Price: {format(price_gold, ',')}g"
else:
alert_type_str = " ".join(self.alert_type.name.split("_"))
return f"{alert_type_str.title()}"
diff --git a/token_bot/persistant_database/alert_type.py b/token_bot/persistant_database/alert_type.py
@@ -38,4 +38,7 @@ class AlertType(Enum):
case "All Time Low":
return AlertType.ALL_TIME_LOW
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
diff --git a/token_bot/tracker.py b/token_bot/tracker.py
@@ -18,6 +18,10 @@ from interactions import (
is_owner,
check,
StringSelectOption, integration_types,
+ Modal,
+ ShortText,
+ modal_callback,
+ ModalContext,
)
from interactions import Task, IntervalTrigger
from interactions import slash_command, listen
@@ -142,6 +146,13 @@ class Tracker(Extension):
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.update_data.start()
@@ -206,20 +217,20 @@ class Tracker(Extension):
try:
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:
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
+ case AlertCategory.CUSTOM:
+ alert_type = AlertType.SPECIFIC_PRICE
except TimeoutError:
return
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):
await asyncio.gather(
self._users.add_alert(user, alert),
@@ -287,24 +298,8 @@ class Tracker(Extension):
# Callbacks Commands #
###################################
- @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 high_alert_menu(self, ctx: ComponentContext):
- await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True)
-
- @component_callback("low_alert_menu")
- async def low_alert_menu(self, ctx: ComponentContext):
- await ctx.send(f"Selected Alert: {ctx.values[0]}", ephemeral=True)
-
- @component_callback("remove_alert_menu")
- 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,
- )
+ # Note: Callbacks for flavor_menu, high_alert_menu, low_alert_menu, and alert buttons
+ # are disabled because they interfere with wait_for_component manual handling
@component_callback("region_menu")
async def region_menu_cb(self, ctx: ComponentContext):
@@ -316,18 +311,6 @@ class Tracker(Extension):
)
await ctx.defer(edit_origin=True, suppress_error=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 #
###################################
@@ -384,8 +367,19 @@ class Tracker(Extension):
await message.edit(context=ctx, components=menu)
selection_split = alert_component.ctx.values[0].split(" ")
flavor = Flavor[selection_split[0].upper()]
- alert_type = AlertType.from_str(" ".join(selection_split[1:]))
- return Alert(alert_type, flavor, user.region)
+ alert_type_str = " ".join(selection_split[1:])
+ 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)
@@ -448,7 +442,7 @@ class Tracker(Extension):
await flavor_message.edit(context=ctx, components=flavor_menu)
return flavor
- async def alert_category_select_menu(self, ctx: SlashContext) -> AlertCategory:
+ async def alert_category_select_menu(self, ctx: SlashContext) -> tuple[AlertCategory, int]:
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
@@ -465,13 +459,20 @@ class Tracker(Extension):
)
raise TimeoutError
else:
- # Acknowledge the component interaction to avoid 404 Unknown Interaction
- await alert_type_component.ctx.defer(edit_origin=True, suppress_error=True)
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:
button.disabled = True
await alert_type_message.edit(context=ctx, components=alert_type_button)
- return alert_type
+ return alert_type, price
async def _alert_select_menu_handler(
self, ctx: SlashContext, menu: StringSelectMenu, message: Message
@@ -508,6 +509,37 @@ class Tracker(Extension):
)
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)
+
+ 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", ""))
+ if price_gold <= 0:
+ await modal_ctx.send("Price must be greater than 0", ephemeral=True)
+ # Convert gold to copper (1 gold = 10000 copper)
+ price_copper = price_gold
+ await modal_ctx.send(f"Custom price alert set for {format(price_gold, ',')}g", ephemeral=True)
+ return price_copper
+ except ValueError:
+ await modal_ctx.send("Invalid price. Please enter a valid number.", ephemeral=True)
+
async def _user_is_registered(self, ctx: SlashContext) -> bool:
if not await self._users.exists(ctx.user.id):
await ctx.send(
@@ -548,7 +580,20 @@ class Tracker(Extension):
f"trigger.squelched:\n\t{trigger.squelched}```"
)
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(
EmbedField(
name=f"{alert.to_human_string()} Alert",
@@ -563,7 +608,7 @@ class Tracker(Extension):
)
return embed
- def _render_token_url(self, alert: Alert) -> str:
+ def _render_token_url(self, alert: Alert, time_range: str | None = None) -> str:
match alert.flavor:
case Flavor.CLASSIC:
url = "https://classic.wowtoken.app/?"
@@ -572,14 +617,22 @@ class Tracker(Extension):
case _:
raise NotImplementedError
url += f"region={alert.region.value}&"
- 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&"
+
+ # 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
diff --git a/token_bot/ui/action_row/tracker.py b/token_bot/ui/action_row/tracker.py
@@ -3,11 +3,13 @@ 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,
+ CUSTOM_ALERT_BUTTON,
)
]