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