diff --git a/cmd_discord/poe2trade.py b/cmd_discord/poe2trade.py index b74a5ef..10d709e 100644 --- a/cmd_discord/poe2trade.py +++ b/cmd_discord/poe2trade.py @@ -3,17 +3,23 @@ from discord.ext import commands import globals from globals import logger from utility.PoE2_wishlist import PoeTradeWatcher -import re +import re, json, asyncio, os +from pathlib import Path from urllib.parse import quote watcher = PoeTradeWatcher() +# Load currency metadata from JSON +storage_path = Path(__file__).resolve().parent.parent / "local_storage" +currency_lookup_path = storage_path / "poe_currency_data.json" +with open(currency_lookup_path, "r", encoding="utf-8") as f: + CURRENCY_METADATA = json.load(f) + class PoE2TradeCog(commands.Cog): def __init__(self, bot): self.bot = bot watcher.set_update_callback(self.handle_poe2_updates) - self.bot.loop.create_task(watcher.start_auto_poll()) @commands.command(name="poe2trade") @@ -41,28 +47,50 @@ class PoE2TradeCog(commands.Cog): if result["status"] == "success": await ctx.reply(f"✅ Filter `{filter_id}` was successfully added to your watchlist.") + logger.info(f"User {user_id} added filter {filter_id} ({custom_name or 'no name'})") elif result["status"] == "exists": await ctx.reply(f"⚠️ Filter `{filter_id}` is already on your watchlist.") + logger.info(f"User {user_id} attempted to add existing filter {filter_id} ({custom_name or 'no name'})") elif result["status"] == "rate_limited": await ctx.reply("⏱️ You're adding filters too quickly. Please wait a bit before trying again.") + logger.warning(f"User {user_id} adding filters too quickly! Filter {filter_id} ({custom_name or 'no name'})") elif result["status"] == "limit_reached": await ctx.reply("🚫 You've reached the maximum number of filters. Remove one before adding another.") + logger.info(f"User {user_id} already at max filters when attempting to add filter {filter_id} ({custom_name or 'no name'})") elif result["status"] == "warning": await ctx.reply("⚠️ That filter returns too many results. Add it with better filters or use force mode (not currently supported).") + logger.info(f"User {user_id} attempted to add too broad filter: {filter_id} ({custom_name or 'no name'})") elif result["status"] == "invalid_session": await ctx.reply("🔒 The session token is invalid or expired. Please update it via `!poe2trade set POESESSID ` (owner-only).") + logger.error(f"POESESSID token invalid when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})") + elif result["status"] == "queued": + await ctx.reply("🕑 The filter has been queued for addition") + logger.error(f"User {user_id} queued filter {filter_id} ({custom_name or 'no name'}) for addition.") else: await ctx.reply(f"❌ Failed to add filter `{filter_id}`. Status: {result['status']}") + logger.error(f"An unknown error occured when user {user_id} attempted to add filter {filter_id} ({custom_name or 'no name'})") + elif subcommand == "remove" and len(args) >= 2: filter_id = args[1] - result = watcher.remove_filter(user_id, filter_id) - if result["status"] == "removed": - await ctx.reply(f"✅ Filter `{filter_id}` was successfully removed.") - elif result["status"] == "not_found": - await ctx.reply(f"⚠️ Filter `{filter_id}` was not found on your watchlist.") + + if filter_id.lower() == "all": + result = watcher.remove_all_filters(user_id) + if result["status"] == "removed": + await ctx.reply("✅ All filters have been removed from your watchlist.") + logger.info(f"User {user_id} removed all filters.") + else: + await ctx.reply("⚠️ You don't have any filters to remove.") else: - await ctx.reply(f"❌ Failed to remove filter `{filter_id}`. Status: {result['status']}") + result = watcher.remove_filter(user_id, filter_id) + if result["status"] == "removed": + await ctx.reply(f"✅ Filter `{filter_id}` was successfully removed.") + logger.info(f"User {user_id} removed filter {filter_id}") + elif result["status"] == "not_found": + await ctx.reply(f"⚠️ Filter `{filter_id}` was not found on your watchlist.") + else: + await ctx.reply(f"❌ Failed to remove filter `{filter_id}`. Status: {result['status']}") + elif subcommand == "list": filters = watcher.watchlists.get(user_id, []) @@ -74,15 +102,22 @@ class PoE2TradeCog(commands.Cog): name = watcher.get_filter_name(user_id, fid) label = f"{fid} — *{name}*" if name else fid + next_time = watcher.get_next_query_time(user_id, fid) + if next_time: + time_line = f"Next check: ~\n" + else: + time_line = "" + embed.add_field( name=label, - value=f"[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`", + value=f"{time_line}[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`", inline=False ) await ctx.reply(embed=embed) + logger.info(f"User {user_id} requested their watchlist.") elif subcommand == "set" and len(args) >= 3: - if ctx.author.id != ctx.guild.owner_id: + if not self._is_owner(user_id): await ctx.reply("Only the server owner can modify settings.") return key, value = args[1], args[2] @@ -99,24 +134,87 @@ class PoE2TradeCog(commands.Cog): await ctx.reply("⚠️ Something went wrong while updating the setting.") elif subcommand == "settings": - if ctx.author.id != ctx.guild.owner_id: + if not self._is_owner(user_id): await ctx.reply("Only the server owner can view settings.") + logger.info(f"User {user_id} requested (but not allowed) to see current settings.") return result = watcher.get_settings() - await ctx.reply(str(result)) + + embed = discord.Embed(title="Current `PoE 2 Trade` Settings", color=discord.Color.teal()) + embed.set_footer(text="Use `!poe2trade set ` to change a setting (owner-only)") + + status = result.get("status", "unknown") + settings = result.get("settings", {}) + + embed = discord.Embed(title="Current PoE 2 Trade Settings", color=discord.Color.teal()) + embed.add_field(name="Status", value=f"`{status}`", inline=False) + + user = self.bot.get_user(int(user_id)) + if user: + owner_display_name = user.display_name # or user.name + else: + owner_display_name = f"Unknown User" + + for key, value in settings.items(): + if key == "POESESSID": + value = f"`{value[:4]}...{value[-4:]}` (*Obfuscated*)" + if key == "AUTO_POLL_INTERVAL": + value = f"`{value}` seconds" + if key == "MAX_SAFE_RESULTS": + value = f"`{value}` max results" + if key == "USER_ADD_INTERVAL": + value = f"`{value}` seconds" + if key == "MAX_FILTERS_PER_USER": + value = f"`{value}` filters" + if key == "RATE_LIMIT_INTERVAL": + value = f"`{value}` seconds" + if key == "OWNER_ID": + value = f"`{value}` (**{owner_display_name}**)" + if key == "LEGACY_WEBHOOK_URL": + value = f"`{value[:23]}...{value[-10:]}` (*Obfuscated*)" + + embed.add_field(name=key, value=f"{value}", inline=False) + + embed.set_footer(text="Use `!poe2trade set ` to change a setting (owner-only)") + + await ctx.reply(embed=embed) + logger.info(f"User {user_id} requested and received current settings.") + elif subcommand == "pause": if watcher.pause_user(user_id): await ctx.reply("⏸️ Your filter notifications are now paused.") + logger.info(f"User {user_id} paused their filters.") else: await ctx.reply("⏸️ Your filters were already paused.") elif subcommand == "resume": if watcher.resume_user(user_id): await ctx.reply("▶️ Filter notifications have been resumed.") + logger.info(f"User {user_id} resumed their filters.") else: await ctx.reply("▶️ Your filters were not paused.") + elif subcommand == "clearcache": + if not self._is_owner(user_id): + await ctx.reply("❌ Only the bot owner can use this command.") + return + + if "-y" in args: + result = watcher.clear_seen_cache(user_id) + if result["status"] == "success": + await ctx.reply("🧹 All cached seen listings have been cleared.") + logger.info(f"User {user_id} cleared their listing cache.") + else: + await ctx.reply("⚠️ Cache clearing failed or confirmation window expired.") + else: + watcher.initiate_cache_clear(user_id) + await ctx.reply( + "⚠️ Are you sure you want to clear all listing cache?\n" + "Run `!poe2trade clearcache -y` within 60 seconds to confirm." + ) + logger.info(f"User {user_id} requested cache clear confirmation.") + else: await ctx.reply("Unknown subcommand. Try `!poe2trade` to see help.") @@ -136,14 +234,20 @@ class PoE2TradeCog(commands.Cog): "`!poe2trade pause` — Temporarily stop notifications for your filters.\n" "`!poe2trade resume` — Resume notifications for your filters.\n" "`!poe2trade set ` — *(Owner only)* Change a system setting.\n" - "`!poe2trade settings` — *(Owner only)* View current settings.\n\n" + "`!poe2trade settings` — *(Owner only)* View current settings.\n" + "`!poe2trade clearcache` — *(Owner only)* Clear current data cache\n\n" "**How to use filters:**\n" "Go to the official trade site: https://www.pathofexile.com/trade2/search/poe2/Standard\n" "Apply the filters you want (stat filters, name, price, etc).\n" "Copy the **filter ID** at the end of the URL.\n" "Example: `https://www.pathofexile.com/trade2/search/poe2/Standard/Ezn0zLzf5`\n" "Filter ID = `Ezn0zLzf5`.\n" - "Then run: `!poe2trade add Ezn0zLzf5`\n" + "Then run: `!poe2trade add Ezn0zLzf5`\n\n" + "**Important note**\n" + "Only the first 10 results from the filter is checked.\n" + "This means if you sort by eg. price, only the 10 cheapest results will be checked.\n" + "If you sort by a stat from highest to lowest on the other hand, only the 10 results \n" + "with the highest stat will be checked. So double-check your filter sorting!" ) return embed @@ -173,24 +277,39 @@ class PoE2TradeCog(commands.Cog): item_name = item_data.get("name") or item_data.get("typeLine", "Unknown Item") item_type = item_data.get("typeLine", "Unknown") - full_name = f"{item_name} ({item_type})" + full_name = item_name if item_name.strip().lower() == item_type.strip().lower() else f"{item_name} ({item_type})" + + rarity = item_data.get("frameType", 2) + rarity_colors = { + 0: discord.Color.light_grey(), # Normal + 1: discord.Color.blue(), # Magic + 2: discord.Color.gold(), # Rare + 3: discord.Color.dark_orange(), # Unique + } embed = discord.Embed( title=full_name, - url=f"https://www.pathofexile.com/trade2/search/poe2/Standard/{search_id}/item/{item.get('id')}", - color=discord.Color.gold() + url=f"https://www.pathofexile.com/trade2/search/poe2/Standard/{filter_id}", + color=rarity_colors.get(rarity, discord.Color.gold()) ) icon_url = item_data.get("icon") if icon_url: embed.set_thumbnail(url=icon_url) - embed.add_field(name="Price", value=f"{price_data.get('amount', '?')} {price_data.get('currency', '?')}", inline=True) + amount = price_data.get("amount", "?") + currency = price_data.get("currency", "?") + if currency in CURRENCY_METADATA: + name = CURRENCY_METADATA[currency]["name"] + currency_field = f"{amount} × {name}" + else: + currency_field = f"{amount} {currency}" + + embed.add_field(name="Price", value=currency_field, inline=True) seller_name = account_data.get("name", "Unknown Seller") seller_url = f"https://www.pathofexile.com/account/view-profile/{quote(seller_name)}" embed.add_field(name="Seller", value=f"[{seller_name}]({seller_url})", inline=True) - embed.add_field(name="Item Level", value=str(item_data.get("ilvl", "?")), inline=True) def clean_stat(stat: str) -> str: @@ -198,22 +317,33 @@ class PoE2TradeCog(commands.Cog): stat = re.sub(r"\[(.+?)\]", r"\1", stat) return stat - stats = item_data.get("explicitMods", []) + stats = [] + if "implicitMods" in item["item"]: + stats.append("__**Implicit Mods**__") + stats.extend(item["item"]["implicitMods"]) + if "explicitMods" in item["item"]: + stats.append("__**Explicit Mods**__") + stats.extend(item["item"]["explicitMods"]) if stats: stats_str = "\n".join(clean_stat(stat) for stat in stats) embed.add_field(name="Stats", value=stats_str, inline=False) - embed.add_field(name="*Remove from watchlist*:", value=f"`!poe2trade remove {filter_id}`", inline=False) + next_check = watcher.get_next_query_time(user_id, filter_id) + timestamp_str = f"\nNext check: ~" if next_check else "Unknown" + embed.add_field( + name="*Remove from watchlist*:", + value=f"`!poe2trade remove {filter_id}`{timestamp_str}", + inline=False + ) + filter_name = watcher.get_filter_name(str(user_id), filter_id) - footer_lines = [f"New PoE 2 trade listing"] + footer_lines = ["New PoE 2 trade listing"] if filter_name: footer_lines.insert(0, f"Filter name: {filter_name}") - embed.set_footer(text="\n".join(footer_lines)) est_char_len = len(embed.title or "") + sum(len(f.name or "") + len(f.value or "") for f in embed.fields) - # Send batch if limit exceeded if (len(embeds) + 1 > MAX_EMBEDS_PER_MESSAGE) or (current_char_count + est_char_len > EMBED_CHAR_LIMIT): await user.send(embeds=embeds) embeds = [] @@ -228,9 +358,23 @@ class PoE2TradeCog(commands.Cog): if embeds: try: await user.send(embeds=embeds) + logger.info(f"Sent {len(embeds)} embeds to user {user_id} for filter {filter_id}") except Exception as e: logger.warning(f"Failed to send batched embeds to user {user_id}: {e}") + def _is_owner(self, user_id): + """ + Returns True or False depending on user ID comparison to owner ID + """ + owner_id = watcher.settings.get("OWNER_ID") + if str(user_id) != owner_id: + return False + elif str(user_id) == owner_id: + return True + else: + logger.error(f"Failed to verify author as owner! user_id: '{user_id}', owner_id: '{owner_id}'") + + async def setup(bot): await bot.add_cog(PoE2TradeCog(bot)) diff --git a/local_storage/poe_currency_data.json b/local_storage/poe_currency_data.json new file mode 100644 index 0000000..7ad43ce --- /dev/null +++ b/local_storage/poe_currency_data.json @@ -0,0 +1,374 @@ +{ + "alt": { + "name": "Orb of Alteration", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxNYWdpYyIsInNjYWxlIjoxfV0/6308fc8ca2/CurrencyRerollMagic.png" + }, + "fusing": { + "name": "Orb of Fusing", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRMaW5rcyIsInNjYWxlIjoxfV0/c5e1959880/CurrencyRerollSocketLinks.png" + }, + "alch": { + "name": "Orb of Alchemy", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9SYXJlIiwic2NhbGUiOjF9XQ/0c72cd1d44/CurrencyUpgradeToRare.png" + }, + "chaos": { + "name": "Chaos Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxSYXJlIiwic2NhbGUiOjF9XQ/46a2347805/CurrencyRerollRare.png" + }, + "gcp": { + "name": "Gemcutter's Prism", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1RdWFsaXR5Iiwic2NhbGUiOjF9XQ/dbe9678a28/CurrencyGemQuality.png" + }, + "exalted": { + "name": "Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb1JhcmUiLCJzY2FsZSI6MX1d/33f2656aea/CurrencyAddModToRare.png" + }, + "chrome": { + "name": "Chromatic Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXRDb2xvdXJzIiwic2NhbGUiOjF9XQ/19c8ddae20/CurrencyRerollSocketColours.png" + }, + "jewellers": { + "name": "Jeweller's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lSZXJvbGxTb2NrZXROdW1iZXJzIiwic2NhbGUiOjF9XQ/ba411ff58a/CurrencyRerollSocketNumbers.png" + }, + "engineers": { + "name": "Engineer's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzT3JiIiwic2NhbGUiOjF9XQ/114b671d41/EngineersOrb.png" + }, + "infused-engineers-orb": { + "name": "Infused Engineer's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mdXNlZEVuZ2luZWVyc09yYiIsInNjYWxlIjoxfV0/55774baf2f/InfusedEngineersOrb.png" + }, + "chance": { + "name": "Orb of Chance", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlUmFuZG9tbHkiLCJzY2FsZSI6MX1d/a3f9bf0917/CurrencyUpgradeRandomly.png" + }, + "chisel": { + "name": "Cartographer's Chisel", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNYXBRdWFsaXR5Iiwic2NhbGUiOjF9XQ/0246313b99/CurrencyMapQuality.png" + }, + "scour": { + "name": "Orb of Scouring", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lDb252ZXJ0VG9Ob3JtYWwiLCJzY2FsZSI6MX1d/a0981d67fe/CurrencyConvertToNormal.png" + }, + "blessed": { + "name": "Blessed Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXBsaWNpdE1vZCIsInNjYWxlIjoxfV0/48e700cc20/CurrencyImplicitMod.png" + }, + "regret": { + "name": "Orb of Regret", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQYXNzaXZlU2tpbGxSZWZ1bmQiLCJzY2FsZSI6MX1d/32d499f562/CurrencyPassiveSkillRefund.png" + }, + "regal": { + "name": "Regal Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlTWFnaWNUb1JhcmUiLCJzY2FsZSI6MX1d/0ded706f57/CurrencyUpgradeMagicToRare.png" + }, + "divine": { + "name": "Divine Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lNb2RWYWx1ZXMiLCJzY2FsZSI6MX1d/ec48896769/CurrencyModValues.png" + }, + "vaal": { + "name": "Vaal Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lWYWFsIiwic2NhbGUiOjF9XQ/593fe2e22e/CurrencyVaal.png" + }, + "annul": { + "name": "Orb of Annulment", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsT3JiIiwic2NhbGUiOjF9XQ/0858a418ac/AnnullOrb.png" + }, + "orb-of-binding": { + "name": "Orb of Binding", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ09yYiIsInNjYWxlIjoxfV0/aac9579bd2/BindingOrb.png" + }, + "ancient-orb": { + "name": "Ancient Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudE9yYiIsInNjYWxlIjoxfV0/83015d0dc9/AncientOrb.png" + }, + "orb-of-horizons": { + "name": "Orb of Horizons", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvbk9yYiIsInNjYWxlIjoxfV0/0891338fb0/HorizonOrb.png" + }, + "harbingers-orb": { + "name": "Harbinger's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyT3JiIiwic2NhbGUiOjF9XQ/0a26e01f15/HarbingerOrb.png" + }, + "fracturing-orb": { + "name": "Fracturing Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYkNvbWJpbmVkIiwic2NhbGUiOjF9XQ/3fb18e8a5b/FracturingOrbCombined.png" + }, + "wisdom": { + "name": "Scroll of Wisdom", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJZGVudGlmaWNhdGlvbiIsInNjYWxlIjoxfV0/c2d03ed3fd/CurrencyIdentification.png" + }, + "portal": { + "name": "Portal Scroll", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lQb3J0YWwiLCJzY2FsZSI6MX1d/d92d3478a0/CurrencyPortal.png" + }, + "scrap": { + "name": "Armourer's Scrap", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBcm1vdXJRdWFsaXR5Iiwic2NhbGUiOjF9XQ/fc4e26afbc/CurrencyArmourQuality.png" + }, + "whetstone": { + "name": "Blacksmith's Whetstone", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lXZWFwb25RdWFsaXR5Iiwic2NhbGUiOjF9XQ/c9cd72719e/CurrencyWeaponQuality.png" + }, + "bauble": { + "name": "Glassblower's Bauble", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lGbGFza1F1YWxpdHkiLCJzY2FsZSI6MX1d/59e57027e5/CurrencyFlaskQuality.png" + }, + "transmute": { + "name": "Orb of Transmutation", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lVcGdyYWRlVG9NYWdpYyIsInNjYWxlIjoxfV0/ded9e8ee63/CurrencyUpgradeToMagic.png" + }, + "aug": { + "name": "Orb of Augmentation", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lBZGRNb2RUb01hZ2ljIiwic2NhbGUiOjF9XQ/d879c15321/CurrencyAddModToMagic.png" + }, + "mirror": { + "name": "Mirror of Kalandra", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lEdXBsaWNhdGUiLCJzY2FsZSI6MX1d/8d7fea29d1/CurrencyDuplicate.png" + }, + "eternal": { + "name": "Eternal Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lJbXByaW50T3JiIiwic2NhbGUiOjF9XQ/49500c70ba/CurrencyImprintOrb.png" + }, + "rogues-marker": { + "name": "Rogue's Marker", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVpc3QvSGVpc3RDb2luQ3VycmVuY3kiLCJzY2FsZSI6MX1d/335e66630d/HeistCoinCurrency.png" + }, + "facetors": { + "name": "Facetor's Lens", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ3VycmVuY3lHZW1FeHBlcmllbmNlIiwic2NhbGUiOjF9XQ/7011b1ed48/CurrencyGemExperience.png" + }, + "tempering-orb": { + "name": "Tempering Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudEJvZHlBcm1vdXJDdXJyZW5jeSIsInNjYWxlIjoxfV0/37681eda1c/DivineEnchantBodyArmourCurrency.png" + }, + "tailoring-orb": { + "name": "Tailoring Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRGl2aW5lRW5jaGFudFdlYXBvbkN1cnJlbmN5Iiwic2NhbGUiOjF9XQ/d417654a23/DivineEnchantWeaponCurrency.png" + }, + "orb-of-unmaking": { + "name": "Orb of Unmaking", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVncmV0T3JiIiwic2NhbGUiOjF9XQ/beae1b00c7/RegretOrb.png" + }, + "veiled-orb": { + "name": "Veiled Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVmVpbGVkQ2hhb3NPcmIiLCJzY2FsZSI6MX1d/fd913b89d0/VeiledChaosOrb.png" + }, + "enkindling-orb": { + "name": "Enkindling Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza1BsYXRlIiwic2NhbGUiOjF9XQ/7c1a584a8d/FlaskPlate.png" + }, + "instilling-orb": { + "name": "Instilling Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhwZWRpdGlvbi9GbGFza0luamVjdG9yIiwic2NhbGUiOjF9XQ/efc518b1be/FlaskInjector.png" + }, + "sacred-orb": { + "name": "Sacred Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2FjcmVkT3JiIiwic2NhbGUiOjF9XQ/0380fd0dba/SacredOrb.png" + }, + "stacked-deck": { + "name": "Stacked Deck", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvRGl2aW5hdGlvbi9EZWNrIiwic2NhbGUiOjF9XQ/8e83aea79a/Deck.png" + }, + "veiled-scarab": { + "name": "Veiled Scarab", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvU2NhcmFicy9TdGFja2VkU2NhcmFiIiwic2NhbGUiOjF9XQ/4674c86ff2/StackedScarab.png" + }, + "crusaders-exalted-orb": { + "name": "Crusader's Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9DcnVzYWRlck9yYiIsInNjYWxlIjoxfV0/8b48230188/CrusaderOrb.png" + }, + "redeemers-exalted-orb": { + "name": "Redeemer's Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9FeXJpZU9yYiIsInNjYWxlIjoxfV0/8ec9b52d65/EyrieOrb.png" + }, + "hunters-exalted-orb": { + "name": "Hunter's Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9CYXNpbGlza09yYiIsInNjYWxlIjoxfV0/cd2131d564/BasiliskOrb.png" + }, + "warlords-exalted-orb": { + "name": "Warlord's Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSW5mbHVlbmNlIEV4YWx0cy9Db25xdWVyb3JPcmIiLCJzY2FsZSI6MX1d/57f0d85951/ConquerorOrb.png" + }, + "awakeners-orb": { + "name": "Awakener's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVHJhbnNmZXJPcmIiLCJzY2FsZSI6MX1d/f3b1c1566f/TransferOrb.png" + }, + "mavens-orb": { + "name": "Orb of Dominance", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5PcmIiLCJzY2FsZSI6MX1d/f307d80bfd/MavenOrb.png" + }, + "eldritch-chaos-orb": { + "name": "Eldritch Chaos Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hDaGFvc09yYiIsInNjYWxlIjoxfV0/98091fc653/EldritchChaosOrb.png" + }, + "eldritch-exalted-orb": { + "name": "Eldritch Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hFeGFsdGVkT3JiIiwic2NhbGUiOjF9XQ/2da131e652/EldritchExaltedOrb.png" + }, + "eldritch-orb-of-annulment": { + "name": "Eldritch Orb of Annulment", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRWxkcml0Y2hBbm51bG1lbnRPcmIiLCJzY2FsZSI6MX1d/b58add03eb/EldritchAnnulmentOrb.png" + }, + "lesser-eldritch-ember": { + "name": "Lesser Eldritch Ember", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsxIiwic2NhbGUiOjF9XQ/c7df0e0316/CleansingFireOrbRank1.png" + }, + "greater-eldritch-ember": { + "name": "Greater Eldritch Ember", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmsyIiwic2NhbGUiOjF9XQ/698817b93d/CleansingFireOrbRank2.png" + }, + "grand-eldritch-ember": { + "name": "Grand Eldritch Ember", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbmszIiwic2NhbGUiOjF9XQ/0486f1ac82/CleansingFireOrbRank3.png" + }, + "exceptional-eldritch-ember": { + "name": "Exceptional Eldritch Ember", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2xlYW5zaW5nRmlyZU9yYlJhbms0Iiwic2NhbGUiOjF9XQ/c2c828fa16/CleansingFireOrbRank4.png" + }, + "lesser-eldritch-ichor": { + "name": "Lesser Eldritch Ichor", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazEiLCJzY2FsZSI6MX1d/70e5e53590/TangleOrbRank1.png" + }, + "greater-eldritch-ichor": { + "name": "Greater Eldritch Ichor", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazIiLCJzY2FsZSI6MX1d/689d1897c7/TangleOrbRank2.png" + }, + "grand-eldritch-ichor": { + "name": "Grand Eldritch Ichor", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazMiLCJzY2FsZSI6MX1d/199f3b36f3/TangleOrbRank3.png" + }, + "exceptional-eldritch-ichor": { + "name": "Exceptional Eldritch Ichor", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvVGFuZ2xlT3JiUmFuazQiLCJzY2FsZSI6MX1d/dcf73ecd8e/TangleOrbRank4.png" + }, + "orb-of-conflict": { + "name": "Orb of Conflict", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ29uZmxpY3RPcmJSYW5rMSIsInNjYWxlIjoxfV0/7e02c990fc/ConflictOrbRank1.png" + }, + "tainted-chromatic-orb": { + "name": "Tainted Chromatic Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNocm9tYXRpY09yYiIsInNjYWxlIjoxfV0/702d29c7ab/HellscapeChromaticOrb.png" + }, + "tainted-orb-of-fusing": { + "name": "Tainted Orb of Fusing", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU9yYk9mRnVzaW5nIiwic2NhbGUiOjF9XQ/845f3c20ed/HellscapeOrbOfFusing.png" + }, + "tainted-jewellers-orb": { + "name": "Tainted Jeweller's Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUpld2VsbGVyc09yYiIsInNjYWxlIjoxfV0/f146c29db2/HellscapeJewellersOrb.png" + }, + "tainted-chaos-orb": { + "name": "Tainted Chaos Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUNoYW9zT3JiIiwic2NhbGUiOjF9XQ/64d1f4db99/HellscapeChaosOrb.png" + }, + "tainted-exalted-orb": { + "name": "Tainted Exalted Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUV4YWx0ZWRPcmIiLCJzY2FsZSI6MX1d/68a0ea3020/HellscapeExaltedOrb.png" + }, + "tainted-mythic-orb": { + "name": "Tainted Mythic Orb", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZU15dGhpY09yYiIsInNjYWxlIjoxfV0/72ba97d1a8/HellscapeMythicOrb.png" + }, + "tainted-armourers-scrap": { + "name": "Tainted Armourer's Scrap", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUFybW91cmVyc1NjcmFwIiwic2NhbGUiOjF9XQ/9ee0a10625/HellscapeArmourersScrap.png" + }, + "tainted-blacksmiths-whetstone": { + "name": "Tainted Blacksmith's Whetstone", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZUJsYWNrc21pdGhXaGV0c3RvbmUiLCJzY2FsZSI6MX1d/0309648ccb/HellscapeBlacksmithWhetstone.png" + }, + "tainted-divine-teardrop": { + "name": "Tainted Divine Teardrop", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGVsbHNjYXBlL0hlbGxzY2FwZVRlYXJkcm9wT3JiIiwic2NhbGUiOjF9XQ/0d251b9d52/HellscapeTeardropOrb.png" + }, + "wild-lifeforce": { + "name": "Wild Crystallised Lifeforce", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9XaWxkTGlmZWZvcmNlIiwic2NhbGUiOjF9XQ/e3d0b372b0/WildLifeforce.png" + }, + "vivid-lifeforce": { + "name": "Vivid Crystallised Lifeforce", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9WaXZpZExpZmVmb3JjZSIsInNjYWxlIjoxfV0/a355b8a5a2/VividLifeforce.png" + }, + "primal-lifeforce": { + "name": "Primal Crystallised Lifeforce", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9QcmltYWxMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/c498cdfd7f/PrimalLifeforce.png" + }, + "sacred-lifeforce": { + "name": "Sacred Crystallised Lifeforce", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFydmVzdC9TYWNyZWRMaWZlZm9yY2UiLCJzY2FsZSI6MX1d/edfba3c893/SacredLifeforce.png" + }, + "hinekoras-lock": { + "name": "Hinekora's Lock", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGluZWtvcmFzTG9jayIsInNjYWxlIjoxfV0/b188026e7f/HinekorasLock.png" + }, + "mavens-chisel-of-procurement": { + "name": "Maven's Chisel of Procurement", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwxIiwic2NhbGUiOjF9XQ/a21d7d73da/MavenChisel1.png" + }, + "mavens-chisel-of-proliferation": { + "name": "Maven's Chisel of Proliferation", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwyIiwic2NhbGUiOjF9XQ/bb82bb4150/MavenChisel2.png" + }, + "mavens-chisel-of-divination": { + "name": "Maven's Chisel of Divination", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw1Iiwic2NhbGUiOjF9XQ/ff3e7f02eb/MavenChisel5.png" + }, + "mavens-chisel-of-scarabs": { + "name": "Maven's Chisel of Scarabs", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWwzIiwic2NhbGUiOjF9XQ/a7a9ac8f01/MavenChisel3.png" + }, + "mavens-chisel-of-avarice": { + "name": "Maven's Chisel of Avarice", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWF2ZW5DaGlzZWw0Iiwic2NhbGUiOjF9XQ/d878502dc7/MavenChisel4.png" + }, + "reflecting-mist": { + "name": "Reflecting Mist", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVmbGVjdGl2ZU1pc3QiLCJzY2FsZSI6MX1d/26956f795e/ReflectiveMist.png" + }, + "chaos-shard": { + "name": "Chaos Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQ2hhb3NTaGFyZCIsInNjYWxlIjoxfV0/db7041e193/ChaosShard.png" + }, + "exalted-shard": { + "name": "Exalted Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRXhhbHRlZFNoYXJkIiwic2NhbGUiOjF9XQ/b9e4013af5/ExaltedShard.png" + }, + "engineers-shard": { + "name": "Engineer's Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRW5naW5lZXJzU2hhcmQiLCJzY2FsZSI6MX1d/9fe1384ff9/EngineersShard.png" + }, + "regal-shard": { + "name": "Regal Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvUmVnYWxTaGFyZCIsInNjYWxlIjoxfV0/6f7fc44a91/RegalShard.png" + }, + "annulment-shard": { + "name": "Annulment Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5udWxsU2hhcmQiLCJzY2FsZSI6MX1d/1cf9962d97/AnnullShard.png" + }, + "binding-shard": { + "name": "Binding Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQmluZGluZ1NoYXJkIiwic2NhbGUiOjF9XQ/569d09ac86/BindingShard.png" + }, + "ancient-shard": { + "name": "Ancient Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvQW5jaWVudFNoYXJkIiwic2NhbGUiOjF9XQ/3695589639/AncientShard.png" + }, + "horizon-shard": { + "name": "Horizon Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSG9yaXpvblNoYXJkIiwic2NhbGUiOjF9XQ/627ae5f273/HorizonShard.png" + }, + "harbingers-shard": { + "name": "Harbinger's Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvSGFyYmluZ2VyU2hhcmQiLCJzY2FsZSI6MX1d/ad19e27b2f/HarbingerShard.png" + }, + "fracturing-shard": { + "name": "Fracturing Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvRnJhY3R1cmluZ09yYlNoYXJkIiwic2NhbGUiOjF9XQ/34fdc6a813/FracturingOrbShard.png" + }, + "mirror-shard": { + "name": "Mirror Shard", + "icon": "https://web.poecdn.com/gen/image/WzI1LDE0LHsiZiI6IjJESXRlbXMvQ3VycmVuY3kvTWlycm9yU2hhcmQiLCJzY2FsZSI6MX1d/698183ea2b/MirrorShard.png" + } +} \ No newline at end of file diff --git a/utility/PoE2_wishlist.py b/utility/PoE2_wishlist.py index 901b33b..5b893e7 100644 --- a/utility/PoE2_wishlist.py +++ b/utility/PoE2_wishlist.py @@ -5,7 +5,14 @@ import json import logging from pathlib import Path from collections import defaultdict +import base64 +# --- Base64 encode/decode helpers --- +def encrypt_token(token: str) -> str: + return base64.b64encode(token.encode()).decode() + +def decrypt_token(encoded: str) -> str: + return base64.b64decode(encoded.encode()).decode() class PoeTradeWatcher: """ @@ -35,11 +42,13 @@ class PoeTradeWatcher: "MAX_SAFE_RESULTS": 100, "USER_ADD_INTERVAL": 60, "MAX_FILTERS_PER_USER": 5, - "RATE_LIMIT_INTERVAL": 5, + "RATE_LIMIT_INTERVAL": 90, "POESESSID": "", + "OWNER_ID": "203190147582394369", "LEGACY_WEBHOOK_URL": "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z" } + self.dynamic_rate_limit = self.settings.get("RATE_LIMIT_INTERVAL", 90) base_path = Path(__file__).resolve().parent.parent / "local_storage" base_path.mkdir(parents=True, exist_ok=True) self.storage_file = base_path / "poe_trade_state.json" @@ -51,10 +60,16 @@ class PoeTradeWatcher: self.last_api_time = 0 self.session_valid = True self.filter_names = defaultdict(dict) # {user_id: {filter_id: custom_name}} + self.last_query_time = defaultdict(dict) # user_id -> filter_id -> timestamp + self.paused_users = set() + self.verification_queue = asyncio.Queue() + self._load_state() asyncio.create_task(self._validate_session()) + asyncio.create_task(self._start_verification_worker()) + asyncio.create_task(self.start_auto_poll()) def _init_logger(self): @@ -87,19 +102,39 @@ class PoeTradeWatcher: async def start_auto_poll(self): """ - Starts the automatic polling loop using AUTO_POLL_INTERVAL. - This will call the registered update callback when new items are found. + Starts a rolling polling system where each user/filter is queried one at a time. + Automatically adjusts wait time based on rate limit headers. """ if self._poll_task is not None: return # Already running async def poll_loop(): + base_delay = self.settings.get("RATE_LIMIT_INTERVAL", 90) + safety_margin = 2 + while True: - try: - await self.query_all() - except Exception as e: - self.logger.error(f"Error in auto poll loop: {e}") - await asyncio.sleep(self.settings.get("AUTO_POLL_INTERVAL", 300)) + for user_id, filters in self.watchlists.items(): + if str(user_id) in self.paused_users: + self.logger.debug(f"Skipping paused user {user_id}") + continue + + for filter_id in filters: + try: + # Always wait at least base_delay + delay = max(base_delay, self.dynamic_rate_limit) + safety_margin + + result = await self.query_single(user_id, filter_id) + + if result == "ratelimited": + # dynamic_rate_limit has already been adjusted within query_single + delay = max(base_delay, self.dynamic_rate_limit) + safety_margin + + except Exception as e: + self.logger.warning(f"Failed query for {filter_id} of user {user_id}: {e}") + delay = base_delay + safety_margin + + await asyncio.sleep(delay) + self._poll_task = asyncio.create_task(poll_loop()) @@ -178,32 +213,91 @@ class PoeTradeWatcher: dict: A dictionary with current settings and status. """ return {"status": "ok", "settings": self.settings} - + async def _validate_session(self): """ Performs a test request to verify if the current POESESSID is valid. Updates internal `session_valid` flag. + Handles rate limit headers to avoid overloading the API at startup. """ test_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/OzKEO5ltE" try: connector = aiohttp.TCPConnector(ssl=True) async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session: async with session.get(test_url) as resp: - if resp.status == 403: + rate_info = { + "Status": resp.status, + "Retry-After": resp.headers.get("Retry-After"), + "X-Rate-Limit-Rules": resp.headers.get("X-Rate-Limit-Rules"), + "X-Rate-Limit-Ip": resp.headers.get("X-Rate-Limit-Ip"), + "X-Rate-Limit-State": resp.headers.get("X-Rate-Limit-State"), + } + self.logger.debug(f"[{resp.status}] GET {test_url} | Rate Info: {rate_info}") + + if resp.status == 429: + retry_after = int(resp.headers.get("Retry-After", 10)) + self.session_valid = False + self.logger.warning(f"Rate limited during session validation. Sleeping for {retry_after + 2} seconds.") + await asyncio.sleep(retry_after + 2) + return + + elif resp.status == 403: self.session_valid = False self.logger.error("POESESSID validation failed: status 403") + elif resp.status == 200: self.session_valid = True self.logger.info("POESESSID validated successfully.") + else: self.session_valid = False self.logger.error(f"POESESSID validation returned unexpected status: {resp.status}") + except Exception as e: self.session_valid = False self.logger.error(f"Session validation request failed: {e}") + def get_next_query_time(self, user_id: str, filter_id: str) -> int | None: + """ + Estimate the next time this filter will be queried, accounting for queued filters, + current ratelimit interval, and safety margins. + This version ensures the returned time is in the future based on queue order. + """ + if str(user_id) in self.paused_users: + return None + + base_interval = self.dynamic_rate_limit + safety_margin = 2 + total_delay = base_interval + safety_margin + + # Build a linear list of all filters in scheduled order + full_queue = [ + (uid, fid) + for uid, flist in self.watchlists.items() + if str(uid) not in self.paused_users + for fid in flist + ] + + if (user_id, filter_id) not in full_queue: + return None + + # Filter position in the total queue + position = full_queue.index((user_id, filter_id)) + + # Always start from last API time and simulate future ticks + now = time.time() + last = self.last_api_time or now + + # Keep incrementing until we find a future timestamp + next_query = last + (position * total_delay) + while next_query <= now: + next_query += len(full_queue) * total_delay + + return int(next_query) + + def _template(self): return { "status": None, @@ -230,6 +324,16 @@ class PoeTradeWatcher: self.filter_names = defaultdict(dict, data.get("filter_names", {})) self.last_seen_items = defaultdict(lambda: defaultdict(dict), data.get("seen", {})) self.settings.update(data.get("settings", {})) + + # Decode POESESSID + enc_token = self.settings.get("POESESSID") + if enc_token: + try: + self.settings["POESESSID"] = decrypt_token(enc_token) + self.COOKIES = {"POESESSID": self.settings["POESESSID"]} + except Exception as de: + self.logger.warning(f"Could not decode POESESSID: {de}") + self.paused_users = set(data.get("paused_users", [])) self.logger.info("State loaded. Active filters: %s", sum(len(v) for v in self.watchlists.values())) except Exception as e: @@ -238,31 +342,74 @@ class PoeTradeWatcher: def _save_state(self): try: + settings_copy = self.settings.copy() + if "POESESSID" in settings_copy: + raw_token = settings_copy["POESESSID"] + settings_copy["POESESSID"] = encrypt_token(raw_token) + data = { "watchlists": dict(self.watchlists), "filter_names": self.filter_names, "seen": self.last_seen_items, - "settings": self.settings, + "settings": settings_copy, "paused_users": list(self.paused_users) } + with open(self.storage_file, "w") as f: json.dump(data, f) except Exception as e: self.logger.error(f"Failed to save state: {e}") - async def add_filter(self, user_id: str, filter_id: str, custom_name: str = None, force: bool = False) -> dict: - """ - Adds a filter to the user's watchlist. + async def _start_verification_worker(self): + while True: + user_id, filter_id, custom_name, response_callback = await self.verification_queue.get() + result = await self._verify_and_add_filter(user_id, filter_id, custom_name) + if callable(response_callback): + await response_callback(result) + await asyncio.sleep(self.settings["RATE_LIMIT_INTERVAL"]) - Args: - user_id (str): Discord user ID. - filter_id (str): PoE trade filter ID. - force (bool, optional): Whether to force add the filter even if it's broad. Defaults to False. - Returns: - dict: Result template with status, user, filter_id, and optional warnings. - """ + async def _verify_and_add_filter(self, user_id, filter_id, custom_name): + result = self._template() + result["user"] = user_id + result["filter_id"] = filter_id + + query_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}" + try: + connector = aiohttp.TCPConnector(ssl=False) + async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session: + async with session.get(query_url) as resp: + if resp.status == 403: + self.session_valid = False + result["status"] = "invalid_session" + return result + elif resp.status != 200: + result["status"] = "error" + return result + data = await resp.json() + except Exception as e: + self.logger.error(f"Error while verifying filter {filter_id}: {e}") + result["status"] = "error" + return result + + total_results = data.get("total", 0) + result["result_count"] = total_results + + if total_results > self.settings["MAX_SAFE_RESULTS"]: + result["status"] = "too_broad" + return result + + self.watchlists[user_id].append(filter_id) + if custom_name: + self.filter_names[user_id][filter_id] = custom_name + + self._save_state() + result["status"] = "success" + return result + + + async def add_filter(self, user_id: str, filter_id: str, custom_name: str = None, response_callback=None) -> dict: result = self._template() result["user"] = user_id result["filter_id"] = filter_id @@ -285,43 +432,15 @@ class PoeTradeWatcher: result["next_allowed_time"] = self.last_add_time[user_id] + self.settings["USER_ADD_INTERVAL"] return result - query_url = f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}" - try: - connector = aiohttp.TCPConnector(ssl=False) - async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES, connector=connector) as session: - async with session.get(query_url) as resp: - if resp.status == 403: - self.session_valid = False - result["status"] = "invalid_session" - return result - elif resp.status != 200: - result["status"] = "error" - return result - data = await resp.json() - except Exception as e: - self.logger.error(f"Error while checking filter {filter_id}: {e}") - result["status"] = "error" - return result - - total_results = data.get("total", 0) - result["result_count"] = total_results - - if total_results > self.settings["MAX_SAFE_RESULTS"] and not force: - result["status"] = "too_broad" - return result - - self.watchlists[user_id].append(filter_id) self.last_add_time[user_id] = now - result["status"] = "success" - if custom_name: - self.filter_names[user_id][filter_id] = custom_name - self._save_state() + await self.verification_queue.put((user_id, filter_id, custom_name, response_callback)) + result["status"] = "queued" return result def remove_filter(self, user_id: str, filter_id: str) -> dict: """ - Removes a specific filter from a user's watchlist. + Removes a specific filter from a user's watchlist and cleans up associated metadata. Args: user_id (str): Discord user ID. @@ -336,13 +455,45 @@ class PoeTradeWatcher: if filter_id in self.watchlists[user_id]: self.watchlists[user_id].remove(filter_id) + self.filter_names[user_id].pop(filter_id, None) + self.last_seen_items[user_id].pop(filter_id, None) + self.last_query_time[user_id].pop(filter_id, None) result["status"] = "removed" else: result["status"] = "not_found" self._save_state() return result + + def remove_all_filters(self, user_id: str) -> dict: + """ + Removes all filters for the specified user and clears associated metadata. + + Args: + user_id (str): Discord user ID. + + Returns: + dict: Result template with summary of removed filters. + """ + result = self._template() + result["user"] = user_id + removed = self.watchlists.pop(user_id, []) + self.filter_names.pop(user_id, None) + self.last_seen_items.pop(user_id, None) + self.last_add_time.pop(user_id, None) + self.last_query_time.pop(user_id, None) + + if removed: + result["status"] = "removed" + result["results"] = removed + result["summary"] = f"Removed {len(removed)} filters from your watchlist." + else: + result["status"] = "empty" + result["summary"] = "You have no filters to remove." + + self._save_state() + return result def get_filters(self, user_id: str) -> dict: """ @@ -360,75 +511,91 @@ class PoeTradeWatcher: result["status"] = "ok" return result - - async def query_all(self): + + async def query_single(self, user_id, filter_id): if not self.session_valid: self.logger.warning("Skipping query: POESESSID invalid.") return + if str(user_id) in self.paused_users: + return + found_items = {} async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES) as session: + async def fetch_with_handling(url, method="GET", **kwargs): + async with getattr(session, method.lower())(url, **kwargs) as res: + rate_info = { + "Status": res.status, + "Retry-After": res.headers.get("Retry-After"), + "X-Rate-Limit-Rules": res.headers.get("X-Rate-Limit-Rules"), + "X-Rate-Limit-Ip": res.headers.get("X-Rate-Limit-Ip"), + "X-Rate-Limit-State": res.headers.get("X-Rate-Limit-State"), + } + self.logger.debug(f"[{res.status}] {method} {url} | Rate Info: {rate_info}") - for user_id, filters in self.watchlists.items(): - if str(user_id) in self.paused_users: - self.logger.debug(f"Skipping paused user {user_id}") - continue - for filter_id in filters: - try: - async with session.get(f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}") as res: - if res.status != 200: - self.logger.warning(f"Failed to fetch filter {filter_id} for user {user_id}. Status: {res.status}") - continue - filter_data = await res.json() - query = {"query": filter_data.get("query", {})} + if res.status == 429: + retry_after = int(res.headers.get("Retry-After", 10)) + self.dynamic_rate_limit = retry_after + 2 + self.logger.warning(f"Rate limited on {url}. Sleeping for {self.dynamic_rate_limit} seconds.") + await asyncio.sleep(self.dynamic_rate_limit) + return "ratelimited" + elif res.status >= 400: + self.logger.warning(f"HTTP {res.status} on {url}") + return None - async with session.post(f"{self.BASE_URL}/search/poe2/{self.LEAGUE}", json=query) as search_res: - if search_res.status != 200: - self.logger.warning(f"Query failed for {filter_id}. Status: {search_res.status}") - continue - result_data = await search_res.json() + return await res.json() - search_id = result_data.get("id") - result_ids = result_data.get("result", [])[:10] - self.logger.info(f"Filter {filter_id} returned {len(result_ids)} item IDs") + filter_data = await fetch_with_handling( + f"{self.BASE_URL}/search/poe2/{self.LEAGUE}/{filter_id}", method="GET" + ) + if not filter_data: + return - if not result_ids: - continue + query = {"query": filter_data.get("query", {})} + result_data = await fetch_with_handling( + f"{self.BASE_URL}/search/poe2/{self.LEAGUE}", method="POST", json=query + ) + if not result_data: + return - joined_ids = ",".join(result_ids) - fetch_url = f"{self.BASE_URL}/fetch/{joined_ids}?query={search_id}" - async with session.get(fetch_url) as detail_res: - if detail_res.status != 200: - self.logger.warning(f"Failed to fetch items for {filter_id}. Status: {detail_res.status}") - continue - item_data = await detail_res.json() + search_id = result_data.get("id") + result_ids = result_data.get("result", [])[:10] + self.logger.info(f"Filter {filter_id} returned {len(result_ids)} item IDs") - items = item_data.get("result", []) - filtered = [] - for item in items: - item_id = item.get("id") - price_data = item.get("listing", {}).get("price", {}) - price = price_data.get("amount") - currency = price_data.get("currency", "unknown") + if not result_ids: + return - if item_id and self.should_notify(user_id, filter_id, item_id, price, currency): - filtered.append(item) - self.mark_seen(user_id, filter_id, item_id, price, currency) + joined_ids = ",".join(result_ids) + fetch_url = f"{self.BASE_URL}/fetch/{joined_ids}?query={search_id}" + item_data = await fetch_with_handling(fetch_url, method="GET") + if not item_data: + return "ratelimited" - if filtered: - found_items[(user_id, filter_id)] = { - "search_id": search_id, - "items": filtered - } + items = item_data.get("result", []) + filtered = [] + for item in items: + item_id = item.get("id") + price_data = item.get("listing", {}).get("price", {}) + price = price_data.get("amount") + currency = price_data.get("currency", "unknown") - except Exception as e: - self.logger.error(f"Error while querying filter {filter_id}: {e}") + if item_id and self.should_notify(user_id, filter_id, item_id, price, currency): + filtered.append(item) + self.mark_seen(user_id, filter_id, item_id, price, currency) - if found_items and self._on_update_callback: - await self._on_update_callback(found_items) - elif not found_items: - self.logger.info("No new results; fallback webhook active.") + if filtered: + found_items[(user_id, filter_id)] = { + "search_id": search_id, + "items": filtered + } + + now = time.time() + self.last_query_time[user_id][filter_id] = now + self.last_api_time = now + + if found_items and self._on_update_callback: + await self._on_update_callback(found_items) def cleanup_filters(self, max_age_seconds: int = 86400) -> dict: @@ -481,3 +648,57 @@ class PoeTradeWatcher: def is_paused(self, user_id: str) -> bool: return user_id in self.paused_users + def clear_cache(self, user_id: str, confirm: bool = False) -> dict: + """ + Clears all persistent and in-memory cache for a user after confirmation. + + Args: + user_id (str): Discord user ID (must be the owner). + confirm (bool): If True, actually clears cache; otherwise sends confirmation. + + Returns: + dict: Result with status and message. + """ + owner_id = self.settings.get("OWNER_ID", "203190147582394369") + result = self._template() + + if str(user_id) != owner_id: + result["status"] = "unauthorized" + result["summary"] = "Only the bot owner may use this command." + return result + + now = time.time() + if not hasattr(self, "_cache_clear_confirmations"): + self._cache_clear_confirmations = {} + + if not confirm: + self._cache_clear_confirmations[user_id] = now + result["status"] = "confirm" + result["summary"] = "⚠️ This action will clear all filters, names, and seen cache.\nRun the same command again with `-y` within 60s to confirm." + return result + + last = self._cache_clear_confirmations.get(user_id, 0) + if now - last > 60: + result["status"] = "expired" + result["summary"] = "Confirmation expired. Please run the command again." + return result + + elif confirm: + result["status"] = "invalid" + result["summary"] = "⚠️ Run the command without the `-y` flag first!" + return result + + # Reset all critical in-memory and persistent states + self.watchlists.clear() + self.filter_names.clear() + self.last_seen_items.clear() + self.last_add_time.clear() + self.last_query_time.clear() + self.paused_users.clear() + + self._cache_clear_confirmations.pop(user_id, None) + self._save_state() + + result["status"] = "cleared" + result["summary"] = "🧹 Cache successfully cleared for all users." + return result