mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-07-22 02:21:33 -05:00
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""
|
|
Archipelago Monitor Cog
|
|
Monitors an Archipelago server log file and sends Discord notifications
|
|
when items are sent between players.
|
|
"""
|
|
|
|
import discord
|
|
from discord.ext import commands, tasks
|
|
import asyncio
|
|
import re
|
|
import logging
|
|
import os
|
|
from dotenv import load_dotenv
|
|
from typing import Optional, Set
|
|
from pathlib import Path
|
|
|
|
load_dotenv()
|
|
|
|
logger = logging.getLogger('ArchipelagoMonitor')
|
|
|
|
|
|
class ArchipelagoMonitor(commands.Cog):
|
|
"""Monitors Archipelago multiworld log file and posts updates to Discord."""
|
|
|
|
def __init__(self, bot: commands.Bot):
|
|
self.bot = bot
|
|
|
|
# Get configuration from environment variables
|
|
self.channel_id = int(os.getenv("ARCHIPELAGO_CHANNEL_ID", "0"))
|
|
self.log_directory = os.getenv("ARCHIPELAGO_LOG_DIR", "/home/lilacrose/Archipelago/logs")
|
|
self.enabled = os.getenv("ARCHIPELAGO_ENABLED", "false").lower() == "true"
|
|
|
|
self.notification_channel: Optional[discord.TextChannel] = None
|
|
self.current_log_file: Optional[Path] = None
|
|
self.last_position = 0 # Track where we last read in the file
|
|
self.seen_lines: Set[str] = set() # Track lines we've already processed
|
|
|
|
# Regex patterns for parsing log messages
|
|
self.patterns = {
|
|
'item_send': re.compile(r'\(Team #\d+\) (.+?) sent (.+?) to (.+?) \((.+?)\)'),
|
|
'join': re.compile(r'Notice \(all\): (.+?) \(Team #\d+\) (?:playing|tracking) (.+?) has joined'),
|
|
'leave': re.compile(r'Notice \(all\): (.+?) \(Team #\d+\) has (?:left the game|stopped tracking)'),
|
|
'tag_change': re.compile(r'Notice \(all\): (.+?) \(Team #\d+\) has changed tags from \[.*?\] to \[(.+?)\]'),
|
|
'server_start': re.compile(r'Hosting game at (.+?) \(Password: (.+?)\)'),
|
|
}
|
|
|
|
# Print startup info
|
|
print(f"[Archipelago] Initializing log monitor...")
|
|
print(f"[Archipelago] Enabled: {self.enabled}")
|
|
print(f"[Archipelago] Channel ID: {self.channel_id}")
|
|
print(f"[Archipelago] Log directory: {self.log_directory}")
|
|
|
|
if self.enabled and self.channel_id:
|
|
logger.info(f"Archipelago monitor enabled - Channel: {self.channel_id}, Log dir: {self.log_directory}")
|
|
print(f"[Archipelago] Monitor will start when bot is ready")
|
|
self.monitor_log.start()
|
|
print(f"[Archipelago] Monitor task started")
|
|
else:
|
|
logger.info("Archipelago monitor disabled (check .env configuration)")
|
|
print(f"[Archipelago] Monitor DISABLED - check your .env file")
|
|
if not self.enabled:
|
|
print(f"[Archipelago] - ARCHIPELAGO_ENABLED is not true")
|
|
if not self.channel_id:
|
|
print(f"[Archipelago] - ARCHIPELAGO_CHANNEL_ID is not set")
|
|
|
|
async def cog_unload(self):
|
|
"""Called when the cog is unloaded."""
|
|
self.monitor_log.cancel()
|
|
|
|
def get_most_recent_log_file(self) -> Optional[Path]:
|
|
"""Find the most recent Server_*.txt log file in the logs directory."""
|
|
try:
|
|
log_dir = Path(self.log_directory)
|
|
if not log_dir.exists():
|
|
logger.error(f"Log directory does not exist: {self.log_directory}")
|
|
return None
|
|
|
|
# Find all Server_*.txt files
|
|
log_files = list(log_dir.glob("Server_*.txt"))
|
|
|
|
if not log_files:
|
|
logger.warning(f"No Server_*.txt log files found in {self.log_directory}")
|
|
return None
|
|
|
|
# Get the most recently modified file
|
|
most_recent = max(log_files, key=lambda p: p.stat().st_mtime)
|
|
logger.info(f"Found most recent log file: {most_recent.name}")
|
|
return most_recent
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error finding log file: {e}")
|
|
return None
|
|
|
|
@tasks.loop(seconds=2)
|
|
async def monitor_log(self):
|
|
"""Monitor the Archipelago log file for new events."""
|
|
try:
|
|
# Check if we need to find a new log file (first run or file changed)
|
|
most_recent = self.get_most_recent_log_file()
|
|
|
|
if most_recent is None:
|
|
logger.debug(f"No log file found in {self.log_directory}")
|
|
return
|
|
|
|
# If this is a different file than we're currently reading, switch to it
|
|
if self.current_log_file != most_recent:
|
|
logger.info(f"Switching to new log file: {most_recent.name}")
|
|
self.current_log_file = most_recent
|
|
self.last_position = 0
|
|
|
|
# Notify Discord that we're reading a new log
|
|
if self.notification_channel:
|
|
await self.notification_channel.send(f"📄 Now monitoring: `{most_recent.name}`")
|
|
|
|
# Check if file exists
|
|
if not self.current_log_file.exists():
|
|
logger.debug(f"Log file not found: {self.current_log_file}")
|
|
return
|
|
|
|
# Read new lines from the file
|
|
with open(self.current_log_file, 'r', encoding='utf-8') as f:
|
|
# Seek to last position
|
|
f.seek(self.last_position)
|
|
|
|
# Read new lines
|
|
new_lines = f.readlines()
|
|
|
|
# Update position
|
|
self.last_position = f.tell()
|
|
|
|
# Process each new line
|
|
for line in new_lines:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Skip if we've already processed this line
|
|
if line in self.seen_lines:
|
|
continue
|
|
|
|
self.seen_lines.add(line)
|
|
|
|
# Keep seen_lines from growing too large
|
|
if len(self.seen_lines) > 1000:
|
|
# Remove oldest half
|
|
self.seen_lines = set(list(self.seen_lines)[-500:])
|
|
|
|
# Try to parse and handle the line
|
|
await self.process_log_line(line)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error monitoring log file: {e}")
|
|
|
|
@monitor_log.before_loop
|
|
async def before_monitor(self):
|
|
"""Wait until the bot is ready before starting the monitor."""
|
|
await self.bot.wait_until_ready()
|
|
|
|
# Get the notification channel
|
|
self.notification_channel = self.bot.get_channel(self.channel_id)
|
|
if self.notification_channel:
|
|
logger.info(f"Notification channel set to: {self.notification_channel.name}")
|
|
|
|
# Find the most recent log file
|
|
most_recent = self.get_most_recent_log_file()
|
|
|
|
if most_recent:
|
|
self.current_log_file = most_recent
|
|
|
|
# Set position to end of file (don't process old messages)
|
|
with open(most_recent, 'r', encoding='utf-8') as f:
|
|
f.seek(0, 2) # Seek to end
|
|
self.last_position = f.tell()
|
|
|
|
await self.notification_channel.send(
|
|
f"🎮 Archipelago log monitor started!\n"
|
|
f"📄 Monitoring: `{most_recent.name}`"
|
|
)
|
|
else:
|
|
await self.notification_channel.send(
|
|
f"⚠️ No Archipelago log files found in: `{self.log_directory}`\n"
|
|
f"Waiting for server to start..."
|
|
)
|
|
logger.warning(f"No log files found in: {self.log_directory}")
|
|
else:
|
|
logger.error(f"Could not find channel with ID {self.channel_id}")
|
|
|
|
async def process_log_line(self, line: str):
|
|
"""Process a single log line and send notifications if needed."""
|
|
if not self.notification_channel:
|
|
return
|
|
|
|
try:
|
|
# Check for item send
|
|
match = self.patterns['item_send'].search(line)
|
|
if match:
|
|
sender = match.group(1)
|
|
item = match.group(2)
|
|
receiver = match.group(3)
|
|
location = match.group(4)
|
|
await self.handle_item_send(sender, item, receiver, location)
|
|
return
|
|
|
|
# Check for player join
|
|
match = self.patterns['join'].search(line)
|
|
if match:
|
|
player = match.group(1)
|
|
game = match.group(2)
|
|
await self.handle_join(player, game)
|
|
return
|
|
|
|
# Check for player leave
|
|
match = self.patterns['leave'].search(line)
|
|
if match:
|
|
player = match.group(1)
|
|
await self.handle_leave(player)
|
|
return
|
|
|
|
# Check for tag changes (DeathLink, etc.)
|
|
match = self.patterns['tag_change'].search(line)
|
|
if match:
|
|
player = match.group(1)
|
|
tags = match.group(2)
|
|
await self.handle_tag_change(player, tags)
|
|
return
|
|
|
|
# Check for server start
|
|
match = self.patterns['server_start'].search(line)
|
|
if match:
|
|
address = match.group(1)
|
|
password = match.group(2)
|
|
await self.handle_server_start(address, password)
|
|
return
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing line: {line}")
|
|
logger.error(f"Error: {e}")
|
|
|
|
async def handle_item_send(self, sender: str, item: str, receiver: str, location: str):
|
|
"""Handle an item send notification."""
|
|
# Clean up item name (remove underscores, make readable)
|
|
item_display = item.replace('_', ' ')
|
|
location_display = location.replace('_', ' ')
|
|
|
|
embed = discord.Embed(
|
|
title="📦 Item Sent!",
|
|
color=discord.Color.green()
|
|
)
|
|
|
|
embed.add_field(name="Item", value=item_display, inline=False)
|
|
embed.add_field(name="From", value=sender, inline=True)
|
|
embed.add_field(name="To", value=receiver, inline=True)
|
|
embed.add_field(name="Location", value=location_display, inline=False)
|
|
|
|
try:
|
|
await self.notification_channel.send(embed=embed)
|
|
logger.info(f"Item send: {sender} sent {item} to {receiver} at {location}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Discord message: {e}")
|
|
|
|
async def handle_join(self, player: str, game: str):
|
|
"""Handle a player join notification."""
|
|
# Clean up game name
|
|
game_display = game.replace('_', ' ')
|
|
|
|
embed = discord.Embed(
|
|
description=f"👋 **{player}** joined playing **{game_display}**",
|
|
color=discord.Color.blue()
|
|
)
|
|
|
|
try:
|
|
await self.notification_channel.send(embed=embed)
|
|
logger.info(f"Player join: {player} playing {game}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Discord message: {e}")
|
|
|
|
async def handle_leave(self, player: str):
|
|
"""Handle a player disconnect notification."""
|
|
embed = discord.Embed(
|
|
description=f"👋 **{player}** left the game",
|
|
color=discord.Color.greyple()
|
|
)
|
|
|
|
try:
|
|
await self.notification_channel.send(embed=embed)
|
|
logger.info(f"Player leave: {player}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Discord message: {e}")
|
|
|
|
async def handle_tag_change(self, player: str, tags: str):
|
|
"""Handle a tag change notification (like enabling DeathLink)."""
|
|
# Only notify for interesting tags
|
|
if 'DeathLink' in tags:
|
|
embed = discord.Embed(
|
|
description=f"💀 **{player}** enabled **DeathLink**",
|
|
color=discord.Color.dark_red()
|
|
)
|
|
|
|
try:
|
|
await self.notification_channel.send(embed=embed)
|
|
logger.info(f"Tag change: {player} -> {tags}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Discord message: {e}")
|
|
|
|
async def handle_server_start(self, address: str, password: str):
|
|
"""Handle server start notification."""
|
|
embed = discord.Embed(
|
|
title="🎮 Archipelago Server Started",
|
|
color=discord.Color.green()
|
|
)
|
|
|
|
embed.add_field(name="Address", value=address, inline=False)
|
|
|
|
# Only show password if it's not sensitive
|
|
if password and password != "None":
|
|
embed.add_field(name="Password", value=password, inline=False)
|
|
|
|
try:
|
|
await self.notification_channel.send(embed=embed)
|
|
logger.info(f"Server started at {address}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send Discord message: {e}")
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
"""Setup function to add the cog to the bot."""
|
|
await bot.add_cog(ArchipelagoMonitor(bot)) |