wow-token-app-bot

An interactions.py Discord bot for getting and alerting on the WoW Token Price
git clone https://git.emily.sh/wowtoken-app/wow-token-app-bot.git
Log | Files | Refs | README | LICENSE

tracker.py (27446B)


      1 import asyncio
      2 import copy
      3 import datetime
      4 import logging
      5 import os
      6 from typing import Type, Dict, List
      7 
      8 import aiohttp
      9 from interactions import (
     10     Extension,
     11     SlashContext,
     12     component_callback,
     13     ComponentContext,
     14     StringSelectMenu,
     15     Message,
     16     Embed,
     17     EmbedField,
     18     is_owner,
     19     check,
     20     StringSelectOption, integration_types,
     21     Modal,
     22     ShortText,
     23     modal_callback,
     24     ModalContext,
     25 )
     26 from interactions import Task, IntervalTrigger
     27 from interactions import slash_command, listen
     28 from interactions.api.events import Component
     29 from interactions.api.events import Startup
     30 from interactions.client.errors import Forbidden
     31 
     32 from token_bot.controller.alerts import AlertsController
     33 from token_bot.controller.users import UsersController
     34 from token_bot.history_manager.history_manager import HistoryManager, History
     35 from token_bot.persistant_database.alert_category import AlertCategory
     36 from token_bot.persistant_database.alert_schema import Alert
     37 from token_bot.persistant_database.alert_type import AlertType
     38 from token_bot.persistant_database.user_schema import User
     39 from token_bot.token_database import database as tdb
     40 from token_bot.token_database.flavor import Flavor
     41 from token_bot.token_database.region import Region
     42 from token_bot.ui.action_row.tracker import ALERT_TYPE_ROW
     43 from token_bot.ui.select_menus.alert_menu import HIGH_ALERT_MENU, LOW_ALERT_MENU
     44 from token_bot.ui.select_menus.flavor_menu import FLAVOR_MENU
     45 from token_bot.ui.select_menus.region_menu import REGION_MENU
     46 
     47 
     48 #### Static Helper Functions
     49 
     50 
     51 async def gather_alerts_by_flavor(alerts: List[Alert]) -> Dict[Flavor, List[Alert]]:
     52     alerts_by_flavor = {}
     53     for alert in alerts:
     54         if alert.flavor not in alerts_by_flavor:
     55             alerts_by_flavor[alert.flavor] = [alert]
     56         else:
     57             alerts_by_flavor[alert.flavor].append(alert)
     58     return alerts_by_flavor
     59 
     60 
     61 class Tracker(Extension):
     62     def __init__(self, bot):
     63         self._users: UsersController | None = None
     64         self._alerts: AlertsController | None = None
     65         self._tdb: tdb.Database | None = None
     66         self._history_manager: HistoryManager | None = None
     67         self._session: aiohttp.ClientSession | None = None
     68 
     69     ###################################
     70     # Task Functions                  #
     71     ###################################
     72 
     73     @Task.create(IntervalTrigger(minutes=1))
     74     async def update_data(self):
     75         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Updating Price")
     76         users_alerts: Dict[User, List[Alert]] = {}
     77         for flavor in Flavor:
     78             for region in Region:
     79                 alerts = await self._history_manager.update_data(flavor, Region(region))
     80                 for alert in alerts:
     81                     users = await self._alerts.get_users(alert)
     82                     for user in users:
     83                         if user not in users_alerts:
     84                             users_alerts[user] = [alert]
     85                         else:
     86                             users_alerts[user].append(alert)
     87         if users_alerts:
     88             self.bot.logger.log(
     89                 logging.INFO, "TokenBot Tracker: Processing User Alerts"
     90             )
     91             for user in users_alerts:
     92                 discord_user = await self.bot.fetch_user(user.user_id)
     93                 alerts_by_flavor = await gather_alerts_by_flavor(users_alerts[user])
     94                 alert_tally = 0
     95                 for flavor in alerts_by_flavor:
     96                     for _ in alerts_by_flavor[flavor]:
     97                         alert_tally += 1
     98                 alert_word = "alert" if alert_tally == 1 else "alerts"
     99                 embeds = [
    100                     Embed(
    101                         title="GoblinBot Tracker Alert Triggered",
    102                         color=0xB10000,
    103                         description=f"You requested to be alerted on the WoW token price. You have {alert_tally} {alert_word}\n\n",
    104                     )
    105                 ]
    106                 for flavor in alerts_by_flavor:
    107                     embeds.append(
    108                         await self._render_alert_flavor(
    109                             alerts_by_flavor[flavor], user=user
    110                         )
    111                     )
    112                 embeds.append(
    113                     Embed(
    114                         title="",
    115                         color=0xB10000,
    116                         description=f"You can remove an alert via ```/remove-alert```\n"
    117                                     f"or you can remove all alerts and user data via ```/remove-registration```\n",
    118                     )
    119                 )
    120                 try:
    121                     await discord_user.send(embeds=embeds)
    122                 except Forbidden:
    123                     self.bot.logger.log(
    124                         logging.ERROR, f"User: {discord_user.id} has no permissions to send alerts, skipping")
    125 
    126             self.bot.logger.log(
    127                 logging.INFO, "TokenBot Tracker: Done Processing User Alerts"
    128             )
    129 
    130     ###################################
    131     # Slash Commands                  #
    132     ###################################
    133 
    134     @listen(Startup)
    135     async def on_start(self):
    136         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initializing")
    137         # Create a single shared ClientSession for all components
    138         self._session = aiohttp.ClientSession()
    139         self._users = UsersController(self._session)
    140         self._alerts = AlertsController(self._session)
    141         self._tdb = tdb.Database(self._session)
    142         self._history_manager = HistoryManager(self._tdb)
    143         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Initialized")
    144         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Historical Data")
    145         await self._history_manager.load_data()
    146         self.bot.logger.log(
    147             logging.INFO, "TokenBot Tracker: Loading Historical Data Finished"
    148         )
    149         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Loading Custom Price Alerts")
    150         # Load all SPECIFIC_PRICE alerts from database (AlertType.SPECIFIC_PRICE = 11)
    151         custom_alerts = await self._alerts.get_all_by_type(AlertType.SPECIFIC_PRICE.value)
    152         await self._history_manager.load_custom_alerts(custom_alerts)
    153         self.bot.logger.log(
    154             logging.INFO, f"TokenBot Tracker: Loaded {len(custom_alerts)} Custom Price Alerts"
    155         )
    156         self.bot.logger.log(logging.INFO, "TokenBot Tracker: Started")
    157         self.update_data.start()
    158 
    159     def extension_unload(self):
    160         """Clean up resources when the extension is unloaded"""
    161         if self._session and not self._session.closed:
    162             asyncio.create_task(self._session.close())
    163             self.bot.logger.log(logging.INFO, "TokenBot Tracker: ClientSession closed")
    164 
    165     @slash_command(
    166         name="register",
    167         description="Register with a new GoblinBot Region for alerts on token price changes.",
    168     )
    169     @integration_types(guild=True, user=True)
    170     async def register(self, ctx: SlashContext):
    171         text = (
    172             "## Select a region to register with \n\n"
    173             "Please note: \n"
    174             "* You can only be registered with one region at a time \n"
    175             "* Changing your region will remove all previous alerts you have signed up for \n"
    176             "* You can remove all alerts and user data using ```/remove-registration```"
    177         )
    178         menu = copy.deepcopy(REGION_MENU)
    179         await ctx.send(text, components=menu, ephemeral=True)
    180 
    181     @slash_command(
    182         name="remove-registration",
    183         description="Remove all alerts and registration from GoblinBot",
    184     )
    185     @integration_types(guild=True, user=True)
    186     async def remove_registration(self, ctx: SlashContext):
    187         if await self._users.exists(ctx.user.id):
    188             user = await self._users.get(ctx.user.id)
    189             for alert in user.subscribed_alerts:
    190                 await self._alerts.remove_user(alert, user)
    191             await self._users.delete(ctx.user.id)
    192 
    193         await ctx.send("All alert subscriptions and user data deleted", ephemeral=True)
    194 
    195     @slash_command(description="The current retail token cost")
    196     @integration_types(guild=True, user=True)
    197     async def current(self, ctx: SlashContext):
    198         current_str = await self.get_current_token(ctx, tdb.Flavor.RETAIL)
    199         await ctx.send(current_str, ephemeral=True)
    200 
    201     @slash_command(description="The current classic token cost")
    202     @integration_types(guild=True, user=True)
    203     async def current_classic(self, ctx: SlashContext):
    204         current_str = await self.get_current_token(ctx, tdb.Flavor.CLASSIC)
    205         await ctx.send(current_str, ephemeral=True)
    206 
    207     @slash_command(name="add-alert", description="Add an alert listener")
    208     @integration_types(guild=True, user=True)
    209     async def add_alert(self, ctx: SlashContext):
    210         if not await self._users.exists(ctx.user.id):
    211             try:
    212                 await self.region_select_menu(ctx)
    213             except TimeoutError:
    214                 return
    215 
    216         user = await self._users.get(ctx.user.id)
    217 
    218         try:
    219             flavor = await self.flavor_select_menu(ctx)
    220             alert_category, price = await self.alert_category_select_menu(ctx)
    221             match alert_category:
    222                 case AlertCategory.LOW:
    223                     alert_type = await self.low_alert_select_menu(ctx)
    224                 case AlertCategory.HIGH:
    225                     alert_type = await self.high_alert_select_menu(ctx)
    226                 case AlertCategory.CUSTOM:
    227                     alert_type = AlertType.SPECIFIC_PRICE
    228 
    229         except (TimeoutError, ValueError):
    230             return
    231 
    232         else:
    233             alert = Alert(alert_type, flavor, user.region, price)
    234             if not await self._users.is_subscribed(user, alert):
    235                 await asyncio.gather(
    236                     self._users.add_alert(user, alert),
    237                     self._alerts.add_user(alert, user),
    238                 )
    239                 await ctx.send("Successfully added alert", ephemeral=True)
    240 
    241             else:
    242                 await ctx.send(
    243                     "You are already subscribed to this alert", ephemeral=True
    244                 )
    245 
    246     @slash_command(
    247         name="remove-alert", description="Remove an alert you have signed up for"
    248     )
    249     @integration_types(guild=True, user=True)
    250     async def remove_alert(self, ctx: SlashContext):
    251         if not await self._user_is_registered(ctx):
    252             return
    253         user = await self._users.get(ctx.user.id)
    254         alerts = await self._users.list_alerts(user)
    255         if len(alerts) == 0:
    256             await ctx.send("You do not have any alerts registered", ephemeral=True)
    257             return
    258 
    259         try:
    260             alert = await self.remove_alert_select_menu(ctx, user)
    261         except TimeoutError:
    262             return
    263         else:
    264             await asyncio.gather(
    265                 self._users.remove_alert(user, alert),
    266                 self._alerts.remove_user(alert, user),
    267             )
    268             await ctx.send("Successfully removed alert", ephemeral=True)
    269 
    270     @slash_command(
    271         name="list-alerts", description="List all alerts you have signed up for"
    272     )
    273     @integration_types(guild=True, user=True)
    274     async def list_alerts(self, ctx: SlashContext):
    275         if not await self._user_is_registered(ctx):
    276             return
    277         user = await self._users.get(ctx.user.id)
    278         alerts = await self._users.list_alerts(user)
    279         if len(alerts) == 0:
    280             await ctx.send("You do not have any alerts registered", ephemeral=True)
    281             return
    282         alerts_str = f"You have {len(alerts)} out of 25 maximum alerts registered"
    283         embeds = [
    284             Embed(
    285                 title="List of GoblinBot Tracker Alerts",
    286                 color=0x0000B1,
    287                 description=alerts_str,
    288             )
    289         ]
    290         alerts_by_flavor = await gather_alerts_by_flavor(alerts)
    291         for flavor in alerts_by_flavor:
    292             embeds.append(
    293                 await self._render_alert_flavor(alerts_by_flavor[flavor], user=user)
    294             )
    295         await ctx.send(embeds=embeds, ephemeral=True)
    296 
    297     ###################################
    298     # Callbacks Commands              #
    299     ###################################
    300 
    301     # Note: Callbacks for flavor_menu, high_alert_menu, low_alert_menu, and alert buttons
    302     # are disabled because they interfere with wait_for_component manual handling
    303 
    304     @component_callback("region_menu")
    305     async def region_menu_cb(self, ctx: ComponentContext):
    306         discord_user = await self.bot.fetch_user(ctx.user.id)
    307         await discord_user.send(
    308             "You have successfully registered your region with GoblinBot!\n"
    309             "Most interactions will happen in direct messages with GoblinBot here.\n"
    310             "You can remove your user data and alerts at any time using ```/remove-registration```\n"
    311         )
    312         await ctx.defer(edit_origin=True, suppress_error=True)
    313 
    314     ###################################
    315     # Helper Functions                #
    316     ###################################
    317 
    318     async def get_current_token(self, ctx: SlashContext, flavor: Flavor) -> str:
    319         user: User = await self._users.get(ctx.user.id)
    320         if user.region is None:
    321             return (
    322                 f"Please register with a region before attempting to list alerts using\n"
    323                 "```/register```"
    324             )
    325         region = user.region.name
    326         region_history = self._history_manager.get_history(flavor, user.region)
    327         price_movement_str = format(region_history.last_price_movement, ",")
    328         if region_history.last_price_movement > 0:
    329             price_movement_str = f"+{price_movement_str}"
    330 
    331         return (
    332             f"Last Price Value for {region}: {format(region_history.last_price_datum[1], ",")}\n"
    333             f"Last Update Time: <t:{int(region_history.last_price_datum[0].timestamp())}:F> local time\n"
    334             f"Last Price Movement: {price_movement_str}"
    335         )
    336 
    337     async def remove_alert_select_menu(self, ctx: SlashContext, user: User):
    338         alerts_by_flavor = await gather_alerts_by_flavor(user.subscribed_alerts)
    339         select_options: List[StringSelectOption] = []
    340         for flavor in alerts_by_flavor:
    341             for alert in alerts_by_flavor[flavor]:
    342                 select_options.append(
    343                     StringSelectOption(
    344                         label=f"{alert.flavor.name.lower().title()} {alert.to_human_string()}",
    345                         value=f"{alert.flavor.name.lower()} {alert.to_human_string()}",
    346                     )
    347                 )
    348         menu = StringSelectMenu(
    349             select_options,
    350             placeholder="Select an alert to remove",
    351             custom_id="remove_alert_menu",
    352         )
    353         message = await ctx.send(
    354             "Select an alert to remove", components=menu, ephemeral=True
    355         )
    356 
    357         try:
    358             alert_component: Component = await self.bot.wait_for_component(
    359                 messages=message, components=menu, timeout=30
    360             )
    361         except TimeoutError:
    362             menu.disabled = True
    363             await message.edit(context=ctx, components=menu, content="Timed out")
    364             raise TimeoutError
    365         else:
    366             menu.disabled = True
    367             await message.edit(context=ctx, components=menu)
    368             selection_split = alert_component.ctx.values[0].split(" ")
    369             flavor = Flavor[selection_split[0].upper()]
    370             alert_type_str = " ".join(selection_split[1:])
    371             alert_type = AlertType.from_str(alert_type_str)
    372 
    373             # Parse price for custom alerts
    374             price = 0
    375             if alert_type == AlertType.SPECIFIC_PRICE:
    376                 # Extract price from "Custom Price: 250,000g"
    377                 price_part = alert_type_str.split(": ")[1].rstrip("g").replace(",", "")
    378                 price_gold = int(price_part)
    379                 # Convert gold to copper
    380                 price = price_gold
    381 
    382             return Alert(alert_type, flavor, user.region, price)
    383 
    384     async def region_select_menu(self, ctx: SlashContext, user: User | None = None):
    385         region_menu = copy.deepcopy(REGION_MENU)
    386         region_menu_str = str()
    387 
    388         if user is None:
    389             region_menu_str += "You are not currently registered with a region, please select a region to register with.\n"
    390         region_menu_str += (
    391             "* You can only be registered with one region at a time.\n"
    392             "* Registering for a new region will remove your old region's registration.\n"
    393         )
    394         region_message = await ctx.send(
    395             region_menu_str,
    396             components=region_menu,
    397             ephemeral=True,
    398         )
    399         try:
    400             region_component = await self.bot.wait_for_component(
    401                 messages=region_message, components=region_menu, timeout=30
    402             )
    403         except TimeoutError:
    404             region_menu.disabled = True
    405             await region_message.edit(
    406                 context=ctx, components=region_menu, content="Timed out"
    407             )
    408             raise TimeoutError
    409         else:
    410             # Acknowledge the component interaction to avoid 404 Unknown Interaction
    411             await region_component.ctx.defer(edit_origin=True, suppress_error=True)
    412             region_menu.disabled = True
    413             region = Region(region_component.ctx.values[0].lower())
    414             user = User(ctx.user.id, region, subscribed_alerts=[])
    415             await asyncio.gather(
    416                 self._users.add(user),
    417                 region_message.edit(context=ctx, components=region_menu),
    418             )
    419             return region
    420 
    421     async def flavor_select_menu(self, ctx: SlashContext) -> Type[Flavor]:
    422         flavor_menu = copy.deepcopy(FLAVOR_MENU)
    423 
    424         flavor_message = await ctx.send(
    425             "Select a flavor to add alerts for", components=flavor_menu, ephemeral=True
    426         )
    427         try:
    428             flavor_component: Component = await self.bot.wait_for_component(
    429                 messages=flavor_message, components=flavor_menu, timeout=30
    430             )
    431         except TimeoutError:
    432             flavor_menu.disabled = True
    433             await flavor_message.edit(
    434                 context=ctx, components=flavor_menu, content="Timed out"
    435             )
    436             raise TimeoutError
    437         else:
    438             # Acknowledge the component interaction to avoid 404 Unknown Interaction
    439             await flavor_component.ctx.defer(edit_origin=True, suppress_error=True)
    440             flavor = Flavor[flavor_component.ctx.values[0].upper()]
    441             flavor_menu.disabled = True
    442             await flavor_message.edit(context=ctx, components=flavor_menu)
    443             return flavor
    444 
    445     async def alert_category_select_menu(self, ctx: SlashContext) -> tuple[AlertCategory, int]:
    446         alert_type_button = copy.deepcopy(ALERT_TYPE_ROW)
    447         alert_type_message = await ctx.send(
    448             "Select an alert type to add", components=alert_type_button, ephemeral=True
    449         )
    450         try:
    451             alert_type_component: Component = await self.bot.wait_for_component(
    452                 messages=alert_type_message, components=alert_type_button, timeout=30
    453             )
    454         except TimeoutError:
    455             for button in alert_type_button[0].components:
    456                 button.disabled = True
    457             await alert_type_message.edit(
    458                 context=ctx, components=alert_type_button, content="Timed out"
    459             )
    460             raise TimeoutError
    461         else:
    462             alert_type = AlertCategory.from_str(alert_type_component.ctx.custom_id)
    463 
    464             # If custom alert, send modal as response to button press
    465             if alert_type == AlertCategory.CUSTOM:
    466                 price = await self.custom_price_modal(alert_type_component.ctx)
    467             else:
    468                 # Acknowledge the component interaction to avoid 404 Unknown Interaction
    469                 await alert_type_component.ctx.defer(edit_origin=True, suppress_error=True)
    470                 price = 0
    471 
    472             for button in alert_type_button[0].components:
    473                 button.disabled = True
    474             await alert_type_message.edit(context=ctx, components=alert_type_button)
    475             return alert_type, price
    476 
    477     async def _alert_select_menu_handler(
    478             self, ctx: SlashContext, menu: StringSelectMenu, message: Message
    479     ) -> AlertType:
    480         try:
    481             component: Component = await self.bot.wait_for_component(
    482                 messages=message, components=menu, timeout=30
    483             )
    484         except TimeoutError:
    485             menu.disabled = True
    486             await message.edit(context=ctx, components=menu, content="Timed out")
    487             raise TimeoutError
    488         else:
    489             menu.disabled = True
    490             await component.ctx.defer(edit_origin=True, suppress_error=True)
    491             await message.edit(context=ctx, components=menu)
    492             return AlertType.from_str(component.ctx.values[0])
    493 
    494     async def high_alert_select_menu(self, ctx: SlashContext) -> AlertType:
    495         high_menu = copy.deepcopy(HIGH_ALERT_MENU)
    496         high_message = await ctx.send(
    497             "Select a time range to add a High Alert for",
    498             components=high_menu,
    499             ephemeral=True,
    500         )
    501         return await self._alert_select_menu_handler(ctx, high_menu, high_message)
    502 
    503     async def low_alert_select_menu(self, ctx: SlashContext) -> AlertType:
    504         low_menu = copy.deepcopy(LOW_ALERT_MENU)
    505         low_message = await ctx.send(
    506             "Select a time range to add a Low Alert for",
    507             components=low_menu,
    508             ephemeral=True,
    509         )
    510         return await self._alert_select_menu_handler(ctx, low_menu, low_message)
    511 
    512     async def custom_price_modal(self, ctx: ComponentContext) -> int:
    513         modal = Modal(
    514             ShortText(
    515                 label="Price (in gold)",
    516                 custom_id="price_input",
    517                 placeholder="e.g., 250000 for 250k gold",
    518                 required=True,
    519             ),
    520             title="Custom Price Alert",
    521             custom_id="custom_price_modal",
    522         )
    523         await ctx.send_modal(modal)
    524 
    525         try:
    526             modal_ctx: ModalContext = await self.bot.wait_for_modal(modal, timeout=300)
    527         except TimeoutError:
    528             await ctx.send("Modal timed out", ephemeral=True)
    529             raise TimeoutError
    530         else:
    531             price_str = modal_ctx.responses["price_input"]
    532             try:
    533                 price_gold = int(price_str.replace(",", "").replace(" ", "").replace("g", ""))
    534             except ValueError:
    535                 await modal_ctx.send("Invalid price. Please enter a valid number.", ephemeral=True)
    536                 raise
    537 
    538             if price_gold <= 0:
    539                 await modal_ctx.send("Price must be greater than 0", ephemeral=True)
    540                 raise ValueError("Price must be greater than 0")
    541 
    542             await modal_ctx.send(f"Custom price alert set for {format(price_gold, ',')}g", ephemeral=True)
    543             return price_gold
    544 
    545     async def _user_is_registered(self, ctx: SlashContext) -> bool:
    546         if not await self._users.exists(ctx.user.id):
    547             await ctx.send(
    548                 "You are not registered with any region\n"
    549                 "Please add an alert to get started ```/add-alert```",
    550                 ephemeral=True,
    551             )
    552             return False
    553         return True
    554 
    555     async def _render_alert_flavor(
    556             self, alerts: List[Alert], user: User | None = None
    557     ) -> Embed:
    558         region = alerts[0].region
    559         flavor = alerts[0].flavor
    560         fields: List[EmbedField] = []
    561         for alert in alerts:
    562             history = self._history_manager.get_history(alert.flavor, alert.region)
    563             trigger = await history.find_update_trigger_from_alert(alert)
    564             if trigger.last_alerting is not None:
    565                 alert_str = (
    566                     f"Last Alerting Price Value: {format(trigger.last_alerting[1], ",")}\n"
    567                     f"Last Alerting Time: <t:{int(trigger.last_alerting[0].timestamp())}:F> local time\n"
    568                     f"[Link to this Chart]({self._render_token_url(alert)})\n"
    569                 )
    570                 if os.getenv("ENV") == "DEBUG":
    571                     alert_str += (
    572                         f"\nShowing you some internals since this is a DEBUG build:\n"
    573                         f"```history.last_price_datum:\n"
    574                         f"\t{history.last_price_datum[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
    575                         f"\t{history.last_price_datum[1]}\n"
    576                         f"trigger.last_alerting:\n"
    577                         f"\t{trigger.last_alerting[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
    578                         f"\t{trigger.last_alerting[1]}\n"
    579                         f"trigger.last_trigger:\n"
    580                         f"\t{trigger.last_trigger[0].strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
    581                         f"\t{trigger.last_trigger[1]}\n"
    582                         f"trigger.squelched:\n\t{trigger.squelched}```"
    583                     )
    584             else:
    585                 # For custom price alerts, show current status vs threshold
    586                 if alert.alert_type == AlertType.SPECIFIC_PRICE:
    587                     current_price = history.last_price_datum[1]
    588                     target_price_gold = alert.price
    589                     current_price_gold = current_price
    590 
    591                     alert_str = (
    592                         f"Threshold has never been crossed\n"
    593                         f"Current Price: {format(current_price_gold, ',')}g\n"
    594                         f"Target Price: {format(target_price_gold, ',')}g\n"
    595                         f"[Link to this Chart]({self._render_token_url(alert, time_range='72h')})\n"
    596                     )
    597                 else:
    598                     alert_str = "You should only be seeing this if the bot has not finished importing history at startup."
    599             fields.append(
    600                 EmbedField(
    601                     name=f"{alert.to_human_string()} Alert",
    602                     value=alert_str,
    603                     inline=False,
    604                 )
    605             )
    606         embed = Embed(
    607             title=f"Alerts for {region.name} {flavor.name.lower().title()}",
    608             color=0xB10000,
    609             fields=fields,
    610         )
    611         return embed
    612 
    613     def _render_token_url(self, alert: Alert, time_range: str | None = None) -> str:
    614         match alert.flavor:
    615             case Flavor.CLASSIC:
    616                 url = "https://classic.wowtoken.app/?"
    617             case Flavor.RETAIL:
    618                 url = "https://wowtoken.app/?"
    619             case _:
    620                 raise NotImplementedError
    621         url += f"region={alert.region.value}&"
    622 
    623         # If time_range is explicitly provided, use it
    624         if time_range:
    625             url += f"time={time_range}&"
    626         else:
    627             # Otherwise, determine time range based on alert type
    628             match alert.alert_type:
    629                 case AlertType.WEEKLY_LOW | AlertType.WEEKLY_HIGH:
    630                     url += "time=168h&"
    631                 case AlertType.MONTHLY_LOW | AlertType.MONTHLY_HIGH:
    632                     url += "time=720h&"
    633                 case AlertType.YEARLY_LOW | AlertType.YEARLY_HIGH:
    634                     url += "time=1y&"
    635                 case AlertType.ALL_TIME_LOW | AlertType.ALL_TIME_HIGH:
    636                     url += "time=all&"
    637                 case _:
    638                     url += "time=72h&"
    639 
    640         return url