# --------------------------------------------------------------------------------- # ░█▀▄░▄▀▀▄░█▀▄░█▀▀▄░█▀▀▄░█▀▀▀░▄▀▀▄░░░█▀▄▀█ # ░█░░░█░░█░█░█░█▄▄▀░█▄▄█░█░▀▄░█░░█░░░█░▀░█ # ░▀▀▀░░▀▀░░▀▀░░▀░▀▀░▀░░▀░▀▀▀▀░░▀▀░░░░▀░░▒▀ # Name: SkyBlockHelper # Description: Ultimate Hypixel SkyBlock suite: Bazaar, Auctions, Items, Events, Elections, Kat & Calculations # Author: @codrago_m # --------------------------------------------------------------------------------- # 🔒 Licensed under the GNU AGPLv3 # 🌐 https://www.gnu.org/licenses/agpl-3.0.html # --------------------------------------------------------------------------------- # Author: @codrago # Commands: bz, bztrack, bzuntrack, mayor, sbitem, sbevents, sbfiresale, sbah, sbkat, sbexp # scope: heroku_only # meta developer: @codrago_m # meta banner: https://raw.githubusercontent.com/coddrago/modules/refs/heads/main/banner.png # --------------------------------------------------------------------------------- __version__ = (2, 5) import asyncio import re import time import aiohttp from herokutl.custom import Message from .. import loader, utils from ..types import BotInlineCall SB_EPOCH = 1560275700 SB_DAY_SECONDS = 1200 SB_MONTH_DAYS = 31 SB_YEAR_MONTHS = 12 SB_YEAR_SECONDS = SB_DAY_SECONDS * SB_MONTH_DAYS * SB_YEAR_MONTHS SKILL_EXP_TABLE = [ 0, 50, 175, 375, 675, 1175, 1925, 2925, 4425, 6425, 9925, 14925, 22425, 32425, 47425, 67425, 97425, 147425, 222425, 322425, 522425, 822425, 1222425, 1722425, 2322425, 3022425, 3822425, 4722425, 5722425, 6822425, 8022425, 9322425, 10722425, 12222425, 13822425, 15522425, 17322425, 19222425, 21222425, 23322425, 25522425, 27822425, 30222425, 32722425, 35322425, 38072425, 40972425, 44072425, 47472425, 51172425, 55172425, 59472425, 64072425, 68972425, 74172425, 79672425, 85472425, 91572425, 97972425, 104672425, 111672425 ] KAT_TIERS = { "Common ➔ Uncommon": {"coins": 10000, "time": "6h"}, "Uncommon ➔ Rare": {"coins": 50000, "time": "1d"}, "Rare ➔ Epic": {"coins": 250000, "time": "3d"}, "Epic ➔ Legendary": {"coins": 1000000, "time": "8d"}, "Legendary ➔ Mythic": {"coins": 5000000, "time": "14d"}, } @loader.tds class SkyBlockHelperMod(loader.Module): """Hypixel SkyBlock suite: Bazaar, Auctions, Items, Events, Elections, Kat & Calculations""" strings = { "name": "SkyBlockHelper", "topic_description": "Системный журнал цен Базара и игровых событий Hypixel SkyBlock", "loading": "[SkyBlock] Получение данных с Hypixel API...", "no_args": "[SkyBlock] Укажите аргументы команды.", "not_found": "[SkyBlock] Объект {query} не найден.", "api_error": "[SkyBlock] Ошибка при обращении к Hypixel API.", "btn_refresh": "🔄 Обновить котировки", "coins_unit": "монет", "votes_unit": "голосов", "pcs_unit": "шт.", "total_label": "Итого", "not_for_sale": "Не продается", "base_stats_header": "• Базовые статы:\n", "enchanted_glint": "• Предмет имеет зачарованное свечение\n", "dungeon_item_tag": "• Предмет Подземелий\n", "no_active_perks": "Нет активных перков", "election_header": "🗳 Текущие выборы (Кандидаты):", "active_now": "Идет прямо сейчас!", "waiting": "Ожидание", "finishing_in": "Завершение через {time}", "firesale_starts_in": "Начало через {time}", "firesale_active": "АКТИВНА (Конец через {time})", "firesale_empty": "[SkyBlock] На данный момент нет активных или анонсированных Firesales.", "track_added": "[SkyBlock] Предмет {item} добавлен в почасовой трекер цен.", "track_already": "[SkyBlock] Предмет {item} уже находится в списке отслеживания.", "track_removed": "[SkyBlock] Предмет {item} удален из списка отслеживания.", "track_not_in_list": "[SkyBlock] Предмет {item} отсутствует в списке отслеживания.", "track_empty": "[SkyBlock] Список отслеживаемых предметов пуст. Добавьте: .bztrack <название>", "track_list": "[SkyBlock] Отслеживаемые предметы ({count}):\n{items}", "month_names": "Ранняя Весна,Весна,Поздняя Весна,Раннее Лето,Лето,Позднее Лето,Ранняя Осень,Осень,Поздняя Осень,Ранняя Зима,Зима,Поздняя Зима", "hourly_report_header": ( "📊 " "[SkyBlock Tracker] Почасовой отчет котировок Базара:\n" ), "bazaar_card": ( "📈 " "[Bazaar] {item_id}{qty_header}\n\n" "• Buy (Мгновенно): {buy_price:,.1f} {coins_unit}{total_buy}\n" "• Sell (Мгновенно): {sell_price:,.1f} {coins_unit}{total_sell}\n" "• Маржа (Флип): {margin:,.1f} {coins_unit} ({margin_percent:.2f}%){total_margin}\n\n" "• Объем покупки (Buy Volume): {buy_volume:,}\n" "• Объем продажи (Sell Volume): {sell_volume:,}" ), "mayor_card": ( "🏛 " "[SkyBlock Mayor] Действующий мэр: {name}\n\n" "Активные перки:\n{perks}\n" "{election_info}" ), "item_card": ( "🗡 " "[Item Info] {name} ({item_id})\n\n" "• Редкость: {tier} | Категория: {category}\n" "{stats}" "• NPC Sell Price: {npc_price} {coins_unit}\n" "{extra_info}" ), "events_card": ( " " "[SkyBlock Calendar & Timers]\n\n" "📅 Текущая дата: Year {year}, {month} {day}-е\n\n" "• Dark Auction: через {da_timer}\n" "• Jacob's Farming Contest: {jacob_status} (через {jacob_timer})\n" "• Spooky Festival: через {spooky_timer}\n" "• Season of Jerry: через {jerry_timer}\n" "• Traveling Zoo: через {zoo_timer}\n" "• New Year Century: через {ny_timer}" ), "firesale_header": ( "🔥 " "[SkyBlock Firesales] Распродажи скинов и косметики:\n\n" ), "ah_card": ( "🏷 " "[Auction House] Лоты по запросу: {query}\n\n" "• Найдено активных BIN лотов: {count}\n" "• Lowest BIN (Минимальный выкуп): {lowest_bin:,.1f} {coins_unit}\n" "• Средняя цена (BIN Avg): {avg_bin:,.1f} {coins_unit}\n\n" "Топ дешевых предложений:\n{top_lots}" ), "ah_empty": "[Auction House] Активных BIN аукционов по запросу {query} не найдено.", "kat_card": ( "🐱 " "[Kat Pet Care] Базовая стоимость улучшения питомцев:\n\n" "{tiers}\n\n" "(Время и стоимость снижаются в зависимости от уровня питомца и уровня навыка Taming)." ), "exp_card": ( "📈 " "[Skill XP Calculator] Прогресс уровня {target_lvl}\n\n" "• Суммарный опыт для уровня {target_lvl}: {total_exp:,} XP\n" "• Опыт от предыдущего уровня ({prev_lvl}): {diff_exp:,} XP" ), "exp_invalid": "[Skill XP] Укажите уровень навыка от 1 до 60. Пример: .sbexp 50", } strings_en = { "name": "SkyBlockHelper", "topic_description": "Hypixel SkyBlock Bazaar price tracking and game events log", "loading": "[SkyBlock] Fetching data from Hypixel API...", "no_args": "[SkyBlock] Please provide command arguments.", "not_found": "[SkyBlock] Target {query} not found.", "api_error": "[SkyBlock] Failed to fetch data from Hypixel API.", "btn_refresh": "🔄 Refresh Quotes", "coins_unit": "coins", "votes_unit": "votes", "pcs_unit": "pcs", "total_label": "Total", "not_for_sale": "Not for sale", "base_stats_header": "• Base Stats:\n", "enchanted_glint": "• Item has Enchanted Glint\n", "dungeon_item_tag": "• Dungeon Item\n", "no_active_perks": "No active perks", "election_header": "🗳 Current Election (Candidates):", "active_now": "Active Now!", "waiting": "Waiting", "finishing_in": "Ends in {time}", "firesale_starts_in": "Starts in {time}", "firesale_active": "ACTIVE (Ends in {time})", "firesale_empty": "[SkyBlock] No active or upcoming firesales found.", "track_added": "[SkyBlock] Item {item} added to hourly price tracker.", "track_already": "[SkyBlock] Item {item} is already being tracked.", "track_removed": "[SkyBlock] Item {item} removed from tracking list.", "track_not_in_list": "[SkyBlock] Item {item} is not in the tracking list.", "track_empty": "[SkyBlock] Tracking list is empty. Add items using: .bztrack <name>", "track_list": "[SkyBlock] Tracked items ({count}):\n{items}", "month_names": "Early Spring,Spring,Late Spring,Early Summer,Summer,Late Summer,Early Autumn,Autumn,Late Autumn,Early Winter,Winter,Late Winter", "hourly_report_header": ( "📊 " "[SkyBlock Tracker] Hourly Bazaar Price Report:\n" ), "bazaar_card": ( "📈 " "[Bazaar] {item_id}{qty_header}\n\n" "• Instant Buy: {buy_price:,.1f} {coins_unit}{total_buy}\n" "• Instant Sell: {sell_price:,.1f} {coins_unit}{total_sell}\n" "• Flip Margin: {margin:,.1f} {coins_unit} ({margin_percent:.2f}%){total_margin}\n\n" "• Buy Volume: {buy_volume:,}\n" "• Sell Volume: {sell_volume:,}" ), "mayor_card": ( "🏛 " "[SkyBlock Mayor] Active Mayor: {name}\n\n" "Active Perks:\n{perks}\n" "{election_info}" ), "item_card": ( "🗡 " "[Item Info] {name} ({item_id})\n\n" "• Rarity: {tier} | Category: {category}\n" "{stats}" "• NPC Sell Price: {npc_price} {coins_unit}\n" "{extra_info}" ), "events_card": ( " " "[SkyBlock Calendar & Timers]\n\n" "📅 Current Date: Year {year}, {month} {day}\n\n" "• Dark Auction: in {da_timer}\n" "• Jacob's Farming Contest: {jacob_status} (in {jacob_timer})\n" "• Spooky Festival: in {spooky_timer}\n" "• Season of Jerry: in {jerry_timer}\n" "• Traveling Zoo: in {zoo_timer}\n" "• New Year Century: in {ny_timer}" ), "firesale_header": ( "🔥 " "[SkyBlock Firesales] Active and upcoming firesales:\n\n" ), "ah_card": ( "🏷 " "[Auction House] Listings for: {query}\n\n" "• Active BIN listings found: {count}\n" "• Lowest BIN: {lowest_bin:,.1f} {coins_unit}\n" "• Average BIN: {avg_bin:,.1f} {coins_unit}\n\n" "Top cheapest listings:\n{top_lots}" ), "ah_empty": "[Auction House] No active BIN listings found matching {query}.", "kat_card": ( "🐱 " "[Kat Pet Care] Pet Tier Upgrade Pricing Table:\n\n" "{tiers}\n\n" "(Time and coins are reduced based on pet level and Taming skill)." ), "exp_card": ( "📈 " "[Skill XP Calculator] Level {target_lvl} Progress\n\n" "• Total cumulative XP for level {target_lvl}: {total_exp:,} XP\n" "• XP needed from previous level ({prev_lvl}): {diff_exp:,} XP" ), "exp_invalid": "[Skill XP] Please specify a skill level between 1 and 60. Example: .sbexp 50", } def __init__(self): self._poll_task = None self._items_cache = None self.config = loader.ModuleConfig( loader.ConfigValue( "interval", 3600, "Interval in seconds between price checks", validator=loader.validators.Integer(minimum=60), ), loader.ConfigValue( "tracked_items", [], "List of item IDs tracked in the hourly forum topic report", validator=loader.validators.Series( loader.validators.String(), ), ), ) async def client_ready(self): self.heroku_forum = self._db.get("heroku.forums", "channel_id", 0) self.assets_topic = await utils.asset_forum_topic( self.client, self.db, self.heroku_forum, self.strings("name"), self.strings("topic_description"), icon_emoji_id=5253952855185829086, ) self._poll_task = asyncio.create_task(self._hourly_tracker_loop()) async def on_unload(self): if self._poll_task and not self._poll_task.done(): self._poll_task.cancel() def _strip_formatting(self, text: str) -> str: """Remove Minecraft color codes and formatting artifacts""" match bool(text): case True: return re.sub(r"§[0-9a-zA-Z]", "", text).strip() case _: return "" def _format_time(self, seconds: int) -> str: """Format seconds into human-readable duration""" match seconds <= 0: case True: return "0m 00s" case _: d, rem = divmod(int(seconds), 86400) h, rem = divmod(rem, 3600) m, s = divmod(rem, 60) if d > 0: return f"{d}d {h:02d}h {m:02d}m" if h > 0: return f"{h:02d}h {m:02d}m {s:02d}s" return f"{m:02d}m {s:02d}s" async def _fetch_bazaar(self) -> dict | None: """Fetch latest market data from Hypixel Bazaar API""" url = "https://api.hypixel.net/v2/skyblock/bazaar" try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=10) as resp: if resp.status == 200: data = await resp.json() match data.get("success", False): case True: return data.get("products", {}) case _: return None except Exception: return None return None async def _fetch_auctions(self) -> list[dict] | None: """Fetch active auctions from Hypixel API""" url = "https://api.hypixel.net/v2/skyblock/auctions?page=0" try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=12) as resp: if resp.status == 200: data = await resp.json() match data.get("success", False): case True: return data.get("auctions", []) case _: return None except Exception: return None return None async def _get_all_items(self) -> list[dict] | None: """Fetch and cache items database from Hypixel API""" if self._items_cache: return self._items_cache url = "https://api.hypixel.net/v2/resources/skyblock/items" try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=15) as resp: if resp.status == 200: data = await resp.json() if data.get("success", False): self._items_cache = data.get("items", []) return self._items_cache except Exception: return None return None def _parse_query_and_count(self, raw_args: str) -> tuple[str, int]: """Extract item name and optional quantity from command arguments""" parts = raw_args.strip().split() if not parts: return "", 1 count = 1 if parts[0].isdigit() and len(parts) > 1: count = max(1, int(parts[0])) return " ".join(parts[1:]), count if parts[-1].isdigit() and len(parts) > 1: count = max(1, int(parts[-1])) return " ".join(parts[:-1]), count return raw_args.strip(), count def _find_bazaar_item(self, query: str, products: dict) -> tuple[str, dict] | tuple[None, None]: """Find matching item identifier in Bazaar products dictionary""" clean_query = query.strip().upper().replace(" ", "_").replace("-", "_") if clean_query in products: return clean_query, products[clean_query] for item_id, data in products.items(): if clean_query in item_id: return item_id, data return None, None def _build_bazaar_card(self, item_id: str, item_data: dict, count: int = 1) -> str: """Construct formatted Bazaar price overview""" quick_status = item_data.get("quick_status", {}) buy_price = quick_status.get("buyPrice", 0.0) sell_price = quick_status.get("sellPrice", 0.0) margin = buy_price - sell_price margin_percent = (margin / sell_price * 100) if sell_price > 0 else 0.0 coins_unit = self.strings("coins_unit") total_lbl = self.strings("total_label") qty_header = f" (x{count:,})" if count > 1 else "" total_buy = f" ({total_lbl}: {buy_price * count:,.1f} {coins_unit})" if count > 1 else "" total_sell = f" ({total_lbl}: {sell_price * count:,.1f} {coins_unit})" if count > 1 else "" total_margin = f" ({total_lbl}: {margin * count:,.1f} {coins_unit})" if count > 1 else "" return self.strings("bazaar_card").format( item_id=item_id, qty_header=qty_header, buy_price=buy_price, total_buy=total_buy, sell_price=sell_price, total_sell=total_sell, margin=margin, total_margin=total_margin, margin_percent=margin_percent, coins_unit=coins_unit, buy_volume=quick_status.get("buyVolume", 0), sell_volume=quick_status.get("sellVolume", 0), ) def _build_events_card(self) -> tuple[str, list[list[dict]]]: """Construct SkyBlock calendar dates and active timers""" now = time.time() passed_seconds = max(0, now - SB_EPOCH) sb_years, rem_year = divmod(passed_seconds, SB_YEAR_SECONDS) current_year = int(sb_years) + 1 months_list = self.strings("month_names").split(",") sb_month_idx, rem_month = divmod(rem_year, SB_DAY_SECONDS * SB_MONTH_DAYS) current_month = months_list[int(sb_month_idx) % 12] current_day = int(rem_month // SB_DAY_SECONDS) + 1 now_dt = time.gmtime(now) da_minutes_left = (55 - now_dt.tm_min) % 60 da_seconds_left = (da_minutes_left * 60) - now_dt.tm_sec if da_seconds_left <= 0: da_seconds_left += 3600 da_timer = self._format_time(da_seconds_left) jacob_min = (15 - now_dt.tm_min) % 60 jacob_sec = (jacob_min * 60) - now_dt.tm_sec if jacob_sec <= 0: jacob_sec += 3600 if 15 <= now_dt.tm_min < 35: jacob_status = self.strings("active_now") active_left = ((35 - now_dt.tm_min) * 60) - now_dt.tm_sec jacob_timer = self.strings("finishing_in").format(time=self._format_time(active_left)) else: jacob_status = self.strings("waiting") jacob_timer = self._format_time(jacob_sec) spooky_start_sec = (7 * SB_MONTH_DAYS + 28) * SB_DAY_SECONDS spooky_wait = (spooky_start_sec - rem_year) if rem_year < spooky_start_sec else (SB_YEAR_SECONDS - rem_year + spooky_start_sec) spooky_timer = self._format_time(spooky_wait) jerry_start_sec = (11 * SB_MONTH_DAYS + 23) * SB_DAY_SECONDS jerry_wait = (jerry_start_sec - rem_year) if rem_year < jerry_start_sec else (SB_YEAR_SECONDS - rem_year + jerry_start_sec) jerry_timer = self._format_time(jerry_wait) zoo_start_sec = (3 * SB_MONTH_DAYS + 0) * SB_DAY_SECONDS zoo_wait = (zoo_start_sec - rem_year) if rem_year < zoo_start_sec else (SB_YEAR_SECONDS - rem_year + zoo_start_sec) zoo_timer = self._format_time(zoo_wait) ny_start_sec = (11 * SB_MONTH_DAYS + 28) * SB_DAY_SECONDS ny_wait = (ny_start_sec - rem_year) if rem_year < ny_start_sec else (SB_YEAR_SECONDS - rem_year + ny_start_sec) ny_timer = self._format_time(ny_wait) text = self.strings("events_card").format( year=current_year, month=current_month, day=current_day, da_timer=da_timer, jacob_status=jacob_status, jacob_timer=jacob_timer, spooky_timer=spooky_timer, jerry_timer=jerry_timer, zoo_timer=zoo_timer, ny_timer=ny_timer, ) markup = [ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_events, "style": "primary", } ] ] return text, markup async def _build_mayor_card(self) -> tuple[str, list[list[dict]]] | tuple[None, None]: """Construct active Mayor breakdown and election candidate data""" url = "https://api.hypixel.net/v2/resources/skyblock/election" try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=10) as resp: if resp.status != 200: return None, None data = await resp.json() except Exception: return None, None mayor = data.get("mayor", {}) name = self._strip_formatting(mayor.get("name", "Unknown")) perks_list = mayor.get("perks", []) formatted_perks = "" for perk in perks_list: p_name = self._strip_formatting(perk.get("name", "Unknown Perk")) p_desc = self._strip_formatting(perk.get("description", "")) formatted_perks += f"• {p_name}: {p_desc}\n" if not formatted_perks: formatted_perks = f"• {self.strings('no_active_perks')}" current_election = data.get("current", {}) candidates = current_election.get("candidates", []) election_info = "" if candidates: total_votes = sum(c.get("votes", 0) for c in candidates) cand_lines = [] votes_unit = self.strings("votes_unit") for c in sorted(candidates, key=lambda x: x.get("votes", 0), reverse=True): c_name = self._strip_formatting(c.get("name", "Candidate")) c_votes = c.get("votes", 0) pct = (c_votes / total_votes * 100) if total_votes > 0 else 0 cand_lines.append(f"• {c_name}: {c_votes:,} {votes_unit} ({pct:.1f}%)") election_info = f"\n{self.strings('election_header')}\n" + "\n".join(cand_lines) text = self.strings("mayor_card").format( name=name, perks=formatted_perks.strip(), election_info=election_info, ) markup = [ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_mayor, "style": "primary", } ] ] return text, markup async def _build_firesale_card(self) -> tuple[str, list[list[dict]]] | tuple[None, None]: """Construct active and upcoming cosmetic Firesales overview""" url = "https://api.hypixel.net/v2/skyblock/firesales" try: async with aiohttp.ClientSession() as session: async with session.get(url, timeout=10) as resp: if resp.status != 200: return None, None data = await resp.json() except Exception: return None, None sales = data.get("sales", []) if not sales: return self.strings("firesale_empty"), [] now_ms = int(time.time() * 1000) lines = [] pcs_unit = self.strings("pcs_unit") for sale in sales: item_id = sale.get("item_id", "Unknown") price = sale.get("price", 0) amount = sale.get("amount", 0) start_ms = sale.get("start", 0) end_ms = sale.get("end", 0) if now_ms < start_ms: status = self.strings("firesale_starts_in").format(time=self._format_time((start_ms - now_ms) // 1000)) elif start_ms <= now_ms <= end_ms: status = self.strings("firesale_active").format(time=self._format_time((end_ms - now_ms) // 1000)) else: continue lines.append( f"• {item_id}\n" f" - {price:,} Gems | {amount:,} {pcs_unit}\n" f" - {status}" ) if not lines: return self.strings("firesale_empty"), [] text = self.strings("firesale_header") + "\n\n".join(lines) markup = [ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_firesale, "style": "primary", } ] ] return text, markup def _build_ah_card(self, query: str, matched: list[dict]) -> tuple[str, list[list[dict]]]: """Construct Auction House results with refresh action""" total_lots = len(matched) prices = [x.get("starting_bid", 0) for x in matched] lowest_bin = prices[0] avg_bin = sum(prices) / len(prices) coins_unit = self.strings("coins_unit") top_lots = matched[:5] lot_lines = [] for lot in top_lots: name = self._strip_formatting(lot.get("item_name", "Item")) price = lot.get("starting_bid", 0) lot_lines.append(f"• {name}: {price:,.1f} {coins_unit}") text = self.strings("ah_card").format( query=query, count=total_lots, lowest_bin=lowest_bin, avg_bin=avg_bin, coins_unit=coins_unit, top_lots="\n".join(lot_lines), ) buttons = [ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_ah, "args": (query,), "style": "primary", } ] ] return text, buttons async def _hourly_tracker_loop(self): """Background loop checking watched Bazaar prices every interval""" while True: await asyncio.sleep(self.config["interval"]) tracked = self.config["tracked_items"] if not tracked or not self.assets_topic: continue products = await self._fetch_bazaar() if not products: continue lines = [] coins_unit = self.strings("coins_unit") for item_id in tracked: if item_id in products: qs = products[item_id].get("quick_status", {}) buy_price = qs.get("buyPrice", 0.0) sell_price = qs.get("sellPrice", 0.0) lines.append( f"• {item_id}: Buy {buy_price:,.1f} | Sell {sell_price:,.1f} {coins_unit}" ) if lines: report_text = self.strings("hourly_report_header") + "\n".join(lines) await self.inline.bot.send_message( self.heroku_forum, report_text, message_thread_id=self.assets_topic.id, link_preview=False, ) async def bzcmd(self, message: Message): """Look up item prices and total quantity costs on Hypixel Bazaar""" args = utils.get_args_raw(message) if not args: return await utils.answer(message, self.strings("no_args")) query, count = self._parse_query_and_count(args) if not query: return await utils.answer(message, self.strings("no_args")) products = await self._fetch_bazaar() if not products: return await utils.answer(message, self.strings("api_error")) item_id, item_data = self._find_bazaar_item(query, products) if not item_id: return await utils.answer(message, self.strings("not_found").format(query=query)) text = self._build_bazaar_card(item_id, item_data, count) await self.inline.form( message=message, text=text, reply_markup=[ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_bz, "args": (item_id, count), "style": "primary", } ] ], ) async def _handle_refresh_bz(self, call: BotInlineCall, item_id: str, count: int = 1): products = await self._fetch_bazaar() if not products or item_id not in products: return await call.answer(self.strings("api_error"), show_alert=True) text = self._build_bazaar_card(item_id, products[item_id], count) await call.edit( text=text, reply_markup=[ [ { "text": self.strings("btn_refresh"), "callback": self._handle_refresh_bz, "args": (item_id, count), "style": "primary", } ] ], ) async def bztrackcmd(self, message: Message): """Add an item to the hourly tracker or list all tracked items""" args = utils.get_args_raw(message) if not args: tracked = self.config["tracked_items"] if not tracked: return await utils.answer(message, self.strings("track_empty")) items_fmt = "\n".join(f"• {item}" for item in tracked) return await utils.answer( message, self.strings("track_list").format(count=len(tracked), items=items_fmt), ) products = await self._fetch_bazaar() if not products: return await utils.answer(message, self.strings("api_error")) item_id, _ = self._find_bazaar_item(args, products) if not item_id: return await utils.answer(message, self.strings("not_found").format(query=query)) if item_id in self.config["tracked_items"]: return await utils.answer(message, self.strings("track_already").format(item=item_id)) self.config["tracked_items"] = self.config["tracked_items"] + [item_id] await utils.answer(message, self.strings("track_added").format(item=item_id)) async def bzuntrackcmd(self, message: Message): """Remove an item from the hourly price tracker""" args = utils.get_args_raw(message) if not args: return await utils.answer(message, self.strings("no_args")) clean_query = args.strip().upper().replace(" ", "_").replace("-", "_") target_item = None for item in self.config["tracked_items"]: if clean_query in item: target_item = item break if not target_item: return await utils.answer(message, self.strings("track_not_in_list").format(item=args)) updated_list = [item for item in self.config["tracked_items"] if item != target_item] self.config["tracked_items"] = updated_list await utils.answer(message, self.strings("track_removed").format(item=target_item)) async def mayorcmd(self, message: Message): """Fetch current active SkyBlock Mayor, perks, and live election breakdown""" text, markup = await self._build_mayor_card() if not text: return await utils.answer(message, self.strings("api_error")) await self.inline.form( message=message, text=text, reply_markup=markup, ) async def _handle_refresh_mayor(self, call: BotInlineCall): text, markup = await self._build_mayor_card() if not text: return await call.answer(self.strings("api_error"), show_alert=True) await call.edit(text=text, reply_markup=markup) async def sbitemcmd(self, message: Message): """Look up stats, rarity, and details for any SkyBlock item""" query = utils.get_args_raw(message) if not query: return await utils.answer(message, self.strings("no_args")) items = await self._get_all_items() if not items: return await utils.answer(message, self.strings("api_error")) clean_q = query.strip().lower() matched_item = None for item in items: item_id = item.get("id", "").lower() name = self._strip_formatting(item.get("name", "")).lower() if clean_q == item_id or clean_q == name: matched_item = item break if clean_q in item_id or clean_q in name: matched_item = item if not matched_item: return await utils.answer(message, self.strings("not_found").format(query=query)) name = self._strip_formatting(matched_item.get("name", matched_item.get("id", "Unknown"))) item_id = matched_item.get("id", "UNKNOWN") tier = matched_item.get("tier", "COMMON") category = matched_item.get("category", "MISC") npc_price = matched_item.get("npc_sell_price", 0) stats_data = matched_item.get("stats", {}) stats_lines = "" if stats_data: stats_formatted = [ f" - {k.replace('_', ' ').title()}: +{v}" for k, v in stats_data.items() ] stats_lines = self.strings("base_stats_header") + "\n".join(stats_formatted) + "\n" extra_info = "" if matched_item.get("glowing", False): extra_info += self.strings("enchanted_glint") if matched_item.get("dungeon_item", False): extra_info += self.strings("dungeon_item_tag") text = self.strings("item_card").format( name=name, item_id=item_id, tier=tier, category=category, stats=stats_lines, npc_price=f"{npc_price:,.1f}" if npc_price else self.strings("not_for_sale"), coins_unit=self.strings("coins_unit"), extra_info=extra_info, ) await utils.answer(message, text) async def sbeventscmd(self, message: Message): """Show current SkyBlock date and upcoming global event timers""" text, markup = self._build_events_card() await self.inline.form( message=message, text=text, reply_markup=markup, ) async def _handle_refresh_events(self, call: BotInlineCall): text, markup = self._build_events_card() await call.edit(text=text, reply_markup=markup) async def sbfiresalecmd(self, message: Message): """List active and upcoming cosmetic Firesales""" text, markup = await self._build_firesale_card() if not text: return await utils.answer(message, self.strings("api_error")) if not markup: return await utils.answer(message, text) await self.inline.form( message=message, text=text, reply_markup=markup, ) async def _handle_refresh_firesale(self, call: BotInlineCall): text, markup = await self._build_firesale_card() if not text: return await call.answer(self.strings("api_error"), show_alert=True) await call.edit(text=text, reply_markup=markup) async def sbahcmd(self, message: Message): """Search active Auction House lots and lowest BIN prices""" query = utils.get_args_raw(message) if not query: return await utils.answer(message, self.strings("no_args")) auctions = await self._fetch_auctions() if not auctions: return await utils.answer(message, self.strings("api_error")) clean_q = query.strip().lower() matched = [] for auc in auctions: item_name = self._strip_formatting(auc.get("item_name", "")).lower() if clean_q in item_name and auc.get("bin", False): matched.append(auc) if not matched: return await utils.answer(message, self.strings("ah_empty").format(query=query)) matched.sort(key=lambda x: x.get("starting_bid", 0)) text, markup = self._build_ah_card(query, matched) await self.inline.form( message=message, text=text, reply_markup=markup, ) async def _handle_refresh_ah(self, call: BotInlineCall, query: str): auctions = await self._fetch_auctions() if not auctions: return await call.answer(self.strings("api_error"), show_alert=True) clean_q = query.strip().lower() matched = [] for auc in auctions: item_name = self._strip_formatting(auc.get("item_name", "")).lower() if clean_q in item_name and auc.get("bin", False): matched.append(auc) if not matched: return await call.edit(self.strings("ah_empty").format(query=query)) matched.sort(key=lambda x: x.get("starting_bid", 0)) text, markup = self._build_ah_card(query, matched) await call.edit(text=text, reply_markup=markup) async def sbkatcmd(self, message: Message): """Display all Kat pet rarity upgrade costs and base durations""" coins_unit = self.strings("coins_unit") lines = [ f"• {tier}:\n - {data['coins']:,} {coins_unit} | {data['time']}" for tier, data in KAT_TIERS.items() ] text = self.strings("kat_card").format(tiers="\n".join(lines)) await utils.answer(message, text) async def sbexpcmd(self, message: Message): """Calculate cumulative skill XP requirement for any level from 1 to 60""" args = utils.get_args_raw(message) if not args or not args.strip().isdigit(): return await utils.answer(message, self.strings("exp_invalid")) lvl = int(args.strip()) match (1 <= lvl <= 60): case True: pass case _: return await utils.answer(message, self.strings("exp_invalid")) total_exp = SKILL_EXP_TABLE[lvl] diff_exp = total_exp - SKILL_EXP_TABLE[lvl - 1] text = self.strings("exp_card").format( target_lvl=lvl, prev_lvl=lvl - 1, total_exp=total_exp, diff_exp=diff_exp, ) await utils.answer(message, text)