PoE 2 Trading Site Implementation
This update includes implementation of Path of Exile 2 trading watchlisting, allowing users to get notified on Discord about new listings within the filter criteria. - Multiple new commands - `!poe2trade` - DMs a message containing basic usage and instructions. - `!poe2trade add <filter_id> [filter_name]` - Adds a trade filter to their watchlist. - New matches will be sent to the user in a DM. - `!poe2trade remove <filter_id | all>` - Removes the specified filter from their watchlist. - If filter_id is set to `all`, it will clear all filters from their watchlist. - `!poe2trade list` - Lists the user's watchlisted filters, along with the filter nickname (if assigned). - `!poe2trade pause` - Stops watchlisting all their filters. No processing nor notifications are performed. - `!poe2trade resume` - Resumes the watchlisting of the user. - `!poe2trade set <key> <value>` - Allows admin/owner to modify settings without restarting the bot. - `!poe2trade settings` - Allows admin/owner to view current settings. - Will be updated in the future to allow non-admin users to view non-sensitive settings. - Automatic notification of new listings in a neatly-formatted embed message - Currently, notification embeds contain: item name, item icon, item level, item stats, listed price, seller name (and account hyperlink), filter information, and instructions on removing the filter from watchlist. - Notification messages are condensed to avoid spam as much as possible; multiple new listings (up to 10) will be combined into a single message containing several embeds. - Respects rate limits to avoid getting the bot locked out. - These ratelimits are set by GGG and CloudFlare, and are thus very strict. This shouldn't affect usability much however. - Several minor QoL features Still very much in active dev, so availability may fluctuate, and features will change. Current TODO: - Smart price-to-stat comparison - Determine listing value for the user to avoid being scammed, and easily pick up good deals. - Will compare item stats and listing price with other items of the same name, level and quality. - Direct listing hyperlink - Currently, PoE 2's trading site does not support direct item links, so this has to be done by dynamically generate a filter that will only result in the relevant listing, providing easy direct access.experimental
parent
d5581710a7
commit
f717ad0f14
|
@ -0,0 +1,236 @@
|
|||
import discord
|
||||
from discord.ext import commands
|
||||
import globals
|
||||
from globals import logger
|
||||
from utility.PoE2_wishlist import PoeTradeWatcher
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
watcher = PoeTradeWatcher()
|
||||
|
||||
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")
|
||||
async def poe2trade(self, ctx, *, arg_str: str = ""):
|
||||
if not arg_str:
|
||||
await ctx.author.send(embed=self._get_help_embed())
|
||||
await ctx.reply("I've sent you a DM with usage instructions.")
|
||||
return
|
||||
|
||||
args = arg_str.strip().split()
|
||||
subcommand = args[0].lower()
|
||||
user_id = str(ctx.author.id)
|
||||
|
||||
try:
|
||||
if subcommand == "add" and len(args) >= 2:
|
||||
filter_id = args[1]
|
||||
custom_name = " ".join(args[2:]).strip() if len(args) > 2 else None
|
||||
|
||||
# Validate name (only basic safe characters)
|
||||
if custom_name and not re.fullmatch(r"[a-zA-Z0-9\s\-\_\!\?\(\)\[\]]+", custom_name):
|
||||
await ctx.reply("❌ Invalid characters in custom name. Only letters, numbers, spaces, and basic punctuation are allowed.")
|
||||
return
|
||||
|
||||
result = await watcher.add_filter(user_id, filter_id, custom_name)
|
||||
|
||||
if result["status"] == "success":
|
||||
await ctx.reply(f"✅ Filter `{filter_id}` was successfully added to your watchlist.")
|
||||
elif result["status"] == "exists":
|
||||
await ctx.reply(f"⚠️ Filter `{filter_id}` is already on your watchlist.")
|
||||
elif result["status"] == "rate_limited":
|
||||
await ctx.reply("⏱️ You're adding filters too quickly. Please wait a bit before trying again.")
|
||||
elif result["status"] == "limit_reached":
|
||||
await ctx.reply("🚫 You've reached the maximum number of filters. Remove one before adding another.")
|
||||
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).")
|
||||
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).")
|
||||
else:
|
||||
await ctx.reply(f"❌ Failed to add filter `{filter_id}`. Status: {result['status']}")
|
||||
|
||||
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.")
|
||||
else:
|
||||
await ctx.reply(f"❌ Failed to remove filter `{filter_id}`. Status: {result['status']}")
|
||||
|
||||
elif subcommand == "list":
|
||||
filters = watcher.watchlists.get(user_id, [])
|
||||
if not filters:
|
||||
await ctx.reply("You don't have any watchlisted filters.")
|
||||
else:
|
||||
embed = discord.Embed(title="Your PoE2 Watchlist", color=discord.Color.orange())
|
||||
for fid in filters:
|
||||
name = watcher.get_filter_name(user_id, fid)
|
||||
label = f"{fid} — *{name}*" if name else fid
|
||||
|
||||
embed.add_field(
|
||||
name=label,
|
||||
value=f"[Open in Trade Site](https://www.pathofexile.com/trade2/search/poe2/Standard/{fid})\n`!poe2trade remove {fid}`",
|
||||
inline=False
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
elif subcommand == "set" and len(args) >= 3:
|
||||
if ctx.author.id != ctx.guild.owner_id:
|
||||
await ctx.reply("Only the server owner can modify settings.")
|
||||
return
|
||||
key, value = args[1], args[2]
|
||||
parsed_value = int(value) if value.isdigit() else value
|
||||
|
||||
result = await watcher.set_setting(key, parsed_value)
|
||||
|
||||
if result["status"] == "ok":
|
||||
logger.info(f"Setting updated: {key} = {value}")
|
||||
await ctx.reply(f"✅ Setting `{key}` updated successfully.")
|
||||
elif result["status"] == "invalid_key":
|
||||
await ctx.reply(f"❌ Setting `{key}` is not recognized.")
|
||||
else:
|
||||
await ctx.reply("⚠️ Something went wrong while updating the setting.")
|
||||
|
||||
elif subcommand == "settings":
|
||||
if ctx.author.id != ctx.guild.owner_id:
|
||||
await ctx.reply("Only the server owner can view settings.")
|
||||
return
|
||||
result = watcher.get_settings()
|
||||
await ctx.reply(str(result))
|
||||
|
||||
elif subcommand == "pause":
|
||||
if watcher.pause_user(user_id):
|
||||
await ctx.reply("⏸️ Your filter notifications are now paused.")
|
||||
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.")
|
||||
else:
|
||||
await ctx.reply("▶️ Your filters were not paused.")
|
||||
|
||||
else:
|
||||
await ctx.reply("Unknown subcommand. Try `!poe2trade` to see help.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in !poe2trade command: {e}", exc_info=True)
|
||||
await ctx.reply("Something went wrong handling your command.")
|
||||
|
||||
|
||||
def _get_help_embed(self):
|
||||
embed = discord.Embed(title="PoE2 Trade Tracker Help", color=discord.Color.blue())
|
||||
embed.description = (
|
||||
"This bot allows you to track Path of Exile 2 trade listings.\n\n"
|
||||
"**Usage:**\n"
|
||||
"`!poe2trade add <filter_id> [filter_nickname]` — Adds a new filter to your watchlist.\n"
|
||||
"`!poe2trade remove <filter_id | all>` — Removes a filter or all.\n"
|
||||
"`!poe2trade list` — Shows your currently watched filters.\n"
|
||||
"`!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"
|
||||
"**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"
|
||||
)
|
||||
return embed
|
||||
|
||||
|
||||
async def handle_poe2_updates(self, data):
|
||||
EMBED_CHAR_LIMIT = 6000
|
||||
MAX_EMBEDS_PER_MESSAGE = 10
|
||||
|
||||
for (user_id, filter_id), payload in data.items():
|
||||
user = self.bot.get_user(int(user_id))
|
||||
if not user:
|
||||
logger.warning(f"User ID {user_id} not found in cache.")
|
||||
continue
|
||||
|
||||
embeds = []
|
||||
current_char_count = 0
|
||||
|
||||
search_id = payload["search_id"]
|
||||
items = payload["items"]
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
listing = item.get("listing", {})
|
||||
item_data = item.get("item", {})
|
||||
price_data = listing.get("price", {})
|
||||
account_data = listing.get("account", {})
|
||||
|
||||
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})"
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
stat = re.sub(r"\[.*?\|(.+?)\]", r"\1", stat)
|
||||
stat = re.sub(r"\[(.+?)\]", r"\1", stat)
|
||||
return stat
|
||||
|
||||
stats = item_data.get("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)
|
||||
filter_name = watcher.get_filter_name(str(user_id), filter_id)
|
||||
footer_lines = [f"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 = []
|
||||
current_char_count = 0
|
||||
|
||||
embeds.append(embed)
|
||||
current_char_count += est_char_len
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process or build embed for user {user_id}: {e}")
|
||||
|
||||
if embeds:
|
||||
try:
|
||||
await user.send(embeds=embeds)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send batched embeds to user {user_id}: {e}")
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
await bot.add_cog(PoE2TradeCog(bot))
|
|
@ -0,0 +1,483 @@
|
|||
import aiohttp
|
||||
import asyncio
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class PoeTradeWatcher:
|
||||
"""
|
||||
A watcher class for managing Path of Exile trade filters, querying updates,
|
||||
and notifying clients of new item listings.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initializes the watcher with default settings and loads persistent state.
|
||||
"""
|
||||
self._poll_task = None
|
||||
self._on_update_callback = None
|
||||
self.logger = self._init_logger()
|
||||
|
||||
self.BASE_URL = "https://www.pathofexile.com/api/trade2"
|
||||
self.LEAGUE = "Standard"
|
||||
self.HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
self.COOKIES = {}
|
||||
self.settings = {
|
||||
"AUTO_POLL_INTERVAL": 300,
|
||||
"MAX_SAFE_RESULTS": 100,
|
||||
"USER_ADD_INTERVAL": 60,
|
||||
"MAX_FILTERS_PER_USER": 5,
|
||||
"RATE_LIMIT_INTERVAL": 5,
|
||||
"POESESSID": "",
|
||||
"LEGACY_WEBHOOK_URL": "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z"
|
||||
}
|
||||
|
||||
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"
|
||||
self.log_file = base_path / "poe_trade.log"
|
||||
|
||||
self.watchlists = defaultdict(list)
|
||||
self.last_seen_items = defaultdict(set)
|
||||
self.last_add_time = defaultdict(lambda: 0)
|
||||
self.last_api_time = 0
|
||||
self.session_valid = True
|
||||
self.filter_names = defaultdict(dict) # {user_id: {filter_id: custom_name}}
|
||||
self.paused_users = set()
|
||||
|
||||
self._load_state()
|
||||
asyncio.create_task(self._validate_session())
|
||||
|
||||
|
||||
def _init_logger(self):
|
||||
logger = logging.getLogger("PoeTradeWatcher")
|
||||
logger.setLevel(logging.INFO)
|
||||
log_path = Path(__file__).resolve().parent.parent / "local_storage" / "poe_trade.log"
|
||||
if not logger.handlers:
|
||||
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||
formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def should_notify(self, user_id, filter_id, item_id, price, currency) -> bool:
|
||||
try:
|
||||
entry = self.last_seen_items[user_id][filter_id][item_id]
|
||||
return entry.get("price") != price or entry.get("currency") != currency
|
||||
except KeyError:
|
||||
return True
|
||||
|
||||
|
||||
def mark_seen(self, user_id, filter_id, item_id, price, currency):
|
||||
self.last_seen_items.setdefault(user_id, {}).setdefault(filter_id, {})[item_id] = {
|
||||
"price": price,
|
||||
"currency": currency
|
||||
}
|
||||
self._save_state()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
if self._poll_task is not None:
|
||||
return # Already running
|
||||
|
||||
async def poll_loop():
|
||||
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))
|
||||
|
||||
self._poll_task = asyncio.create_task(poll_loop())
|
||||
|
||||
|
||||
def pause(self):
|
||||
"""
|
||||
Pauses the auto-polling loop if it's currently running.
|
||||
"""
|
||||
if self._poll_task:
|
||||
self._poll_task.cancel()
|
||||
self._poll_task = None
|
||||
self.logger.info("Auto-polling paused.")
|
||||
|
||||
|
||||
def resume(self):
|
||||
"""
|
||||
Resumes auto-polling if it was previously paused.
|
||||
"""
|
||||
if self._poll_task is None:
|
||||
asyncio.create_task(self.start_auto_poll())
|
||||
self.logger.info("Auto-polling resumed.")
|
||||
|
||||
|
||||
def set_update_callback(self, callback):
|
||||
"""
|
||||
Sets the callback function that will be triggered when new results are found during polling.
|
||||
|
||||
Args:
|
||||
callback (Callable): An async function that receives a dict of new results.
|
||||
"""
|
||||
self._on_update_callback = callback
|
||||
|
||||
|
||||
async def set_setting(self, key, value, force: bool = False):
|
||||
"""
|
||||
Updates a configuration setting and persists the state.
|
||||
|
||||
Args:
|
||||
key (str): Setting name to update.
|
||||
value (Any): New value for the setting.
|
||||
force (bool): Allows overriding even admin-only settings.
|
||||
|
||||
Returns:
|
||||
dict: Result with status and current setting value.
|
||||
"""
|
||||
result = self._template()
|
||||
admin_only_keys = {"RATE_LIMIT_INTERVAL", "USER_ADD_INTERVAL", "MAX_FILTERS_PER_USER", "AUTO_POLL_INTERVAL", "MAX_SAFE_RESULTS"}
|
||||
|
||||
try:
|
||||
if key in admin_only_keys and not force:
|
||||
result["status"] = "restricted"
|
||||
return result
|
||||
|
||||
if key == "POESESSID":
|
||||
self.settings[key] = value
|
||||
self.COOKIES = {"POESESSID": value}
|
||||
await self._validate_session()
|
||||
result["session"] = value
|
||||
result["status"] = "ok" if self.session_valid else "invalid"
|
||||
else:
|
||||
self.settings[key] = type(self.settings.get(key, value))(value)
|
||||
result["status"] = "ok"
|
||||
|
||||
self._save_state()
|
||||
except Exception as e:
|
||||
result["status"] = "error"
|
||||
self.logger.error(f"Failed to update setting {key}: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def get_settings(self):
|
||||
"""
|
||||
Returns the current settings.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
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 _template(self):
|
||||
return {
|
||||
"status": None,
|
||||
"user": None,
|
||||
"filter_id": None,
|
||||
"result_count": None,
|
||||
"summary": None,
|
||||
"results": None,
|
||||
"session": None,
|
||||
"input": None,
|
||||
"user_count": None,
|
||||
"query_time": None,
|
||||
"next_allowed_time": None,
|
||||
"settings": None
|
||||
}
|
||||
|
||||
|
||||
def _load_state(self):
|
||||
if self.storage_file.exists():
|
||||
try:
|
||||
with open(self.storage_file, "r") as f:
|
||||
data = json.load(f)
|
||||
self.watchlists = defaultdict(list, data.get("watchlists", {}))
|
||||
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", {}))
|
||||
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:
|
||||
self.logger.error(f"Failed to load state: {e}")
|
||||
|
||||
|
||||
def _save_state(self):
|
||||
try:
|
||||
data = {
|
||||
"watchlists": dict(self.watchlists),
|
||||
"filter_names": self.filter_names,
|
||||
"seen": self.last_seen_items,
|
||||
"settings": self.settings,
|
||||
"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.
|
||||
|
||||
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.
|
||||
"""
|
||||
result = self._template()
|
||||
result["user"] = user_id
|
||||
result["filter_id"] = filter_id
|
||||
now = time.time()
|
||||
|
||||
if not self.session_valid:
|
||||
result["status"] = "invalid_session"
|
||||
return result
|
||||
|
||||
if filter_id in self.watchlists[user_id]:
|
||||
result["status"] = "exists"
|
||||
return result
|
||||
|
||||
if len(self.watchlists[user_id]) >= self.settings["MAX_FILTERS_PER_USER"]:
|
||||
result["status"] = "limit_reached"
|
||||
return result
|
||||
|
||||
if now - self.last_add_time[user_id] < self.settings["USER_ADD_INTERVAL"]:
|
||||
result["status"] = "cooldown"
|
||||
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()
|
||||
return result
|
||||
|
||||
|
||||
def remove_filter(self, user_id: str, filter_id: str) -> dict:
|
||||
"""
|
||||
Removes a specific filter from a user's watchlist.
|
||||
|
||||
Args:
|
||||
user_id (str): Discord user ID.
|
||||
filter_id (str): Filter ID to remove.
|
||||
|
||||
Returns:
|
||||
dict: Result template with status and user info.
|
||||
"""
|
||||
result = self._template()
|
||||
result["user"] = user_id
|
||||
result["filter_id"] = filter_id
|
||||
|
||||
if filter_id in self.watchlists[user_id]:
|
||||
self.watchlists[user_id].remove(filter_id)
|
||||
result["status"] = "removed"
|
||||
else:
|
||||
result["status"] = "not_found"
|
||||
|
||||
self._save_state()
|
||||
return result
|
||||
|
||||
|
||||
def get_filters(self, user_id: str) -> dict:
|
||||
"""
|
||||
Returns all active filters for a user.
|
||||
|
||||
Args:
|
||||
user_id (str): Discord user ID.
|
||||
|
||||
Returns:
|
||||
dict: Result template with active filters list and status.
|
||||
"""
|
||||
result = self._template()
|
||||
result["user"] = user_id
|
||||
result["results"] = self.watchlists[user_id]
|
||||
result["status"] = "ok"
|
||||
return result
|
||||
|
||||
|
||||
async def query_all(self):
|
||||
if not self.session_valid:
|
||||
self.logger.warning("Skipping query: POESESSID invalid.")
|
||||
return
|
||||
|
||||
found_items = {}
|
||||
|
||||
async with aiohttp.ClientSession(headers=self.HEADERS, cookies=self.COOKIES) as session:
|
||||
|
||||
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", {})}
|
||||
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
if not result_ids:
|
||||
continue
|
||||
|
||||
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()
|
||||
|
||||
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 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 filtered:
|
||||
found_items[(user_id, filter_id)] = {
|
||||
"search_id": search_id,
|
||||
"items": filtered
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error while querying filter {filter_id}: {e}")
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
def cleanup_filters(self, max_age_seconds: int = 86400) -> dict:
|
||||
"""
|
||||
Cleans up filters that haven't had results in a specified time.
|
||||
|
||||
Args:
|
||||
max_age_seconds (int): Threshold of inactivity before filters are cleaned.
|
||||
|
||||
Returns:
|
||||
dict: Result with summary of removed filters.
|
||||
"""
|
||||
result = self._template()
|
||||
now = time.time()
|
||||
removed = {}
|
||||
|
||||
for key in list(self.last_seen_items.keys()):
|
||||
last_seen = max((now - self.last_add_time.get(key[0], now)), 0)
|
||||
if last_seen > max_age_seconds:
|
||||
user, filter_id = key
|
||||
if filter_id in self.watchlists[user]:
|
||||
self.watchlists[user].remove(filter_id)
|
||||
removed.setdefault(user, []).append(filter_id)
|
||||
del self.last_seen_items[key]
|
||||
|
||||
self._save_state()
|
||||
result["status"] = "ok"
|
||||
result["results"] = removed
|
||||
return result
|
||||
|
||||
|
||||
def get_filter_name(self, user_id: str, filter_id: str) -> str | None:
|
||||
return self.filter_names.get(user_id, {}).get(filter_id)
|
||||
|
||||
|
||||
def pause_user(self, user_id: str) -> bool:
|
||||
if user_id in self.paused_users:
|
||||
return False
|
||||
self.paused_users.add(user_id)
|
||||
self._save_state()
|
||||
return True
|
||||
|
||||
def resume_user(self, user_id: str) -> bool:
|
||||
if user_id not in self.paused_users:
|
||||
return False
|
||||
self.paused_users.remove(user_id)
|
||||
self._save_state()
|
||||
return True
|
||||
|
||||
def is_paused(self, user_id: str) -> bool:
|
||||
return user_id in self.paused_users
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import requests
|
||||
import time
|
||||
import json
|
||||
|
||||
# CONFIGURATION
|
||||
LEAGUE = "Standard" # PoE2 league name
|
||||
DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/1354003262709305364/afkTjeXcu1bfZXsQzFl-QqSb3R1MmQ4hdZhosR3vm4I__QVEyZ0jO9cqndUTQwb1mt5Z"
|
||||
SEARCH_INTERVAL = 300 # 5 minutes in seconds
|
||||
POESESSID = "e6f8684e56b4ceb489b10225222640f4" # Test session ID
|
||||
|
||||
# Memory to store previously seen listings
|
||||
last_seen_ids = set()
|
||||
|
||||
# Headers and cookies for authenticated requests
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||
}
|
||||
COOKIES = {
|
||||
"POESESSID": POESESSID
|
||||
}
|
||||
|
||||
# EXAMPLE WISHLIST FILTER for PoE2
|
||||
query = {
|
||||
"query": {
|
||||
"status": {"option": "online"},
|
||||
"name": "Taryn's Shiver",
|
||||
"type": "Gelid Staff",
|
||||
"stats": [
|
||||
{
|
||||
"type": "and",
|
||||
"filters": [
|
||||
{
|
||||
"id": "skill.freezing_shards",
|
||||
"value": {"min": 10},
|
||||
"disabled": False
|
||||
},
|
||||
{
|
||||
"id": "explicit.stat_3291658075",
|
||||
"value": {"min": 100},
|
||||
"disabled": False
|
||||
}
|
||||
],
|
||||
"disabled": False
|
||||
}
|
||||
],
|
||||
"filters": {
|
||||
"req_filters": {
|
||||
"filters": {
|
||||
"lvl": {
|
||||
"max": 40,
|
||||
"min": None
|
||||
}
|
||||
},
|
||||
"disabled": False
|
||||
},
|
||||
"trade_filters": {
|
||||
"filters": {
|
||||
"price": {
|
||||
"max": 1,
|
||||
"min": None,
|
||||
"option": None
|
||||
}
|
||||
},
|
||||
"disabled": True
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def search_items():
|
||||
global last_seen_ids
|
||||
|
||||
# Step 1: Submit search query
|
||||
response = requests.post(f"https://www.pathofexile.com/api/trade2/search/poe2/{LEAGUE}", json=query, headers=HEADERS, cookies=COOKIES)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
search_id = data.get("id")
|
||||
result_ids = data.get("result", [])[:10] # Limit to first 10 results
|
||||
|
||||
if not result_ids:
|
||||
print("No results found.")
|
||||
return
|
||||
|
||||
new_ids = [item_id for item_id in result_ids if item_id not in last_seen_ids]
|
||||
if not new_ids:
|
||||
print("No new items since last check.")
|
||||
return
|
||||
|
||||
# Step 2: Fetch item details
|
||||
joined_ids = ",".join(new_ids)
|
||||
fetch_url = f"https://www.pathofexile.com/api/trade2/fetch/{joined_ids}?query={search_id}"
|
||||
details = requests.get(fetch_url, headers=HEADERS, cookies=COOKIES).json()
|
||||
|
||||
for item in details.get("result", []):
|
||||
item_id = item.get("id")
|
||||
last_seen_ids.add(item_id)
|
||||
|
||||
item_info = item.get("item", {})
|
||||
listing = item.get("listing", {})
|
||||
|
||||
name = item_info.get("name", "")
|
||||
type_line = item_info.get("typeLine", "")
|
||||
price = listing.get("price", {}).get("amount", "?")
|
||||
currency = listing.get("price", {}).get("currency", "?")
|
||||
account = listing.get("account", {}).get("name", "")
|
||||
whisper = listing.get("whisper", "")
|
||||
icon = item_info.get("icon", "")
|
||||
item_lvl = item_info.get("ilvl", "?")
|
||||
required_lvl = item_info.get("requirements", [])
|
||||
|
||||
# Requirements formatting
|
||||
req_str = ""
|
||||
for r in required_lvl:
|
||||
if r.get("name") == "Level":
|
||||
req_str += f"Level {r.get('values')[0][0]}"
|
||||
else:
|
||||
req_str += f", {r.get('values')[0][0]} {r.get('name')}"
|
||||
|
||||
# Extract key stats for display (if available)
|
||||
stats = item_info.get("explicitMods", [])
|
||||
stat_text = "\n".join([f"{s}" for s in stats]) if stats else "No explicit stats."
|
||||
|
||||
# Construct listing URL
|
||||
listing_url = f"https://www.pathofexile.com/trade2/search/poe2/{LEAGUE}/{search_id}" + f"/item/{item_id}"
|
||||
|
||||
embed = {
|
||||
"embeds": [
|
||||
{
|
||||
"author": {
|
||||
"name": f"{name} — {type_line}"
|
||||
},
|
||||
"title": f"{price} {currency} • Listed by {account}",
|
||||
"url": listing_url,
|
||||
"description": f"**Item Level:** {item_lvl}\n**Requirements:** {req_str}\n\n{stat_text}\n\n**Whisper:**\n`{whisper}`",
|
||||
"thumbnail": {"url": icon},
|
||||
"color": 7506394,
|
||||
"footer": {"text": "Click the title to view full item details."}
|
||||
}
|
||||
]
|
||||
}
|
||||
send_to_discord_embed(embed)
|
||||
|
||||
|
||||
def send_to_discord_embed(embed_payload):
|
||||
response = requests.post(DISCORD_WEBHOOK_URL, json=embed_payload)
|
||||
if response.status_code == 204:
|
||||
print("Embed sent to Discord.")
|
||||
else:
|
||||
print(f"Failed to send embed: {response.status_code}\n{response.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
while True:
|
||||
print("Searching for wishlist items...")
|
||||
try:
|
||||
search_items()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
time.sleep(SEARCH_INTERVAL)
|
Loading…
Reference in New Issue