diff --git a/token_bot/controller/alerts.py b/token_bot/controller/alerts.py index b01826b..1dcd887 100644 --- 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 index 88e29e6..a903185 100644 --- 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 index 9e5804f..305e8f2 100644 --- 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 index c187eea..96d0058 100644 --- 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 index f1a3047..9ec9e00 100644 --- 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 index ab5cd5f..648b9be 100644 --- 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 index 50275a8..994c971 100644 --- 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 index a18ebc3..6557668 100644 --- 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, ) ]