added portals, quest tracking, discord monitor etc etc
|
|
@ -37,6 +37,7 @@ This project provides:
|
|||
- Filter by character, equipment type, material, stats, and more
|
||||
- Sort by any column with live results
|
||||
- Track item properties including spells, armor level, damage ratings
|
||||
- **Discord Rare Monitor Bot**: Monitors rare discoveries and posts filtered notifications to Discord channels
|
||||
- **Sample Data Generator**: `generate_data.py` sends telemetry snapshots over WebSocket for testing.
|
||||
|
||||
## Requirements
|
||||
|
|
|
|||
35
db_async.py
|
|
@ -5,6 +5,7 @@ initialization function to set up TimescaleDB hypertable.
|
|||
"""
|
||||
import os
|
||||
import sqlalchemy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from databases import Database
|
||||
from sqlalchemy import MetaData, Table, Column, Integer, String, Float, DateTime, text
|
||||
from sqlalchemy import Index, BigInteger, JSON, Boolean, UniqueConstraint
|
||||
|
|
@ -126,6 +127,20 @@ character_inventories = Table(
|
|||
UniqueConstraint("character_name", "item_id", name="uq_char_item"),
|
||||
)
|
||||
|
||||
# Portal discoveries table for 24-hour live tracking
|
||||
portal_discoveries = Table(
|
||||
# Records player portal discoveries with 24-hour retention
|
||||
"portal_discoveries",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("character_name", String, nullable=False, index=True),
|
||||
Column("portal_name", String, nullable=False),
|
||||
Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
|
||||
Column("ns", Float, nullable=False), # North/South coordinate as float
|
||||
Column("ew", Float, nullable=False), # East/West coordinate as float
|
||||
Column("z", Float, nullable=False), # Elevation as float
|
||||
)
|
||||
|
||||
# Server health monitoring tables
|
||||
server_health_checks = Table(
|
||||
# Time-series data for server health checks
|
||||
|
|
@ -213,4 +228,22 @@ async def init_db_async():
|
|||
"SELECT add_compression_policy('telemetry_events', INTERVAL '1 day')"
|
||||
))
|
||||
except Exception as e:
|
||||
print(f"Warning: failed to set retention/compression policies: {e}")
|
||||
print(f"Warning: failed to set retention/compression policies: {e}")
|
||||
|
||||
async def cleanup_old_portals():
|
||||
"""Clean up portal discoveries older than 24 hours."""
|
||||
try:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
|
||||
# Delete old portal discoveries
|
||||
result = await database.execute(
|
||||
"DELETE FROM portal_discoveries WHERE timestamp < :cutoff_time",
|
||||
{"cutoff_time": cutoff_time}
|
||||
)
|
||||
|
||||
print(f"Cleaned up {result} portal discoveries older than 24 hours")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: failed to cleanup old portals: {e}")
|
||||
return 0
|
||||
27
discord-rare-monitor/Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Discord Rare Monitor Bot - Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY discord_rare_monitor.py .
|
||||
COPY test_websocket.py .
|
||||
COPY icon_mapping.py .
|
||||
|
||||
# Copy icons directory
|
||||
COPY icons/ ./icons/
|
||||
|
||||
# Default environment variables
|
||||
ENV DISCORD_RARE_BOT_TOKEN="" \
|
||||
DERETH_TRACKER_WS_URL="ws://dereth-tracker:8765/ws/position" \
|
||||
COMMON_RARE_CHANNEL_ID="1355328792184226014" \
|
||||
GREAT_RARE_CHANNEL_ID="1353676584334131211" \
|
||||
LOG_LEVEL="INFO"
|
||||
|
||||
# Run the bot
|
||||
CMD ["python", "discord_rare_monitor.py"]
|
||||
95
discord-rare-monitor/README.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Discord Rare Monitor Bot
|
||||
|
||||
A Discord bot that monitors the Dereth Tracker WebSocket stream for rare discoveries and posts filtered notifications to Discord channels.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Monitoring**: Connects to Dereth Tracker WebSocket for instant rare notifications
|
||||
- **Smart Classification**: Automatically classifies rares as "common" or "great" based on keywords
|
||||
- **Rich Embeds**: Posts formatted Discord embeds with location and timestamp information
|
||||
- **Dual Channels**: Posts to separate channels for common and great rares
|
||||
- **Robust Connection**: Automatic reconnection with exponential backoff on connection failures
|
||||
|
||||
## Rare Classification
|
||||
|
||||
### Common Rares
|
||||
Items containing these keywords (except "Frore Crystal"):
|
||||
- Crystal
|
||||
- Jewel
|
||||
- Pearl
|
||||
- Elixir
|
||||
- Kit
|
||||
|
||||
### Great Rares
|
||||
All other rare discoveries not classified as common.
|
||||
|
||||
## Configuration
|
||||
|
||||
The bot is configured via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DISCORD_RARE_BOT_TOKEN` | Required | Discord bot token |
|
||||
| `DERETH_TRACKER_WS_URL` | `ws://dereth-tracker:8765/ws/position` | WebSocket URL |
|
||||
| `COMMON_RARE_CHANNEL_ID` | `1355328792184226014` | Discord channel for common rares |
|
||||
| `GREAT_RARE_CHANNEL_ID` | `1353676584334131211` | Discord channel for great rares |
|
||||
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
|
||||
## Docker Usage
|
||||
|
||||
The bot is designed to run as a Docker container alongside the Dereth Tracker services:
|
||||
|
||||
```bash
|
||||
# Build and start all services including the Discord bot
|
||||
docker-compose up -d
|
||||
|
||||
# View bot logs
|
||||
docker-compose logs discord-rare-monitor
|
||||
|
||||
# Restart just the bot
|
||||
docker-compose restart discord-rare-monitor
|
||||
```
|
||||
|
||||
## Manual Setup
|
||||
|
||||
1. Create a Discord application and bot at https://discord.com/developers/applications
|
||||
2. Get the bot token and invite the bot to your Discord server
|
||||
3. Set the `DISCORD_RARE_BOT_TOKEN` environment variable
|
||||
4. Ensure the bot has permissions to send messages in the target channels
|
||||
|
||||
## Message Format
|
||||
|
||||
The bot listens for WebSocket messages with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "rare",
|
||||
"character_name": "PlayerName",
|
||||
"name": "Dark Heart",
|
||||
"timestamp": "2025-06-22T16:00:00Z",
|
||||
"ew": 12.34,
|
||||
"ns": -56.78,
|
||||
"z": 10.5
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **WebSocket Client**: Connects to Dereth Tracker's WebSocket stream
|
||||
- **Message Filter**: Only processes `{"type": "rare"}` messages
|
||||
- **Classifier**: Determines rare type based on name keywords
|
||||
- **Discord Client**: Posts formatted embeds to appropriate channels
|
||||
- **Retry Logic**: Automatic reconnection with exponential backoff
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `discord.py>=2.3.0` - Discord API client
|
||||
- `websockets>=11.0.0` - WebSocket client library
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Zero Duplication**: Each rare generates exactly one notification
|
||||
- **Real-time**: Instant notifications via WebSocket stream
|
||||
- **Lightweight**: Minimal resource usage (~50MB RAM)
|
||||
- **Reliable**: Robust error handling and reconnection logic
|
||||
- **Integrated**: Seamlessly works with existing Dereth Tracker infrastructure
|
||||
67
discord-rare-monitor/config.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Configuration module for Discord Rare Monitor Bot.
|
||||
Centralizes environment variable handling and configuration constants.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration class for Discord Rare Monitor Bot."""
|
||||
|
||||
# Discord Configuration
|
||||
DISCORD_TOKEN: str = os.getenv('DISCORD_RARE_BOT_TOKEN', '')
|
||||
COMMON_RARE_CHANNEL_ID: int = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014'))
|
||||
GREAT_RARE_CHANNEL_ID: int = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211'))
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_URL: str = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/position')
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL: str = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
|
||||
# Rare Classification Configuration
|
||||
COMMON_RARE_KEYWORDS: list = ["Crystal", "Jewel", "Pearl", "Elixir", "Kit"]
|
||||
|
||||
# WebSocket Retry Configuration
|
||||
INITIAL_RETRY_DELAY: int = 5 # seconds
|
||||
MAX_RETRY_DELAY: int = 300 # 5 minutes
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> list:
|
||||
"""Validate configuration and return list of errors."""
|
||||
errors = []
|
||||
|
||||
if not cls.DISCORD_TOKEN:
|
||||
errors.append("DISCORD_RARE_BOT_TOKEN environment variable is required")
|
||||
|
||||
if not cls.WEBSOCKET_URL:
|
||||
errors.append("DERETH_TRACKER_WS_URL environment variable is required")
|
||||
|
||||
try:
|
||||
cls.COMMON_RARE_CHANNEL_ID = int(cls.COMMON_RARE_CHANNEL_ID)
|
||||
except (ValueError, TypeError):
|
||||
errors.append("COMMON_RARE_CHANNEL_ID must be a valid integer")
|
||||
|
||||
try:
|
||||
cls.GREAT_RARE_CHANNEL_ID = int(cls.GREAT_RARE_CHANNEL_ID)
|
||||
except (ValueError, TypeError):
|
||||
errors.append("GREAT_RARE_CHANNEL_ID must be a valid integer")
|
||||
|
||||
return errors
|
||||
|
||||
@classmethod
|
||||
def log_config(cls, logger):
|
||||
"""Log current configuration (excluding sensitive data)."""
|
||||
logger.info("🔧 Discord Rare Monitor Configuration:")
|
||||
logger.info(f" WebSocket URL: {cls.WEBSOCKET_URL}")
|
||||
logger.info(f" Common Rare Channel ID: {cls.COMMON_RARE_CHANNEL_ID}")
|
||||
logger.info(f" Great Rare Channel ID: {cls.GREAT_RARE_CHANNEL_ID}")
|
||||
logger.info(f" Log Level: {cls.LOG_LEVEL}")
|
||||
logger.info(f" Common Keywords: {cls.COMMON_RARE_KEYWORDS}")
|
||||
logger.info(f" Discord Token: {'✅ Set' if cls.DISCORD_TOKEN else '❌ Not Set'}")
|
||||
|
||||
|
||||
# Global config instance
|
||||
config = Config()
|
||||
880
discord-rare-monitor/discord_rare_monitor.py
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Discord Rare Monitor Bot - Monitors Dereth Tracker WebSocket for rare discoveries
|
||||
and posts filtered notifications to Discord channels.
|
||||
|
||||
Listens for {"type": "rare"} messages from the WebSocket stream and posts them
|
||||
to appropriate Discord channels based on rare classification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import discord
|
||||
import websockets
|
||||
|
||||
# Get log level from environment
|
||||
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level, logging.INFO),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stdout)]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration from environment variables
|
||||
DISCORD_TOKEN = os.getenv('DISCORD_RARE_BOT_TOKEN')
|
||||
WEBSOCKET_URL = os.getenv('DERETH_TRACKER_WS_URL', 'ws://dereth-tracker:8765/ws/live')
|
||||
SHARED_SECRET = 'your_shared_secret'
|
||||
ACLOG_CHANNEL_ID = int(os.getenv('ACLOG_CHANNEL_ID', '1349649482786275328'))
|
||||
COMMON_RARE_CHANNEL_ID = int(os.getenv('COMMON_RARE_CHANNEL_ID', '1355328792184226014'))
|
||||
GREAT_RARE_CHANNEL_ID = int(os.getenv('GREAT_RARE_CHANNEL_ID', '1353676584334131211'))
|
||||
|
||||
# Character to monitor for allegiance chat
|
||||
MONITOR_CHARACTER = os.getenv('MONITOR_CHARACTER', 'Dunking Rares')
|
||||
|
||||
# Comprehensive rare classification patterns
|
||||
|
||||
# Common Rares - Exact match pattern (70 items)
|
||||
COMMON_RARES_PATTERN = re.compile(r"^(Alchemist's Crystal|Scholar's Crystal|Smithy's Crystal|Hunter's Crystal|Observer's Crystal|Thorsten's Crystal|Elysa's Crystal|Chef's Crystal|Enchanter's Crystal|Oswald's Crystal|Deceiver's Crystal|Fletcher's Crystal|Physician's Crystal|Artificer's Crystal|Tinker's Crystal|Vaulter's Crystal|Monarch's Crystal|Life Giver's Crystal|Thief's Crystal|Adherent's Crystal|Resister's Crystal|Imbuer's Crystal|Converter's Crystal|Evader's Crystal|Dodger's Crystal|Zefir's Crystal|Ben Ten's Crystal|Corruptor's Crystal|Artist's Crystal|T'ing's Crystal|Warrior's Crystal|Brawler's Crystal|Hieromancer's Crystal|Rogue's Crystal|Berzerker's Crystal|Lugian's Pearl|Ursuin's Pearl|Wayfarer's Pearl|Sprinter's Pearl|Magus's Pearl|Lich's Pearl|Warrior's Jewel|Melee's Jewel|Mage's Jewel|Duelist's Jewel|Archer's Jewel|Tusker's Jewel|Olthoi's Jewel|Inferno's Jewel|Gelid's Jewel|Astyrrian's Jewel|Executor's Jewel|Pearl of Blood Drinking|Pearl of Heart Seeking|Pearl of Defending|Pearl of Swift Killing|Pearl of Spirit Drinking|Pearl of Hermetic Linking|Pearl of Blade Baning|Pearl of Pierce Baning|Pearl of Bludgeon Baning|Pearl of Acid Baning|Pearl of Flame Baning|Pearl of Frost Baning|Pearl of Lightning Baning|Pearl of Impenetrability|Refreshing Elixir|Invigorating Elixir|Miraculous Elixir|Medicated Health Kit|Medicated Stamina Kit|Medicated Mana Kit|Casino Exquisite Keyring)$")
|
||||
|
||||
# Combined pattern for detecting any rare in chat messages (simplified for common detection)
|
||||
RARE_IN_CHAT_PATTERN = re.compile(r"(Crystal|Pearl|Jewel|Elixir|Kit|Hieroglyph|Pictograph|Ideograph|Rune|Infinite|Eternal|Perennial|Foolproof|Limitless|Shimmering|Gelidite|Leikotha|Frore|Staff of|Count Renari|Wand of)")
|
||||
|
||||
# Legacy keywords for backward compatibility
|
||||
COMMON_RARE_KEYWORDS = ["Crystal", "Jewel", "Pearl", "Elixir", "Kit"]
|
||||
|
||||
|
||||
class DiscordRareMonitor:
|
||||
"""Discord bot that monitors WebSocket for rare discoveries and posts to Discord."""
|
||||
|
||||
def __init__(self):
|
||||
# Discord client setup
|
||||
intents = discord.Intents.default()
|
||||
intents.guilds = True
|
||||
intents.messages = True
|
||||
intents.message_content = True
|
||||
self.client = discord.Client(intents=intents)
|
||||
|
||||
# WebSocket connection tracking
|
||||
self.websocket_task: Optional[asyncio.Task] = None
|
||||
self.running = False
|
||||
|
||||
# Setup Discord event handlers
|
||||
self.setup_discord_handlers()
|
||||
|
||||
def setup_discord_handlers(self):
|
||||
"""Setup Discord client event handlers."""
|
||||
|
||||
@self.client.event
|
||||
async def on_ready():
|
||||
logger.info(f'✅ Discord bot logged in as {self.client.user}')
|
||||
|
||||
# Debug: List all guilds (servers) the bot is in
|
||||
guild_count = len(self.client.guilds)
|
||||
logger.info(f"🏰 Bot is in {guild_count} guild(s)")
|
||||
for guild in self.client.guilds:
|
||||
logger.info(f" - {guild.name} (ID: {guild.id})")
|
||||
# List first 5 channels in each guild for debugging
|
||||
channels = list(guild.channels)[:5]
|
||||
for channel in channels:
|
||||
logger.info(f" Channel: #{channel.name} (ID: {channel.id})")
|
||||
|
||||
# Verify channels exist
|
||||
aclog_channel = self.client.get_channel(ACLOG_CHANNEL_ID)
|
||||
common_channel = self.client.get_channel(COMMON_RARE_CHANNEL_ID)
|
||||
great_channel = self.client.get_channel(GREAT_RARE_CHANNEL_ID)
|
||||
|
||||
logger.info(f"🔍 Looking for aclog channel {ACLOG_CHANNEL_ID}: {'Found' if aclog_channel else 'NOT FOUND'}")
|
||||
logger.info(f"🔍 Looking for common channel {COMMON_RARE_CHANNEL_ID}: {'Found' if common_channel else 'NOT FOUND'}")
|
||||
logger.info(f"🔍 Looking for great channel {GREAT_RARE_CHANNEL_ID}: {'Found' if great_channel else 'NOT FOUND'}")
|
||||
|
||||
if aclog_channel:
|
||||
logger.info(f"📍 AC Log channel: #{aclog_channel.name}")
|
||||
if common_channel:
|
||||
logger.info(f"📍 Common rares channel: #{common_channel.name}")
|
||||
if great_channel:
|
||||
logger.info(f"📍 Great rares channel: #{great_channel.name}")
|
||||
|
||||
logger.info("🎯 Bot ready to receive messages!")
|
||||
|
||||
# Start WebSocket monitoring
|
||||
self.running = True
|
||||
self.websocket_task = asyncio.create_task(self.monitor_websocket())
|
||||
logger.info("🔄 Started WebSocket monitoring task")
|
||||
|
||||
@self.client.event
|
||||
async def on_disconnect():
|
||||
logger.warning("⚠️ Discord client disconnected")
|
||||
self.running = False
|
||||
if self.websocket_task:
|
||||
self.websocket_task.cancel()
|
||||
|
||||
@self.client.event
|
||||
async def on_message(message):
|
||||
# Don't respond to bot's own messages
|
||||
if message.author == self.client.user:
|
||||
return
|
||||
|
||||
# Debug: log all messages (remove this after testing)
|
||||
logger.info(f"📨 Received message: '{message.content}' from {message.author} in #{message.channel}")
|
||||
|
||||
# Handle !echo command for testing bot responsiveness
|
||||
content = message.content # Don't convert to lowercase for echo
|
||||
if content.startswith('!echo'):
|
||||
# Extract the text after !echo
|
||||
echo_text = message.content[5:].strip()
|
||||
if echo_text:
|
||||
await message.channel.send(f"🤖 Echo: {echo_text}")
|
||||
else:
|
||||
await message.channel.send("🤖 Echo: Hello! Discord Rare Monitor Bot is alive and responding!")
|
||||
logger.info(f"📣 Responded to !echo command from {message.author} in #{message.channel}")
|
||||
|
||||
# Handle !status command for bot status
|
||||
elif message.content.startswith('!status'):
|
||||
status_msg = "🤖 **Discord Rare Monitor Bot Status:**\n"
|
||||
status_msg += f"✅ Bot is online and connected\n"
|
||||
status_msg += f"🔗 WebSocket: {'Connected' if self.websocket_task and not self.websocket_task.done() else 'Disconnected'}\n"
|
||||
status_msg += f"📡 Monitoring: {WEBSOCKET_URL}\n"
|
||||
status_msg += f"📍 Common Rares Channel: <#{COMMON_RARE_CHANNEL_ID}>\n"
|
||||
status_msg += f"📍 Great Rares Channel: <#{GREAT_RARE_CHANNEL_ID}>"
|
||||
|
||||
await message.channel.send(status_msg)
|
||||
logger.info(f"📊 Responded to !status command from {message.author} in #{message.channel}")
|
||||
|
||||
# Handle !icons command to display all rare icons
|
||||
elif message.content.startswith('!icons'):
|
||||
# Parse command arguments
|
||||
args = message.content.split()
|
||||
logger.info(f"🔍 !icons command received with args: {args}")
|
||||
if len(args) > 1:
|
||||
if args[1].lower() == 'all':
|
||||
logger.info("📋 Calling handle_icons_command for 'all'")
|
||||
await self.handle_icons_command(message)
|
||||
elif args[1].lower() == 'grid':
|
||||
await self.handle_icons_grid(message)
|
||||
else:
|
||||
# Search for specific rare item
|
||||
search_term = ' '.join(args[1:])
|
||||
await self.handle_icons_search(message, search_term)
|
||||
else:
|
||||
logger.info("📋 Calling handle_icons_summary (no args)")
|
||||
await self.handle_icons_summary(message)
|
||||
|
||||
async def monitor_websocket(self):
|
||||
"""Monitor Dereth Tracker WebSocket for rare events with robust reconnection."""
|
||||
retry_delay = 5 # seconds
|
||||
max_retry_delay = 300 # 5 minutes
|
||||
consecutive_failures = 0
|
||||
max_consecutive_failures = 10
|
||||
|
||||
while self.running:
|
||||
websocket = None
|
||||
last_message_time = time.time()
|
||||
health_check_interval = 60 # Check health every 60 seconds
|
||||
message_timeout = 180 # Consider connection dead if no messages for 3 minutes
|
||||
|
||||
try:
|
||||
# Connect to live endpoint (no authentication needed for browsers)
|
||||
logger.info(f"🔗 Connecting to WebSocket: {WEBSOCKET_URL}")
|
||||
|
||||
# Add connection timeout and ping interval for better connection health
|
||||
websocket = await websockets.connect(
|
||||
WEBSOCKET_URL,
|
||||
ping_interval=30, # Send ping every 30 seconds
|
||||
ping_timeout=10, # Wait 10 seconds for pong
|
||||
close_timeout=10 # Wait 10 seconds for close
|
||||
)
|
||||
|
||||
logger.info("✅ WebSocket connected successfully")
|
||||
retry_delay = 5 # Reset retry delay on successful connection
|
||||
consecutive_failures = 0 # Reset failure counter
|
||||
last_message_time = time.time() # Reset message timer
|
||||
|
||||
# Send a test message to Discord to indicate connection restored
|
||||
await self.post_status_to_aclog("🔗 WebSocket connection established")
|
||||
|
||||
# Create tasks for message processing and health checking
|
||||
async def process_messages():
|
||||
nonlocal last_message_time
|
||||
async for message in websocket:
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
last_message_time = time.time()
|
||||
logger.debug(f"📨 Raw WebSocket message: {message[:100]}...")
|
||||
await self.process_websocket_message(message)
|
||||
|
||||
async def health_check():
|
||||
"""Periodically check connection health and force reconnect if needed."""
|
||||
while self.running and websocket and not websocket.closed:
|
||||
await asyncio.sleep(health_check_interval)
|
||||
|
||||
current_time = time.time()
|
||||
time_since_last_message = current_time - last_message_time
|
||||
|
||||
if time_since_last_message > message_timeout:
|
||||
logger.warning(f"⚠️ No messages received for {time_since_last_message:.0f} seconds, forcing reconnect")
|
||||
await self.post_status_to_aclog(f"⚠️ WebSocket appears dead, reconnecting...")
|
||||
if websocket and not websocket.closed:
|
||||
await websocket.close()
|
||||
break
|
||||
else:
|
||||
logger.debug(f"💓 WebSocket health check passed (last message {time_since_last_message:.0f}s ago)")
|
||||
|
||||
# Run both tasks concurrently
|
||||
message_task = asyncio.create_task(process_messages())
|
||||
health_task = asyncio.create_task(health_check())
|
||||
|
||||
# Wait for either task to complete (health check failure or message loop exit)
|
||||
done, pending = await asyncio.wait(
|
||||
[message_task, health_task],
|
||||
return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
# Cancel any remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.warning(f"⚠️ WebSocket connection closed: {e}")
|
||||
consecutive_failures += 1
|
||||
await self.post_status_to_aclog(f"⚠️ WebSocket disconnected (attempt {consecutive_failures})")
|
||||
|
||||
except websockets.exceptions.InvalidStatusCode as e:
|
||||
logger.error(f"❌ WebSocket invalid status code: {e}")
|
||||
consecutive_failures += 1
|
||||
await self.post_status_to_aclog(f"❌ WebSocket connection failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ WebSocket error: {e}")
|
||||
consecutive_failures += 1
|
||||
await self.post_status_to_aclog(f"❌ WebSocket error: {e}")
|
||||
|
||||
finally:
|
||||
# Ensure websocket is properly closed
|
||||
if websocket and not websocket.closed:
|
||||
try:
|
||||
await websocket.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if we should keep retrying
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error(f"❌ Too many consecutive failures ({consecutive_failures}). Stopping reconnection attempts.")
|
||||
await self.post_status_to_aclog(f"❌ WebSocket reconnection failed after {consecutive_failures} attempts. Bot may need restart.")
|
||||
break
|
||||
|
||||
if self.running:
|
||||
logger.info(f"🔄 Retrying WebSocket connection in {retry_delay} seconds... (failure #{consecutive_failures})")
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
# Exponential backoff with max delay
|
||||
retry_delay = min(retry_delay * 2, max_retry_delay)
|
||||
|
||||
async def process_websocket_message(self, raw_message: str):
|
||||
"""Process incoming WebSocket message."""
|
||||
try:
|
||||
data = json.loads(raw_message)
|
||||
msg_type = data.get('type')
|
||||
|
||||
# Debug: Log all message types
|
||||
logger.debug(f"📩 WebSocket message type: {msg_type} from {data.get('character_name', 'Unknown')}")
|
||||
|
||||
# Handle rare event messages
|
||||
if msg_type == 'rare':
|
||||
await self.handle_rare_event(data)
|
||||
|
||||
# Handle chat messages from monitored character
|
||||
elif msg_type == 'chat':
|
||||
await self.handle_chat_message(data)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Ignore invalid JSON messages
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error processing WebSocket message: {e}")
|
||||
|
||||
async def handle_rare_event(self, data: dict):
|
||||
"""Handle a rare discovery event."""
|
||||
try:
|
||||
rare_name = data.get('name', 'Unknown Rare')
|
||||
character_name = data.get('character_name', 'Unknown Character')
|
||||
timestamp = data.get('timestamp', '')
|
||||
|
||||
logger.info(f"🎯 RARE EVENT RECEIVED: {rare_name} by {character_name}")
|
||||
logger.debug(f"📦 Full rare data: {data}")
|
||||
|
||||
# Classify rare type
|
||||
rare_type = self.classify_rare(rare_name)
|
||||
|
||||
# Post to Discord
|
||||
await self.post_rare_to_discord(data, rare_type)
|
||||
|
||||
# DEBUG: Also post rare info to aclog for monitoring classification
|
||||
await self.post_rare_debug_to_aclog(rare_name, rare_type, character_name)
|
||||
|
||||
logger.info(f"📬 Posted to Discord: {rare_name} ({rare_type}) from {character_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling rare event: {e}")
|
||||
|
||||
async def handle_chat_message(self, data: dict):
|
||||
"""Handle a chat message from monitored character."""
|
||||
try:
|
||||
character_name = data.get('character_name', '')
|
||||
chat_text = data.get('text', '')
|
||||
|
||||
logger.debug(f"🔍 Checking chat from '{character_name}' (looking for '{MONITOR_CHARACTER}')")
|
||||
|
||||
# Only process messages from the monitored character
|
||||
if character_name != MONITOR_CHARACTER:
|
||||
return
|
||||
|
||||
# Skip if this message contains any rare names (common or great)
|
||||
if RARE_IN_CHAT_PATTERN.search(chat_text) or self.is_rare_message(chat_text):
|
||||
logger.debug(f"🎯 Skipping rare message from {character_name}: {chat_text}")
|
||||
return
|
||||
|
||||
logger.info(f"💬 Chat from {character_name}: {chat_text}")
|
||||
|
||||
# Post to AC Log channel
|
||||
await self.post_chat_to_discord(data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling chat message: {e}")
|
||||
|
||||
def is_rare_message(self, text: str) -> bool:
|
||||
"""Check if text appears to be a rare discovery message."""
|
||||
# Look for common rare discovery patterns
|
||||
rare_patterns = [
|
||||
"has discovered the",
|
||||
"found a rare",
|
||||
"discovered a",
|
||||
"rare discovery",
|
||||
"Golden Gryphon",
|
||||
"Dark Heart",
|
||||
"Sunstone",
|
||||
]
|
||||
return any(pattern.lower() in text.lower() for pattern in rare_patterns)
|
||||
|
||||
def classify_rare(self, rare_name: str) -> str:
|
||||
"""Classify rare as 'common' or 'great' based on exact name matching."""
|
||||
# Use regex pattern matching for precise classification
|
||||
if COMMON_RARES_PATTERN.match(rare_name):
|
||||
return "common"
|
||||
else:
|
||||
return "great"
|
||||
|
||||
def get_rare_icon_path(self, rare_name: str) -> Optional[str]:
|
||||
"""Get the file path for a rare item's icon if it exists."""
|
||||
try:
|
||||
# Convert rare name to icon filename using same logic as download script
|
||||
filename = rare_name.replace("'", "").replace(" ", "_").replace("-", "_") + "_Icon.png"
|
||||
|
||||
# Get the icons directory path
|
||||
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
|
||||
icon_path = os.path.join(icons_dir, filename)
|
||||
|
||||
# Check if file exists
|
||||
if os.path.exists(icon_path):
|
||||
return icon_path
|
||||
else:
|
||||
logger.debug(f"🔍 Icon not found for '{rare_name}': {filename}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error getting icon path for '{rare_name}': {e}")
|
||||
return None
|
||||
|
||||
async def post_rare_to_discord(self, data: dict, rare_type: str):
|
||||
"""Post rare discovery to appropriate Discord channel."""
|
||||
try:
|
||||
rare_name = data.get('name', 'Unknown Rare')
|
||||
character_name = data.get('character_name', 'Unknown Character')
|
||||
timestamp_str = data.get('timestamp', '')
|
||||
ew = data.get('ew')
|
||||
ns = data.get('ns')
|
||||
z = data.get('z')
|
||||
|
||||
# Parse timestamp
|
||||
try:
|
||||
if timestamp_str:
|
||||
# Handle both with and without 'Z' suffix
|
||||
if timestamp_str.endswith('Z'):
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
else:
|
||||
timestamp = datetime.now()
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Try to get icon for this rare
|
||||
icon_path = self.get_rare_icon_path(rare_name)
|
||||
icon_file = None
|
||||
icon_filename = None
|
||||
|
||||
if icon_path:
|
||||
icon_filename = os.path.basename(icon_path)
|
||||
icon_file = discord.File(icon_path, filename=icon_filename)
|
||||
logger.debug(f"🖼️ Found icon for '{rare_name}': {icon_filename}")
|
||||
else:
|
||||
logger.debug(f"📷 No icon available for '{rare_name}'")
|
||||
|
||||
# Create Discord embed
|
||||
if rare_type == "great":
|
||||
embed = discord.Embed(
|
||||
title="💎 Great Rare Discovery!",
|
||||
description=f"**{character_name}** has discovered the **{rare_name}**!",
|
||||
color=discord.Color.gold(),
|
||||
timestamp=timestamp
|
||||
)
|
||||
else:
|
||||
embed = discord.Embed(
|
||||
title="🔸 Common Rare Discovery",
|
||||
description=f"**{character_name}** has discovered the **{rare_name}**!",
|
||||
color=discord.Color.blue(),
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# Add icon image if available (at original size)
|
||||
if icon_file and icon_filename:
|
||||
embed.set_image(url=f"attachment://{icon_filename}")
|
||||
|
||||
# Add location if available
|
||||
if ew is not None and ns is not None:
|
||||
location_str = f"{ew:.1f}E, {ns:.1f}N"
|
||||
if z is not None:
|
||||
location_str += f", {z:.1f}Z"
|
||||
embed.add_field(name="📍 Location", value=location_str, inline=True)
|
||||
|
||||
# Add timestamp in a readable format
|
||||
embed.add_field(
|
||||
name="⏰ Time",
|
||||
value=timestamp.strftime("%H:%M:%S UTC"),
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Get appropriate channel
|
||||
if rare_type == "common":
|
||||
channel = self.client.get_channel(COMMON_RARE_CHANNEL_ID)
|
||||
else:
|
||||
channel = self.client.get_channel(GREAT_RARE_CHANNEL_ID)
|
||||
|
||||
if channel:
|
||||
# Send with or without icon file
|
||||
if icon_file:
|
||||
await channel.send(file=icon_file, embed=embed)
|
||||
logger.debug(f"📤 Sent embed with icon to #{channel.name}")
|
||||
else:
|
||||
await channel.send(embed=embed)
|
||||
logger.debug(f"📤 Sent text-only embed to #{channel.name}")
|
||||
else:
|
||||
logger.error(f"❌ Could not find Discord channel for {rare_type} rares")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error posting to Discord: {e}")
|
||||
|
||||
async def post_chat_to_discord(self, data: dict):
|
||||
"""Post chat message to AC Log Discord channel."""
|
||||
try:
|
||||
character_name = data.get('character_name', 'Unknown Character')
|
||||
chat_text = data.get('text', '')
|
||||
timestamp_str = data.get('timestamp', '')
|
||||
|
||||
# Parse timestamp
|
||||
try:
|
||||
if timestamp_str:
|
||||
if timestamp_str.endswith('Z'):
|
||||
timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
timestamp = datetime.fromisoformat(timestamp_str)
|
||||
else:
|
||||
timestamp = datetime.now()
|
||||
except ValueError:
|
||||
timestamp = datetime.now()
|
||||
|
||||
# Create simple message format similar to your old bot
|
||||
time_str = timestamp.strftime("%H:%M:%S")
|
||||
message_content = f"`{time_str}` **{character_name}**: {chat_text}"
|
||||
|
||||
# Get AC Log channel
|
||||
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
|
||||
|
||||
if channel:
|
||||
await channel.send(message_content)
|
||||
logger.debug(f"📤 Posted chat to #{channel.name}: {character_name}")
|
||||
else:
|
||||
logger.error(f"❌ Could not find AC Log channel ID: {ACLOG_CHANNEL_ID}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error posting chat to Discord: {e}")
|
||||
|
||||
async def post_rare_debug_to_aclog(self, rare_name: str, rare_type: str, character_name: str):
|
||||
"""Post rare classification debug info to AC Log channel."""
|
||||
try:
|
||||
# Create debug message showing classification
|
||||
debug_message = f"🔍 **RARE DEBUG**: `{rare_name}` → **{rare_type.upper()}** (found by {character_name})"
|
||||
|
||||
# Get AC Log channel
|
||||
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
|
||||
|
||||
if channel:
|
||||
await channel.send(debug_message)
|
||||
logger.debug(f"📤 Posted rare debug to #{channel.name}: {rare_name} -> {rare_type}")
|
||||
else:
|
||||
logger.error(f"❌ Could not find AC Log channel for debug: {ACLOG_CHANNEL_ID}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error posting rare debug to Discord: {e}")
|
||||
|
||||
async def post_status_to_aclog(self, status_message: str):
|
||||
"""Post status update to AC Log channel."""
|
||||
try:
|
||||
# Create status message with timestamp
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
message = f"`{timestamp}` **BOT STATUS**: {status_message}"
|
||||
|
||||
# Get AC Log channel
|
||||
channel = self.client.get_channel(ACLOG_CHANNEL_ID)
|
||||
|
||||
if channel:
|
||||
await channel.send(message)
|
||||
logger.debug(f"📤 Posted status to #{channel.name}: {status_message}")
|
||||
else:
|
||||
logger.error(f"❌ Could not find AC Log channel for status: {ACLOG_CHANNEL_ID}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error posting status to Discord: {e}")
|
||||
|
||||
async def handle_icons_command(self, message):
|
||||
"""Handle !icons all command to display 10 random rare icons with images."""
|
||||
try:
|
||||
logger.info("🎯 STARTING handle_icons_command - will send 10 separate messages")
|
||||
import random
|
||||
import asyncio
|
||||
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
|
||||
|
||||
# Get all icon files
|
||||
all_icons = [f for f in os.listdir(icons_dir) if f.endswith("_Icon.png")]
|
||||
logger.info(f"📂 Found {len(all_icons)} total icons")
|
||||
|
||||
# Select 10 random icons
|
||||
random_icons = random.sample(all_icons, min(10, len(all_icons)))
|
||||
logger.info(f"🎲 Selected {len(random_icons)} random icons to display")
|
||||
|
||||
# Send each icon individually - no embeds, just file + text
|
||||
for i, filename in enumerate(random_icons):
|
||||
# Convert filename to display name
|
||||
base_name = filename[:-9]
|
||||
display_name = base_name.replace("_", " ")
|
||||
display_name = display_name.replace("s Crystal", "'s Crystal")
|
||||
display_name = display_name.replace("s Pearl", "'s Pearl")
|
||||
display_name = display_name.replace("s Jewel", "'s Jewel")
|
||||
|
||||
# Classify rare
|
||||
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
|
||||
emoji = "🔸" if rare_type == "common" else "💎"
|
||||
|
||||
# Send just the file with simple text
|
||||
icon_path = os.path.join(icons_dir, filename)
|
||||
if os.path.exists(icon_path):
|
||||
file = discord.File(icon_path, filename=filename)
|
||||
text = f"{emoji} **{display_name}** ({rare_type} rare)"
|
||||
|
||||
logger.info(f"📤 Sending message {i+1}/10: {display_name}")
|
||||
await message.channel.send(content=text, file=file)
|
||||
|
||||
# Small delay between messages to avoid rate limiting
|
||||
await asyncio.sleep(0.8)
|
||||
|
||||
# Send summary message
|
||||
await message.channel.send(f"📚 Displayed 10 random icons from {len(all_icons)} total rare icons. Use `!icons [name]` to search.")
|
||||
|
||||
logger.info(f"📚 Sent 10 random icon samples to {message.author} in #{message.channel}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling !icons command: {e}")
|
||||
await message.channel.send(f"❌ Error displaying icons: {str(e)}")
|
||||
|
||||
async def handle_icons_summary(self, message):
|
||||
"""Handle !icons command to display summary with example images."""
|
||||
try:
|
||||
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
|
||||
|
||||
# Count icons by category and get examples
|
||||
common_count = 0
|
||||
great_count = 0
|
||||
icon_files = []
|
||||
common_examples = []
|
||||
great_examples = []
|
||||
|
||||
for filename in os.listdir(icons_dir):
|
||||
if filename.endswith("_Icon.png"):
|
||||
base_name = filename[:-9]
|
||||
display_name = base_name.replace("_", " ")
|
||||
# Fix common patterns
|
||||
display_name = display_name.replace("s Crystal", "'s Crystal")
|
||||
display_name = display_name.replace("s Pearl", "'s Pearl")
|
||||
display_name = display_name.replace("s Jewel", "'s Jewel")
|
||||
|
||||
icon_files.append((filename, display_name))
|
||||
|
||||
# Classify and collect examples
|
||||
if self.classify_rare(display_name) == "common":
|
||||
common_count += 1
|
||||
if len(common_examples) < 3:
|
||||
common_examples.append((filename, display_name))
|
||||
else:
|
||||
great_count += 1
|
||||
if len(great_examples) < 3:
|
||||
great_examples.append((filename, display_name))
|
||||
|
||||
total_count = len(icon_files)
|
||||
|
||||
# Create summary embed with thumbnail
|
||||
embed = discord.Embed(
|
||||
title="📚 Rare Icons Library Summary",
|
||||
description=f"Complete collection of Asheron's Call rare item icons\n\n**{total_count}** total icons ({common_count} common, {great_count} great)",
|
||||
color=discord.Color.gold()
|
||||
)
|
||||
|
||||
# Add example icons text
|
||||
examples_text = "**Common Rare Examples:**\n"
|
||||
for _, name in common_examples:
|
||||
examples_text += f"🔸 {name}\n"
|
||||
|
||||
examples_text += "\n**Great Rare Examples:**\n"
|
||||
for _, name in great_examples:
|
||||
examples_text += f"💎 {name}\n"
|
||||
|
||||
embed.add_field(
|
||||
name="📋 Icon Examples",
|
||||
value=examples_text,
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="💡 Commands",
|
||||
value="`!icons all` - Browse all icons with images\n`!icons [item_name]` - Search for specific item\n`!icons grid` - View icon grids",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.set_footer(text=f"Coverage: {(total_count/293)*100:.1f}% of all known rares")
|
||||
|
||||
# Select a representative icon for thumbnail (first great rare)
|
||||
if great_examples:
|
||||
thumbnail_file = great_examples[0][0]
|
||||
thumbnail_path = os.path.join(icons_dir, thumbnail_file)
|
||||
if os.path.exists(thumbnail_path):
|
||||
file = discord.File(thumbnail_path, filename=thumbnail_file)
|
||||
embed.set_thumbnail(url=f"attachment://{thumbnail_file}")
|
||||
await message.channel.send(file=file, embed=embed)
|
||||
else:
|
||||
await message.channel.send(embed=embed)
|
||||
else:
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
logger.info(f"📊 Sent icon summary with thumbnail to {message.author} in #{message.channel}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling !icons summary: {e}")
|
||||
await message.channel.send(f"❌ Error displaying icon summary: {str(e)}")
|
||||
|
||||
async def handle_icons_search(self, message, search_term):
|
||||
"""Handle !icons [item_name] to search for specific rare item."""
|
||||
try:
|
||||
icons_dir = os.path.join(os.path.dirname(__file__), "icons")
|
||||
|
||||
# Load icon mapping
|
||||
icon_mapping = {}
|
||||
for filename in os.listdir(icons_dir):
|
||||
if filename.endswith("_Icon.png"):
|
||||
base_name = filename[:-9]
|
||||
display_name = base_name.replace("_", " ")
|
||||
# Fix common patterns
|
||||
display_name = display_name.replace("s Crystal", "'s Crystal")
|
||||
display_name = display_name.replace("s Pearl", "'s Pearl")
|
||||
display_name = display_name.replace("s Jewel", "'s Jewel")
|
||||
display_name = display_name.replace("s Breath", "'s Breath")
|
||||
display_name = display_name.replace("s Glaive", "'s Glaive")
|
||||
display_name = display_name.replace("s Grip", "'s Grip")
|
||||
display_name = display_name.replace("Tri Blade", "Tri-Blade")
|
||||
display_name = display_name.replace("T ing", "T'ing")
|
||||
if "Renari" in display_name:
|
||||
display_name = display_name.replace("Renaris", "Renari's")
|
||||
if "Leikotha" in display_name:
|
||||
display_name = display_name.replace("Leikothas", "Leikotha's")
|
||||
icon_mapping[filename] = display_name
|
||||
|
||||
# Search for matching items (case-insensitive, partial match)
|
||||
search_lower = search_term.lower()
|
||||
matches = []
|
||||
for filename, display_name in icon_mapping.items():
|
||||
if search_lower in display_name.lower():
|
||||
matches.append((filename, display_name))
|
||||
|
||||
if not matches:
|
||||
await message.channel.send(f"❌ No rare items found matching '{search_term}'. Try a different search term.")
|
||||
return
|
||||
|
||||
# Sort matches by relevance (exact match first, then starts with, then contains)
|
||||
def match_score(item):
|
||||
name = item[1].lower()
|
||||
if name == search_lower:
|
||||
return 0 # Exact match
|
||||
elif name.startswith(search_lower):
|
||||
return 1 # Starts with
|
||||
else:
|
||||
return 2 # Contains
|
||||
|
||||
matches.sort(key=match_score)
|
||||
|
||||
# Limit to first 10 matches to avoid spam
|
||||
matches = matches[:10]
|
||||
|
||||
if len(matches) == 1:
|
||||
# Single match - show full detail with large image
|
||||
filename, display_name = matches[0]
|
||||
icon_path = os.path.join(icons_dir, filename)
|
||||
|
||||
if os.path.exists(icon_path):
|
||||
# Get file stats
|
||||
file_size = os.path.getsize(icon_path) / 1024
|
||||
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
|
||||
emoji = "🔸" if rare_type == "common" else "💎"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"{emoji} {display_name}",
|
||||
description=f"**Type:** {rare_type.title()} Rare\n**File:** `{filename}`\n**Size:** {file_size:.1f} KB",
|
||||
color=discord.Color.blue() if rare_type == "common" else discord.Color.gold()
|
||||
)
|
||||
|
||||
file = discord.File(icon_path, filename=filename)
|
||||
embed.set_image(url=f"attachment://{filename}")
|
||||
|
||||
await message.channel.send(file=file, embed=embed)
|
||||
logger.info(f"🔍 Sent single icon result for '{search_term}' to {message.author}")
|
||||
else:
|
||||
await message.channel.send(f"❌ Icon file not found for {display_name}")
|
||||
else:
|
||||
# Multiple matches - show list with thumbnails
|
||||
embed = discord.Embed(
|
||||
title=f"🔍 Search Results for '{search_term}'",
|
||||
description=f"Found {len(matches)} matching rare items:",
|
||||
color=discord.Color.blue()
|
||||
)
|
||||
|
||||
# Add matches to embed
|
||||
for i, (filename, display_name) in enumerate(matches):
|
||||
rare_type = "common" if self.classify_rare(display_name) == "common" else "great"
|
||||
emoji = "🔸" if rare_type == "common" else "💎"
|
||||
embed.add_field(
|
||||
name=f"{emoji} {display_name}",
|
||||
value=f"{rare_type.title()} rare",
|
||||
inline=True
|
||||
)
|
||||
|
||||
# Attach first 5 images
|
||||
files = []
|
||||
for i in range(min(5, len(matches))):
|
||||
filename, _ = matches[i]
|
||||
icon_path = os.path.join(icons_dir, filename)
|
||||
if os.path.exists(icon_path):
|
||||
files.append(discord.File(icon_path, filename=filename))
|
||||
|
||||
if files:
|
||||
embed.add_field(
|
||||
name="🖼️ Preview Images",
|
||||
value=f"First {len(files)} results shown as attachments",
|
||||
inline=False
|
||||
)
|
||||
await message.channel.send(files=files, embed=embed)
|
||||
else:
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
logger.info(f"🔍 Sent {len(matches)} search results for '{search_term}' to {message.author}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error handling !icons search: {e}")
|
||||
await message.channel.send(f"❌ Error searching icons: {str(e)}")
|
||||
|
||||
async def handle_icons_grid(self, message):
|
||||
"""Handle !icons grid to show icon grid compositions."""
|
||||
await message.channel.send("🚧 **Grid View Coming Soon!**\n\nThis feature will show multiple icons arranged in grids. For now, use:\n• `!icons` - Summary with examples\n• `!icons all` - Browse all icons\n• `!icons [name]` - Search specific items")
|
||||
|
||||
async def start(self):
|
||||
"""Start the Discord bot."""
|
||||
if not DISCORD_TOKEN:
|
||||
logger.error("❌ DISCORD_RARE_BOT_TOKEN environment variable not set")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info("🚀 Starting Discord Rare Monitor Bot...")
|
||||
await self.client.start(DISCORD_TOKEN)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to start Discord bot: {e}")
|
||||
return False
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the Discord bot and cleanup."""
|
||||
logger.info("🛑 Stopping Discord Rare Monitor Bot...")
|
||||
self.running = False
|
||||
|
||||
if self.websocket_task:
|
||||
self.websocket_task.cancel()
|
||||
try:
|
||||
await self.websocket_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if not self.client.is_closed():
|
||||
await self.client.close()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
# Validate required environment variables
|
||||
if not DISCORD_TOKEN:
|
||||
logger.error("❌ Missing required environment variable: DISCORD_RARE_BOT_TOKEN")
|
||||
sys.exit(1)
|
||||
|
||||
# Log configuration
|
||||
actual_log_level = logging.getLevelName(logger.getEffectiveLevel())
|
||||
logger.info("🔧 Discord Rare Monitor Configuration:")
|
||||
logger.info(f" WebSocket URL: {WEBSOCKET_URL}")
|
||||
logger.info(f" Monitor Character: {MONITOR_CHARACTER}")
|
||||
logger.info(f" AC Log Channel ID: {ACLOG_CHANNEL_ID}")
|
||||
logger.info(f" Common Rare Channel ID: {COMMON_RARE_CHANNEL_ID}")
|
||||
logger.info(f" Great Rare Channel ID: {GREAT_RARE_CHANNEL_ID}")
|
||||
logger.info(f" Log Level: {actual_log_level} (ENV: {log_level})")
|
||||
|
||||
# Create and start bot
|
||||
bot = DiscordRareMonitor()
|
||||
|
||||
try:
|
||||
await bot.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 Received keyboard interrupt")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Unexpected error: {e}")
|
||||
finally:
|
||||
await bot.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
55
discord-rare-monitor/icon_mapping.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate mapping between icon filenames and rare item names.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
def generate_icon_mapping():
|
||||
"""Generate mapping from icon filenames to display names."""
|
||||
icons_dir = "/home/erik/MosswartOverlord/discord-rare-monitor/icons"
|
||||
|
||||
# Create reverse mapping from filename to display name
|
||||
icon_mapping = {}
|
||||
|
||||
# List all PNG files in the icons directory
|
||||
for filename in os.listdir(icons_dir):
|
||||
if filename.endswith("_Icon.png"):
|
||||
# Convert filename back to display name
|
||||
# Remove _Icon.png suffix
|
||||
base_name = filename[:-9]
|
||||
|
||||
# Convert underscores to spaces and handle apostrophes
|
||||
display_name = base_name.replace("_", " ")
|
||||
|
||||
# Fix common patterns
|
||||
display_name = display_name.replace("s Crystal", "'s Crystal")
|
||||
display_name = display_name.replace("s Pearl", "'s Pearl")
|
||||
display_name = display_name.replace("s Jewel", "'s Jewel")
|
||||
display_name = display_name.replace("s Breath", "'s Breath")
|
||||
display_name = display_name.replace("s Glaive", "'s Glaive")
|
||||
display_name = display_name.replace("s Grip", "'s Grip")
|
||||
display_name = display_name.replace("Tri Blade", "Tri-Blade")
|
||||
display_name = display_name.replace("T ing", "T'ing")
|
||||
|
||||
# Special cases
|
||||
if "Renari" in display_name:
|
||||
display_name = display_name.replace("Renaris", "Renari's")
|
||||
if "Leikotha" in display_name:
|
||||
display_name = display_name.replace("Leikothas", "Leikotha's")
|
||||
|
||||
icon_mapping[filename] = display_name
|
||||
|
||||
# Save mapping to JSON file
|
||||
with open(os.path.join(os.path.dirname(icons_dir), "icon_name_mapping.json"), "w") as f:
|
||||
json.dump(icon_mapping, f, indent=2, sort_keys=True)
|
||||
|
||||
return icon_mapping
|
||||
|
||||
if __name__ == "__main__":
|
||||
mapping = generate_icon_mapping()
|
||||
print(f"Generated mapping for {len(mapping)} icons")
|
||||
print("\nFirst 10 mappings:")
|
||||
for i, (filename, display_name) in enumerate(list(mapping.items())[:10]):
|
||||
print(f" {filename} -> {display_name}")
|
||||
294
discord-rare-monitor/icon_name_mapping.json
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
{
|
||||
"Adepts_Fervor_Icon.png": "Adepts Fervor",
|
||||
"Adherents_Crystal_Icon.png": "Adherent's Crystal",
|
||||
"Alchemists_Crystal_Icon.png": "Alchemist's Crystal",
|
||||
"Aquamarine_Foolproof_Icon.png": "Aquamarine Foolproof",
|
||||
"Archers_Jewel_Icon.png": "Archer's Jewel",
|
||||
"Aristocrats_Bracelet_Icon.png": "Aristocrats Bracelet",
|
||||
"Artificers_Crystal_Icon.png": "Artificer's Crystal",
|
||||
"Artists_Crystal_Icon.png": "Artist's Crystal",
|
||||
"Assassins_Whisper_Icon.png": "Assassins Whisper",
|
||||
"Astyrrians_Jewel_Icon.png": "Astyrrian's Jewel",
|
||||
"Band_of_Elemental_Harmony_Icon.png": "Band of Elemental Harmony",
|
||||
"Baton_of_Tirethas_Icon.png": "Baton of Tirethas",
|
||||
"Bearded_Axe_of_Souia-Vey_Icon.png": "Bearded Axe of Souia-Vey",
|
||||
"Ben_Tens_Crystal_Icon.png": "Ben Ten's Crystal",
|
||||
"Berzerkers_Crystal_Icon.png": "Berzerker's Crystal",
|
||||
"Black_Cloud_Bow_Icon.png": "Black Cloud Bow",
|
||||
"Black_Garnet_Foolproof_Icon.png": "Black Garnet Foolproof",
|
||||
"Black_Opal_Foolproof_Icon.png": "Black Opal Foolproof",
|
||||
"Black_Thistle_Icon.png": "Black Thistle",
|
||||
"Bloodmark_Crossbow_Icon.png": "Bloodmark Crossbow",
|
||||
"Bracelet_of_Binding_Icon.png": "Bracelet of Binding",
|
||||
"Bracers_of_Leikothas_Tears_Icon.png": "Bracers of Leikotha's Tears",
|
||||
"Bradors_Frozen_Eye_Icon.png": "Bradors Frozen Eye",
|
||||
"Brawlers_Crystal_Icon.png": "Brawler's Crystal",
|
||||
"Breastplate_of_Leikothas_Tears_Icon.png": "Breastplate of Leikotha's Tears",
|
||||
"Canfield_Cleaver_Icon.png": "Canfield Cleaver",
|
||||
"Casino_Exquisite_Keyring_Icon.png": "Casino Exquisite Keyring",
|
||||
"Champions_Demise_Icon.png": "Champions Demise",
|
||||
"Chefs_Crystal_Icon.png": "Chef's Crystal",
|
||||
"Chitin_Cracker_Icon.png": "Chitin Cracker",
|
||||
"Circle_of_Pure_Thought_Icon.png": "Circle of Pure Thought",
|
||||
"Converters_Crystal_Icon.png": "Converter's Crystal",
|
||||
"Corruptors_Crystal_Icon.png": "Corruptor's Crystal",
|
||||
"Corsairs_Arc_Icon.png": "Corsairs Arc",
|
||||
"Count_Renaris_Equalizer_Icon.png": "Count Renari's Equalizer",
|
||||
"Dart_Flicker_Icon.png": "Dart Flicker",
|
||||
"Deaths_Grip_Staff_Icon.png": "Death's Grip Staff",
|
||||
"Decapitators_Blade_Icon.png": "Decapitators Blade",
|
||||
"Deceivers_Crystal_Icon.png": "Deceiver's Crystal",
|
||||
"Defiler_of_Milantos_Icon.png": "Defiler of Milantos",
|
||||
"Deru_Limb_Icon.png": "Deru Limb",
|
||||
"Desert_Wyrm_Icon.png": "Desert Wyrm",
|
||||
"Dodgers_Crystal_Icon.png": "Dodger's Crystal",
|
||||
"Dragonspine_Bow_Icon.png": "Dragonspine Bow",
|
||||
"Dread_Marauder_Shield_Icon.png": "Dread Marauder Shield",
|
||||
"Dreamseer_Bangle_Icon.png": "Dreamseer Bangle",
|
||||
"Drifters_Atlatl_Icon.png": "Drifters Atlatl",
|
||||
"Dripping_Death_Icon.png": "Dripping Death",
|
||||
"Duelists_Jewel_Icon.png": "Duelist's Jewel",
|
||||
"Dusk_Coat_Icon.png": "Dusk Coat",
|
||||
"Dusk_Leggings_Icon.png": "Dusk Leggings",
|
||||
"Ebonwood_Shortbow_Icon.png": "Ebonwood Shortbow",
|
||||
"Elysas_Crystal_Icon.png": "Elysa's Crystal",
|
||||
"Emerald_Foolproof_Icon.png": "Emerald Foolproof",
|
||||
"Enchanters_Crystal_Icon.png": "Enchanter's Crystal",
|
||||
"Eternal_Health_Kit_Icon.png": "Eternal Health Kit",
|
||||
"Eternal_Mana_Charge_Icon.png": "Eternal Mana Charge",
|
||||
"Eternal_Mana_Kit_Icon.png": "Eternal Mana Kit",
|
||||
"Eternal_Stamina_Kit_Icon.png": "Eternal Stamina Kit",
|
||||
"Evaders_Crystal_Icon.png": "Evader's Crystal",
|
||||
"Executors_Jewel_Icon.png": "Executor's Jewel",
|
||||
"Eye_of_Muramm_Icon.png": "Eye of Muramm",
|
||||
"Feathered_Razor_Icon.png": "Feathered Razor",
|
||||
"Fire_Opal_Foolproof_Icon.png": "Fire Opal Foolproof",
|
||||
"Fist_of_Three_Principles_Icon.png": "Fist of Three Principles",
|
||||
"Fletchers_Crystal_Icon.png": "Fletcher's Crystal",
|
||||
"Footmans_Boots_Icon.png": "Footmans Boots",
|
||||
"Gauntlets_of_Leikothas_Tears_Icon.png": "Gauntlets of Leikotha's Tears",
|
||||
"Gauntlets_of_the_Crimson_Star_Icon.png": "Gauntlets of the Crimson Star",
|
||||
"Gelidite_Boots_Icon.png": "Gelidite Boots",
|
||||
"Gelidite_Bracers_Icon.png": "Gelidite Bracers",
|
||||
"Gelidite_Breastplate_Icon.png": "Gelidite Breastplate",
|
||||
"Gelidite_Gauntlets_Icon.png": "Gelidite Gauntlets",
|
||||
"Gelidite_Girth_Icon.png": "Gelidite Girth",
|
||||
"Gelidite_Greaves_Icon.png": "Gelidite Greaves",
|
||||
"Gelidite_Mitre_Icon.png": "Gelidite Mitre",
|
||||
"Gelidite_Pauldrons_Icon.png": "Gelidite Pauldrons",
|
||||
"Gelidite_Tassets_Icon.png": "Gelidite Tassets",
|
||||
"Gelids_Jewel_Icon.png": "Gelid's Jewel",
|
||||
"Girth_of_Leikothas_Tears_Icon.png": "Girth of Leikotha's Tears",
|
||||
"Golden_Snake_Choker_Icon.png": "Golden Snake Choker",
|
||||
"Greaves_of_Leikothas_Tears_Icon.png": "Greaves of Leikotha's Tears",
|
||||
"Guardian_of_Pwyll_Icon.png": "Guardian of Pwyll",
|
||||
"Heart_of_Darkest_Flame_Icon.png": "Heart of Darkest Flame",
|
||||
"Helm_of_Leikothas_Tears_Icon.png": "Helm of Leikotha's Tears",
|
||||
"Hevelios_Half-Moon_Icon.png": "Hevelios Half-Moon",
|
||||
"Hieroglyph_of_Alchemy_Mastery_Icon.png": "Hieroglyph of Alchemy Mastery",
|
||||
"Hieroglyph_of_Arcane_Enlightenment_Icon.png": "Hieroglyph of Arcane Enlightenment",
|
||||
"Hieroglyph_of_Armor_Tinkering_Expertise_Icon.png": "Hieroglyph of Armor Tinkering Expertise",
|
||||
"Hieroglyph_of_Cooking_Mastery_Icon.png": "Hieroglyph of Cooking Mastery",
|
||||
"Hieroglyph_of_Creature_Enchantment_Mastery_Icon.png": "Hieroglyph of Creature Enchantment Mastery",
|
||||
"Hieroglyph_of_Deception_Mastery_Icon.png": "Hieroglyph of Deception Mastery",
|
||||
"Hieroglyph_of_Dirty_Fighting_Mastery_Icon.png": "Hieroglyph of Dirty Fighting Mastery",
|
||||
"Hieroglyph_of_Dual_Wield_Mastery_Icon.png": "Hieroglyph of Dual Wield Mastery",
|
||||
"Hieroglyph_of_Fealty_Icon.png": "Hieroglyph of Fealty",
|
||||
"Hieroglyph_of_Finesse_Weapon_Mastery_Icon.png": "Hieroglyph of Finesse Weapon Mastery",
|
||||
"Hieroglyph_of_Fletching_Mastery_Icon.png": "Hieroglyph of Fletching Mastery",
|
||||
"Hieroglyph_of_Healing_Mastery_Icon.png": "Hieroglyph of Healing Mastery",
|
||||
"Hieroglyph_of_Heavy_Weapon_Mastery_Icon.png": "Hieroglyph of Heavy Weapon Mastery",
|
||||
"Hieroglyph_of_Impregnability_Icon.png": "Hieroglyph of Impregnability",
|
||||
"Hieroglyph_of_Invulnerability_Icon.png": "Hieroglyph of Invulnerability",
|
||||
"Hieroglyph_of_Item_Enchantment_Mastery_Icon.png": "Hieroglyph of Item Enchantment Mastery",
|
||||
"Hieroglyph_of_Item_Tinkering_Expertise_Icon.png": "Hieroglyph of Item Tinkering Expertise",
|
||||
"Hieroglyph_of_Jumping_Mastery_Icon.png": "Hieroglyph of Jumping Mastery",
|
||||
"Hieroglyph_of_Leadership_Mastery_Icon.png": "Hieroglyph of Leadership Mastery",
|
||||
"Hieroglyph_of_Life_Magic_Mastery_Icon.png": "Hieroglyph of Life Magic Mastery",
|
||||
"Hieroglyph_of_Light_Weapon_Mastery_Icon.png": "Hieroglyph of Light Weapon Mastery",
|
||||
"Hieroglyph_of_Lockpick_Mastery_Icon.png": "Hieroglyph of Lockpick Mastery",
|
||||
"Hieroglyph_of_Magic_Item_Tinkering_Expertise_Icon.png": "Hieroglyph of Magic Item Tinkering Expertise",
|
||||
"Hieroglyph_of_Magic_Resistance_Icon.png": "Hieroglyph of Magic Resistance",
|
||||
"Hieroglyph_of_Mana_Conversion_Mastery_Icon.png": "Hieroglyph of Mana Conversion Mastery",
|
||||
"Hieroglyph_of_Missile_Weapon_Mastery_Icon.png": "Hieroglyph of Missile Weapon Mastery",
|
||||
"Hieroglyph_of_Monster_Attunement_Icon.png": "Hieroglyph of Monster Attunement",
|
||||
"Hieroglyph_of_Person_Attunement_Icon.png": "Hieroglyph of Person Attunement",
|
||||
"Hieroglyph_of_Recklessness_Mastery_Icon.png": "Hieroglyph of Recklessness Mastery",
|
||||
"Hieroglyph_of_Shield_Mastery_Icon.png": "Hieroglyph of Shield Mastery",
|
||||
"Hieroglyph_of_Sneak_Attack_Mastery_Icon.png": "Hieroglyph of Sneak Attack Mastery",
|
||||
"Hieroglyph_of_Sprint_Icon.png": "Hieroglyph of Sprint",
|
||||
"Hieroglyph_of_Two_Handed_Weapons_Mastery_Icon.png": "Hieroglyph of Two Handed Weapons Mastery",
|
||||
"Hieroglyph_of_Void_Magic_Mastery_Icon.png": "Hieroglyph of Void Magic Mastery",
|
||||
"Hieroglyph_of_War_Magic_Mastery_Icon.png": "Hieroglyph of War Magic Mastery",
|
||||
"Hieroglyph_of_Weapon_Tinkering_Expertise_Icon.png": "Hieroglyph of Weapon Tinkering Expertise",
|
||||
"Hieromancers_Crystal_Icon.png": "Hieromancer's Crystal",
|
||||
"Hooded_Serpent_Slinger_Icon.png": "Hooded Serpent Slinger",
|
||||
"Hunters_Crystal_Icon.png": "Hunter's Crystal",
|
||||
"Huntsmans_Dart-Thrower_Icon.png": "Huntsmans Dart-Thrower",
|
||||
"Ibriyas_Choice_Icon.png": "Ibriyas Choice",
|
||||
"Ideograph_of_Acid_Protection_Icon.png": "Ideograph of Acid Protection",
|
||||
"Ideograph_of_Armor_Icon.png": "Ideograph of Armor",
|
||||
"Ideograph_of_Blade_Protection_Icon.png": "Ideograph of Blade Protection",
|
||||
"Ideograph_of_Bludgeoning_Protection_Icon.png": "Ideograph of Bludgeoning Protection",
|
||||
"Ideograph_of_Fire_Protection_Icon.png": "Ideograph of Fire Protection",
|
||||
"Ideograph_of_Frost_Protection_Icon.png": "Ideograph of Frost Protection",
|
||||
"Ideograph_of_Lightning_Protection_Icon.png": "Ideograph of Lightning Protection",
|
||||
"Ideograph_of_Mana_Renewal_Icon.png": "Ideograph of Mana Renewal",
|
||||
"Ideograph_of_Piercing_Protection_Icon.png": "Ideograph of Piercing Protection",
|
||||
"Ideograph_of_Regeneration_Icon.png": "Ideograph of Regeneration",
|
||||
"Ideograph_of_Revitalization_Icon.png": "Ideograph of Revitalization",
|
||||
"Imbuers_Crystal_Icon.png": "Imbuer's Crystal",
|
||||
"Imperial_Chevairds_Helm_Icon.png": "Imperial Chevairds Helm",
|
||||
"Imperial_Topaz_Foolproof_Icon.png": "Imperial Topaz Foolproof",
|
||||
"Infernos_Jewel_Icon.png": "Inferno's Jewel",
|
||||
"Infinite_Deadly_Acid_Arrowheads_Icon.png": "Infinite Deadly Acid Arrowheads",
|
||||
"Infinite_Deadly_Armor_Piercing_Arrowheads_Icon.png": "Infinite Deadly Armor Piercing Arrowheads",
|
||||
"Infinite_Deadly_Blunt_Arrowheads_Icon.png": "Infinite Deadly Blunt Arrowheads",
|
||||
"Infinite_Deadly_Broad_Arrowheads_Icon.png": "Infinite Deadly Broad Arrowheads",
|
||||
"Infinite_Deadly_Electric_Arrowheads_Icon.png": "Infinite Deadly Electric Arrowheads",
|
||||
"Infinite_Deadly_Fire_Arrowheads_Icon.png": "Infinite Deadly Fire Arrowheads",
|
||||
"Infinite_Deadly_Frog_Crotch_Arrowheads_Icon.png": "Infinite Deadly Frog Crotch Arrowheads",
|
||||
"Infinite_Deadly_Frost_Arrowheads_Icon.png": "Infinite Deadly Frost Arrowheads",
|
||||
"Infinite_Elaborate_Dried_Rations_Icon.png": "Infinite Elaborate Dried Rations",
|
||||
"Infinite_Ivory_Icon.png": "Infinite Ivory",
|
||||
"Infinite_Leather_Icon.png": "Infinite Leather",
|
||||
"Infinite_Simple_Dried_Rations_Icon.png": "Infinite Simple Dried Rations",
|
||||
"Invigorating_Elixir_Icon.png": "Invigorating Elixir",
|
||||
"Iron_Bull_Icon.png": "Iron Bull",
|
||||
"Itakas_Naginata_Icon.png": "Itakas Naginata",
|
||||
"Jet_Foolproof_Icon.png": "Jet Foolproof",
|
||||
"Lichs_Pearl_Icon.png": "Lich's Pearl",
|
||||
"Life_Givers_Crystal_Icon.png": "Life Giver's Crystal",
|
||||
"Limitless_Lockpick_Icon.png": "Limitless Lockpick",
|
||||
"Loop_of_Opposing_Benedictions_Icon.png": "Loop of Opposing Benedictions",
|
||||
"Loves_Favor_Icon.png": "Loves Favor",
|
||||
"Lugians_Pearl_Icon.png": "Lugian's Pearl",
|
||||
"Mages_Jewel_Icon.png": "Mage's Jewel",
|
||||
"Maguss_Pearl_Icon.png": "Magus's Pearl",
|
||||
"Malachite_Slasher_Icon.png": "Malachite Slasher",
|
||||
"Medicated_Health_Kit_Icon.png": "Medicated Health Kit",
|
||||
"Medicated_Mana_Kit_Icon.png": "Medicated Mana Kit",
|
||||
"Medicated_Stamina_Kit_Icon.png": "Medicated Stamina Kit",
|
||||
"Melees_Jewel_Icon.png": "Melee's Jewel",
|
||||
"Miraculous_Elixir_Icon.png": "Miraculous Elixir",
|
||||
"Mirrored_Justice_Icon.png": "Mirrored Justice",
|
||||
"Monarchs_Crystal_Icon.png": "Monarch's Crystal",
|
||||
"Moriharus_Kitchen_Knife_Icon.png": "Moriharus Kitchen Knife",
|
||||
"Morrigans_Vanity_Icon.png": "Morrigans Vanity",
|
||||
"Necklace_of_Iniquity_Icon.png": "Necklace of Iniquity",
|
||||
"Observers_Crystal_Icon.png": "Observer's Crystal",
|
||||
"Olthois_Jewel_Icon.png": "Olthoi's Jewel",
|
||||
"Orb_of_the_Ironsea_Icon.png": "Orb of the Ironsea",
|
||||
"Oswalds_Crystal_Icon.png": "Oswald's Crystal",
|
||||
"Patriarchs_Twilight_Coat_Icon.png": "Patriarchs Twilight Coat",
|
||||
"Patriarchs_Twilight_Tights_Icon.png": "Patriarchs Twilight Tights",
|
||||
"Pauldrons_of_Leikothas_Tears_Icon.png": "Pauldrons of Leikotha's Tears",
|
||||
"Pearl_of_Acid_Baning_Icon.png": "Pearl of Acid Baning",
|
||||
"Pearl_of_Blade_Baning_Icon.png": "Pearl of Blade Baning",
|
||||
"Pearl_of_Blood_Drinking_Icon.png": "Pearl of Blood Drinking",
|
||||
"Pearl_of_Bludgeon_Baning_Icon.png": "Pearl of Bludgeon Baning",
|
||||
"Pearl_of_Defending_Icon.png": "Pearl of Defending",
|
||||
"Pearl_of_Flame_Baning_Icon.png": "Pearl of Flame Baning",
|
||||
"Pearl_of_Frost_Baning_Icon.png": "Pearl of Frost Baning",
|
||||
"Pearl_of_Heart_Seeking_Icon.png": "Pearl of Heart Seeking",
|
||||
"Pearl_of_Hermetic_Linking_Icon.png": "Pearl of Hermetic Linking",
|
||||
"Pearl_of_Impenetrability_Icon.png": "Pearl of Impenetrability",
|
||||
"Pearl_of_Lightning_Baning_Icon.png": "Pearl of Lightning Baning",
|
||||
"Pearl_of_Pierce_Baning_Icon.png": "Pearl of Pierce Baning",
|
||||
"Pearl_of_Spirit_Drinking_Icon.png": "Pearl of Spirit Drinking",
|
||||
"Pearl_of_Swift_Killing_Icon.png": "Pearl of Swift Killing",
|
||||
"Perennial_Argenory_Dye_Icon.png": "Perennial Argenory Dye",
|
||||
"Perennial_Berimphur_Dye_Icon.png": "Perennial Berimphur Dye",
|
||||
"Perennial_Botched_Dye_Icon.png": "Perennial Botched Dye",
|
||||
"Perennial_Colban_Dye_Icon.png": "Perennial Colban Dye",
|
||||
"Perennial_Hennacin_Dye_Icon.png": "Perennial Hennacin Dye",
|
||||
"Perennial_Lapyan_Dye_Icon.png": "Perennial Lapyan Dye",
|
||||
"Perennial_Minalim_Dye_Icon.png": "Perennial Minalim Dye",
|
||||
"Perennial_Relanim_Dye_Icon.png": "Perennial Relanim Dye",
|
||||
"Perennial_Thananim_Dye_Icon.png": "Perennial Thananim Dye",
|
||||
"Perennial_Verdalim_Dye_Icon.png": "Perennial Verdalim Dye",
|
||||
"Peridot_Foolproof_Icon.png": "Peridot Foolproof",
|
||||
"Physicians_Crystal_Icon.png": "Physician's Crystal",
|
||||
"Pictograph_of_Coordination_Icon.png": "Pictograph of Coordination",
|
||||
"Pictograph_of_Endurance_Icon.png": "Pictograph of Endurance",
|
||||
"Pictograph_of_Focus_Icon.png": "Pictograph of Focus",
|
||||
"Pictograph_of_Quickness_Icon.png": "Pictograph of Quickness",
|
||||
"Pictograph_of_Strength_Icon.png": "Pictograph of Strength",
|
||||
"Pictograph_of_Willpower_Icon.png": "Pictograph of Willpower",
|
||||
"Pillar_of_Fearlessness_Icon.png": "Pillar of Fearlessness",
|
||||
"Pitfighters_Edge_Icon.png": "Pitfighters Edge",
|
||||
"Red_Garnet_Foolproof_Icon.png": "Red Garnet Foolproof",
|
||||
"Refreshing_Elixir_Icon.png": "Refreshing Elixir",
|
||||
"Resisters_Crystal_Icon.png": "Resister's Crystal",
|
||||
"Revenants_Scythe_Icon.png": "Revenants Scythe",
|
||||
"Ridgeback_Dagger_Icon.png": "Ridgeback Dagger",
|
||||
"Ring_of_Channeling_Icon.png": "Ring of Channeling",
|
||||
"Rogues_Crystal_Icon.png": "Rogue's Crystal",
|
||||
"Royal_Ladle_Icon.png": "Royal Ladle",
|
||||
"Rune_of_Acid_Bane_Icon.png": "Rune of Acid Bane",
|
||||
"Rune_of_Blade_Bane_Icon.png": "Rune of Blade Bane",
|
||||
"Rune_of_Blood_Drinker_Icon.png": "Rune of Blood Drinker",
|
||||
"Rune_of_Bludgeon_Bane_Icon.png": "Rune of Bludgeon Bane",
|
||||
"Rune_of_Defender_Icon.png": "Rune of Defender",
|
||||
"Rune_of_Dispel_Icon.png": "Rune of Dispel",
|
||||
"Rune_of_Flame_Bane_Icon.png": "Rune of Flame Bane",
|
||||
"Rune_of_Frost_Bane_Icon.png": "Rune of Frost Bane",
|
||||
"Rune_of_Heart_Seeker_Icon.png": "Rune of Heart Seeker",
|
||||
"Rune_of_Hermetic_Link_Icon.png": "Rune of Hermetic Link",
|
||||
"Rune_of_Impenetrability_Icon.png": "Rune of Impenetrability",
|
||||
"Rune_of_Lifestone_Recall_Icon.png": "Rune of Lifestone Recall",
|
||||
"Rune_of_Lightning_Bane_Icon.png": "Rune of Lightning Bane",
|
||||
"Rune_of_Pierce_Bane_Icon.png": "Rune of Pierce Bane",
|
||||
"Rune_of_Portal_Recall_Icon.png": "Rune of Portal Recall",
|
||||
"Rune_of_Spirit_Drinker_Icon.png": "Rune of Spirit Drinker",
|
||||
"Rune_of_Swift_Killer_Icon.png": "Rune of Swift Killer",
|
||||
"Scholars_Crystal_Icon.png": "Scholar's Crystal",
|
||||
"Serpents_Flight_Icon.png": "Serpents Flight",
|
||||
"Shield_of_Engorgement_Icon.png": "Shield of Engorgement",
|
||||
"Shimmering_Skeleton_Key_Icon.png": "Shimmering Skeleton Key",
|
||||
"Skullpuncher_Icon.png": "Skullpuncher",
|
||||
"Smite_Icon.png": "Smite",
|
||||
"Smithys_Crystal_Icon.png": "Smithy's Crystal",
|
||||
"Spear_of_Lost_Truths_Icon.png": "Spear of Lost Truths",
|
||||
"Spirit_Shifting_Staff_Icon.png": "Spirit Shifting Staff",
|
||||
"Sprinters_Pearl_Icon.png": "Sprinter's Pearl",
|
||||
"Squires_Glaive_Icon.png": "Squire's Glaive",
|
||||
"Staff_of_All_Aspects_Icon.png": "Staff of All Aspects",
|
||||
"Staff_of_Fettered_Souls_Icon.png": "Staff of Fettered Souls",
|
||||
"Staff_of_Tendrils_Icon.png": "Staff of Tendrils",
|
||||
"Star_of_Gharun_Icon.png": "Star of Gharun",
|
||||
"Star_of_Tukal_Icon.png": "Star of Tukal",
|
||||
"Steel_Butterfly_Icon.png": "Steel Butterfly",
|
||||
"Steel_Wall_Boots_Icon.png": "Steel Wall Boots",
|
||||
"Subjugator_Icon.png": "Subjugator",
|
||||
"Sunstone_Foolproof_Icon.png": "Sunstone Foolproof",
|
||||
"Swift_Strike_Ring_Icon.png": "Swift Strike Ring",
|
||||
"Tassets_of_Leikothas_Tears_Icon.png": "Tassets of Leikotha's Tears",
|
||||
"Thiefs_Crystal_Icon.png": "Thief's Crystal",
|
||||
"Thorstens_Crystal_Icon.png": "Thorsten's Crystal",
|
||||
"Thunderhead_Icon.png": "Thunderhead",
|
||||
"Tings_Crystal_Icon.png": "Ting's Crystal",
|
||||
"Tinkers_Crystal_Icon.png": "Tinker's Crystal",
|
||||
"Tracker_Boots_Icon.png": "Tracker Boots",
|
||||
"Tri_Blade_Spear_Icon.png": "Tri-Blade Spear",
|
||||
"Tusked_Axe_of_Ayan_Baqur_Icon.png": "Tusked Axe of Ayan Baqur",
|
||||
"Tuskers_Jewel_Icon.png": "Tusker's Jewel",
|
||||
"Twin_Ward_Icon.png": "Twin Ward",
|
||||
"Unchained_Prowess_Ring_Icon.png": "Unchained Prowess Ring",
|
||||
"Ursuins_Pearl_Icon.png": "Ursuin's Pearl",
|
||||
"Valkeers_Helm_Icon.png": "Valkeers Helm",
|
||||
"Vaulters_Crystal_Icon.png": "Vaulter's Crystal",
|
||||
"Wand_of_the_Frore_Crystal_Icon.png": "Wand of the Frore Crystal",
|
||||
"Warriors_Crystal_Icon.png": "Warrior's Crystal",
|
||||
"Warriors_Jewel_Icon.png": "Warrior's Jewel",
|
||||
"Wayfarers_Pearl_Icon.png": "Wayfarer's Pearl",
|
||||
"Weeping_Ring_Icon.png": "Weeping Ring",
|
||||
"White_Sapphire_Foolproof_Icon.png": "White Sapphire Foolproof",
|
||||
"Wings_of_Rakhil_Icon.png": "Wings of Rakhil",
|
||||
"Winters_Heart_Icon.png": "Winters Heart",
|
||||
"Yellow_Topaz_Foolproof_Icon.png": "Yellow Topaz Foolproof",
|
||||
"Zefirs_Breath_Icon.png": "Zefir's Breath",
|
||||
"Zefirs_Crystal_Icon.png": "Zefir's Crystal",
|
||||
"Zharalim_Crookblade_Icon.png": "Zharalim Crookblade",
|
||||
"Zircon_Foolproof_Icon.png": "Zircon Foolproof"
|
||||
}
|
||||
BIN
discord-rare-monitor/icons/Adepts_Fervor_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Adherents_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Alchemists_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Aquamarine_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Archers_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Aristocrats_Bracelet_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Artificers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Artists_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Assassins_Whisper_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Astyrrians_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Band_of_Elemental_Harmony_Icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
discord-rare-monitor/icons/Baton_of_Tirethas_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Bearded_Axe_of_Souia-Vey_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Ben_Tens_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Berzerkers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Black_Cloud_Bow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Black_Garnet_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Black_Opal_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Black_Thistle_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Bloodmark_Crossbow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Bracelet_of_Binding_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Bracers_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Bradors_Frozen_Eye_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Brawlers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Canfield_Cleaver_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Casino_Exquisite_Keyring_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Champions_Demise_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Chefs_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Chitin_Cracker_Icon.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
discord-rare-monitor/icons/Circle_of_Pure_Thought_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Converters_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Corruptors_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Corsairs_Arc_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Count_Renaris_Equalizer_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Dart_Flicker_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Deaths_Grip_Staff_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Decapitators_Blade_Icon.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
discord-rare-monitor/icons/Deceivers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Defiler_of_Milantos_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Deru_Limb_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Desert_Wyrm_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Dodgers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Dragonspine_Bow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Dread_Marauder_Shield_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Dreamseer_Bangle_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Drifters_Atlatl_Icon.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
discord-rare-monitor/icons/Dripping_Death_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Duelists_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Dusk_Coat_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Dusk_Leggings_Icon.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
discord-rare-monitor/icons/Ebonwood_Shortbow_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Elysas_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Emerald_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Enchanters_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Eternal_Health_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
discord-rare-monitor/icons/Eternal_Mana_Charge_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Eternal_Mana_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Eternal_Stamina_Kit_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Evaders_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Executors_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Eye_of_Muramm_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Feathered_Razor_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Fire_Opal_Foolproof_Icon.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
discord-rare-monitor/icons/Fist_of_Three_Principles_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Fletchers_Crystal_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Footmans_Boots_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gauntlets_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Boots_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Bracers_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Breastplate_Icon.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Gauntlets_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Girth_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Greaves_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Mitre_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Pauldrons_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Gelidite_Tassets_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Gelids_Jewel_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Girth_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
discord-rare-monitor/icons/Golden_Snake_Choker_Icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
discord-rare-monitor/icons/Greaves_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
discord-rare-monitor/icons/Guardian_of_Pwyll_Icon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
discord-rare-monitor/icons/Heart_of_Darkest_Flame_Icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
discord-rare-monitor/icons/Helm_of_Leikothas_Tears_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
discord-rare-monitor/icons/Hevelios_Half-Moon_Icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.5 KiB |