PoE2Trade update
- Vastly improved on rate-limiting tolerance - Dynamically fetches and assigns any server-imposed rate-limits. - Implemented a rolling queue system for filter checks. - This effectively means each filter for all users will run one after the other. Increasing time between checks, but prevents rate-limiting. - Improved notification message embeds to contain more information in a cleaner format - Added "implicit mods" to the stats list for better stats report - Security implementations - PoE2 session token is now encrypted in storage - Somewhat sensitive data is now obscufated in settings reply - Improved on listing pricing display - Now says "1 x Exalted Orb" instead of simply "1 exalted" - Added approximated next check times for filters, viewable in notification messages and filters list - General minor other improvements to readibility, commands, structure, cache, and functionality Support for per-user-definable session tokens is in the works. This will allow users to define their own tokens, which are encrypted for security, to reduce on ratelimits imposed by using the global token. Once this is implemented, those users will benefit from faster checks, providing earlier listing notifications. This should reduce the risk of missing valuable listings.experimental
parent
f717ad0f14
commit
58270b1fbe
|
@ -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 <token>` (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: ~<t:{next_time}:R>\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 <key> <value>` 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 <key> <value>` 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 <key> <value>` — *(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: ~<t:{next_check}:R>" 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))
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
||||
|
@ -184,26 +219,85 @@ class PoeTradeWatcher:
|
|||
"""
|
||||
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,6 +455,9 @@ 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"
|
||||
|
@ -344,6 +466,35 @@ class PoeTradeWatcher:
|
|||
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:
|
||||
"""
|
||||
Returns all active filters for a user.
|
||||
|
@ -361,74 +512,90 @@ class PoeTradeWatcher:
|
|||
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
|
||||
|
|
Loading…
Reference in New Issue