diff --git a/README.md b/README.md index a536287..854088a 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ Note: This is a work in progress. It has only been tested on **Python 3.12.6**. ## Features -- Monitors Icecast stream metadata and announces track changes -- Configurable via YAML files and command line arguments -- Supports running multiple bot instances simultaneously -- Pattern-based song title filtering -- Configurable logging levels and output -- Smart URL resolution for metadata fetching -- Automatic reconnection and error recovery with status reporting -- Admin commands with permission system +- Monitors Icecast streams for metadata changes +- Announces new tracks in IRC channels +- Supports multiple IRC networks and channels +- Customizable announcement formats +- Command system with admin privileges +- Automatic reconnection on network issues +- Multiple bot instances can be managed together +- Systemd service integration ## Dependencies @@ -37,21 +37,21 @@ Create a YAML config file (default: `config.yaml`): ```yaml irc: - host: "irc.example.net" + host: "irc.libera.chat" port: 6667 - nick: "MusicBot" - user: "musicbot" - realname: "Music Announcer Bot" - channel: "#music" + nick: "IcecastBot" + user: "icecastbot" + realname: "Icecast IRC Bot" + channel: "#yourchannel" stream: - url: "https://stream.example.com" - endpoint: "stream" - health_check_interval: 300 + url: "https://your.stream.url" # Base URL without /stream or .mp3 + endpoint: "/stream" # The endpoint part (e.g. /stream, /radio.mp3) + health_check_interval: 300 # How often to check health status (in seconds) announce: - format: "\x02Now playing:\x02 {song}" - ignore_patterns: + format: "\x02Now playing:\x02 {song}" # Format for song announcements + ignore_patterns: # Don't announce songs matching these patterns - "Unknown" - "Unable to fetch metadata" - "Error fetching metadata" @@ -61,12 +61,14 @@ commands: require_nick_prefix: false # If true, commands must be prefixed with "botname: " or "botname, " allow_private_commands: false # If true, allows commands in private messages +help: # Help message templates + specific_format: "\x02{prefix}{cmd}\x02: {desc}" # Format for specific command help + list_format: "(\x02{cmd}\x02, {desc})" # Format for commands in list + list_separator: " | " # Separator between commands in list + admin: users: # List of users who can use admin commands (use "*" for anyone) - "*" - -logging: - level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, or CRITICAL ``` ## Usage @@ -119,35 +121,20 @@ Run multiple instances with different configs: python main.py config1.yaml config2.yaml config3.yaml ``` -## IRC Commands +## Commands -Regular commands: -- `!np`: Shows the currently playing track -- `!help`: Shows available commands +The bot supports the following commands: -Admin commands: -- `!start`: Start stream monitoring -- `!stop`: Stop stream monitoring -- `!reconnect`: Reconnect to stream (with status feedback) -- `!restart`: Restart the bot (requires using bot.sh) -- `!quit`: Shutdown the bot +- `!np` - Show the currently playing song +- `!help` - Show available commands or help for a specific command -## Logging +Admin commands (only available to users listed in the `admin.users` config): -The bot supports different logging levels configurable in the config.yaml: -- DEBUG: Detailed information for troubleshooting -- INFO: General operational messages (default) -- WARNING: Warning messages and potential issues -- ERROR: Error messages only -- CRITICAL: Critical failures only - -Logs include: -- Stream health status -- Command processing -- Connection status -- Error details - -The bot also maintains an ERROR.log file for critical issues. +- `!start` - Start stream monitoring +- `!stop` - Stop stream monitoring +- `!reconnect` - Reconnect to the stream +- `!restart` - Restart the bot +- `!quit` - Shutdown the bot ## Error Handling diff --git a/VERSION b/VERSION index 1cc5f65..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 \ No newline at end of file +1.1.0 diff --git a/config.yaml.example b/config.yaml.example index 71bc729..a56efc4 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -30,10 +30,4 @@ help: # Help message templates admin: users: # List of users who can use admin commands (use "*" for anyone) - - "*" - -logging: - level: "INFO" # Logging level: DEBUG, INFO, WARNING, ERROR, or CRITICAL - format: "%(asctime)s - %(levelname)s - %(message)s" # Format for console logs - error_format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" # Format for error logs - datefmt: "%H:%M:%S" # Date/time format for log timestamps \ No newline at end of file + - "*" \ No newline at end of file diff --git a/icecast-irc-bot-manager.py b/icecast-irc-bot-manager.py index 98b1d57..d9eaa14 100644 --- a/icecast-irc-bot-manager.py +++ b/icecast-irc-bot-manager.py @@ -3,93 +3,368 @@ import argparse import asyncio import json -import logging import os import signal import sys import tempfile +import time from pathlib import Path from typing import Dict, List, Optional -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -logger = logging.getLogger('icecast-irc-bot-manager') +# ANSI color codes for terminal output if needed +class Colors: + COLORS = [ + '\033[94m', # BLUE + '\033[92m', # GREEN + '\033[95m', # MAGENTA + '\033[93m', # YELLOW + '\033[96m', # CYAN + '\033[91m', # RED + '\033[38;5;208m', # ORANGE + '\033[38;5;165m', # PURPLE + '\033[38;5;39m', # DEEP BLUE + '\033[38;5;82m', # LIME + ] + ENDC = '\033[0m' + BOLD = '\033[1m' + + # Additional colors for output + GREY = '\033[37m' + WHITE = '\033[97m' + RED = '\033[91m' + ORANGE = '\033[38;5;208m' # Using 256-color code for orange + CYAN = '\033[96m' + MAGENTA = '\033[95m' class BotManager: """Manages multiple Icecast IRC bot instances.""" def __init__(self): self.bots: Dict[str, asyncio.subprocess.Process] = {} - self.config_dir = Path('/etc/icecast-irc-bot') + self.config_dir = Path('.') # Use current directory instead of /etc/icecast-irc-bot self.socket_dir = Path(tempfile.gettempdir()) + self.state_file = Path(tempfile.gettempdir()) / 'icecast-irc-bots.json' + self.monitor_task = None + self.venv_python = os.getenv('VIRTUAL_ENV', '/opt/icecast-irc-bot/venv') + '/bin/python3' + # If venv_python doesn't exist, use the current Python interpreter + if not os.path.exists(self.venv_python): + self.venv_python = sys.executable - async def start_bot(self, config_path: Path) -> bool: - """Start a bot instance with the given config. + def _save_state(self): + """Save the current state of running bots.""" + try: + # Load existing state + existing_state = self._load_state() + + # Update with current bots + current_state = { + bot_id: { + 'pid': process.pid, + 'config': str(self.config_dir / f"{bot_id}.yaml") + } + for bot_id, process in self.bots.items() + } + + # Merge states, with current bots taking precedence + merged_state = {**existing_state, **current_state} + + # Save merged state + with open(self.state_file, 'w') as f: + json.dump(merged_state, f) + + print(f"Saved state with {len(merged_state)} bots") + except Exception as e: + print(f"Error saving state: {e}") + pass + + def _load_state(self): + """Load the state of running bots.""" + try: + if self.state_file.exists(): + with open(self.state_file, 'r') as f: + state = json.load(f) + return state + return {} + except Exception: + return {} + + async def monitor_processes(self): + """Monitor running bot processes and clean up dead ones.""" + while True: + try: + for bot_id in list(self.bots.keys()): + process = self.bots[bot_id] + try: + # Check if process exists in system + os.kill(process.pid, 0) + # Check if process has terminated + if process.returncode is not None: + await self._cleanup_process(process) + del self.bots[bot_id] + except ProcessLookupError: + await self._cleanup_process(process) + del self.bots[bot_id] + except Exception: + pass + except Exception: + pass + await asyncio.sleep(5) # Check every 5 seconds + + async def _cleanup_process(self, process: asyncio.subprocess.Process): + """Clean up a process and its resources. Args: - config_path: Path to the bot's config file + process: The process to clean up + """ + try: + print(f"Cleaning up process with PID {process.pid}") + # Wait for the process to finish if it hasn't + if process.returncode is None: + try: + print(f"Terminating process {process.pid}") + process.terminate() + await asyncio.wait_for(process.wait(), timeout=5.0) + except (asyncio.TimeoutError, ProcessLookupError) as e: + print(f"Error terminating process: {e}") + try: + print(f"Killing process {process.pid}") + process.kill() + await process.wait() + except ProcessLookupError as e: + print(f"Process already gone: {e}") + pass + + # Drain any remaining output to prevent deadlocks + if process.stdout: + try: + data = await process.stdout.read() + if data: + print(f"Remaining stdout: {data.decode().strip()}") + except (ValueError, IOError) as e: + print(f"Error reading stdout: {e}") + pass + if process.stderr: + try: + data = await process.stderr.read() + if data: + print(f"Remaining stderr: {data.decode().strip()}") + except (ValueError, IOError) as e: + print(f"Error reading stderr: {e}") + pass + + except Exception as e: + print(f"Exception during cleanup: {e}") + pass + + async def start_bot(self, config_path: Path) -> bool: + """Start a new bot instance with the given config file. + + Args: + config_path: Path to the config file Returns: bool: True if bot was started successfully """ try: - # Create unique name for this bot instance + # Generate a unique ID for this bot based on the config file name bot_id = config_path.stem - # Check if bot is already running - if bot_id in self.bots: - logger.warning(f"Bot {bot_id} is already running") - return False + # Check if a bot with this ID is already running + state = self._load_state() + if bot_id in state: + try: + pid = state[bot_id]['pid'] + os.kill(pid, 0) # Check if process exists + return False + except ProcessLookupError: + # Process doesn't exist, remove from state + pass - # Start the bot process + # Start the bot process using venv Python process = await asyncio.create_subprocess_exec( - sys.executable, '-m', 'main', + self.venv_python, 'main.py', '--config', str(config_path), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - self.bots[bot_id] = process - logger.info(f"Started bot {bot_id} (PID: {process.pid})") - return True + # Verify process started successfully + try: + os.kill(process.pid, 0) + except ProcessLookupError: + await self._cleanup_process(process) + return False - except Exception as e: - logger.error(f"Failed to start bot with config {config_path}: {e}") + # Start the monitor task if not already running + if self.monitor_task is None: + self.monitor_task = asyncio.create_task(self.monitor_processes()) + + # Monitor the process output for a short time to ensure it starts properly + try: + startup_timeout = 5.0 # Give it 5 seconds to start + start_time = time.time() + success = False + + while time.time() - start_time < startup_timeout: + # Check if process has exited + if process.returncode is not None: + await self._cleanup_process(process) + return False + + # Read any available output during startup + try: + line = await asyncio.wait_for(process.stderr.readline(), timeout=0.1) + if line: + line = line.decode().strip() + print(f"Bot output: {line}") + # Consider the bot started successfully after a short delay + # instead of looking for specific output + success = True + except asyncio.TimeoutError: + # No output available, continue monitoring + continue + except Exception as e: + print(f"Error reading bot output: {e}") + pass + + # Consider the bot started successfully if it's still running after the timeout + if not success and process.returncode is None: + success = True + + # Start background task to monitor process output + async def monitor_output(process, bot_id): + try: + while True: + if process.returncode is not None: + break + try: + line = await process.stderr.readline() + if not line: + break + # Print the bot's output + print(f"Bot {bot_id} output: {line.decode().strip()}") + except Exception as e: + print(f"Error monitoring bot {bot_id}: {e}") + break + except Exception as e: + print(f"Monitor task exception for bot {bot_id}: {e}") + pass + + asyncio.create_task(monitor_output(process, bot_id)) + + # Store the process + self.bots[bot_id] = process + self._save_state() + return True + + except Exception: + await self._cleanup_process(process) + return False + + except Exception: return False async def stop_bot(self, bot_id: str) -> bool: """Stop a running bot instance. + Args: + bot_id: ID of the bot to stop, or "all" to stop all bots + + Returns: + bool: True if bot(s) were stopped successfully + """ + if bot_id == "all": + print("Stopping all bots...") + success = True + state = self._load_state() + for bid in list(state.keys()): + print(f"Stopping bot {bid}...") + if not await self._stop_single_bot(bid): + success = False + return success + + # Stop a single bot + return await self._stop_single_bot(bot_id) + + async def _stop_single_bot(self, bot_id: str) -> bool: + """Stop a single bot instance. + Args: bot_id: ID of the bot to stop Returns: bool: True if bot was stopped successfully """ - if bot_id not in self.bots: - logger.warning(f"Bot {bot_id} is not running") - return False - - try: + # Check both local bots and state file + state = self._load_state() + process = None + + if bot_id in self.bots: process = self.bots[bot_id] - process.terminate() + elif bot_id in state: try: - await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: - process.kill() - await process.wait() - - del self.bots[bot_id] - logger.info(f"Stopped bot {bot_id}") - return True - - except Exception as e: - logger.error(f"Failed to stop bot {bot_id}: {e}") + pid = state[bot_id]['pid'] + print(f"Stopping bot {bot_id} with PID {pid}") + # Try to kill the process + try: + # First try a gentle termination + os.kill(pid, signal.SIGTERM) + print(f"Sent SIGTERM to process {pid}") + + # Wait a bit for the process to terminate + for i in range(50): # 5 seconds + await asyncio.sleep(0.1) + try: + os.kill(pid, 0) # Check if process exists + except ProcessLookupError: + print(f"Process {pid} terminated successfully") + break + else: + # Process didn't terminate, force kill + print(f"Process {pid} didn't terminate, sending SIGKILL") + try: + os.kill(pid, signal.SIGKILL) + print(f"Sent SIGKILL to process {pid}") + except ProcessLookupError: + print(f"Process {pid} already terminated") + pass + except ProcessLookupError: + print(f"Process {pid} not found") + pass + + # Remove only this bot from state + if bot_id in state: + print(f"Removing {bot_id} from state file") + del state[bot_id] + with open(self.state_file, 'w') as f: + json.dump(state, f) + print(f"State file updated, remaining bots: {list(state.keys())}") + return True + except Exception as e: + print(f"Error stopping bot {bot_id}: {e}") + return False + else: + print(f"Bot {bot_id} not found") return False + + if process: + try: + print(f"Cleaning up process for bot {bot_id}") + await self._cleanup_process(process) + del self.bots[bot_id] + + # Update state file - only remove this bot + state = self._load_state() + if bot_id in state: + print(f"Removing {bot_id} from state file") + del state[bot_id] + with open(self.state_file, 'w') as f: + json.dump(state, f) + print(f"State file updated, remaining bots: {list(state.keys())}") + return True + except Exception as e: + print(f"Error cleaning up process for bot {bot_id}: {e}") + return False async def restart_bot(self, bot_id: str) -> bool: """Restart a running bot instance. @@ -103,7 +378,6 @@ class BotManager: # Find the config file for this bot config_path = self.config_dir / f"{bot_id}.yaml" if not config_path.exists(): - logger.error(f"Config file not found for bot {bot_id}") return False # Stop the bot if it's running @@ -114,81 +388,175 @@ class BotManager: # Start the bot with the same config return await self.start_bot(config_path) - async def list_bots(self) -> List[Dict]: - """List all running bot instances. + async def list_bots(self) -> bool: + """List all running bots. Returns: - List[Dict]: List of bot info dictionaries + bool: True if any bots are running """ - bot_info = [] - for bot_id, process in self.bots.items(): - info = { - 'id': bot_id, - 'pid': process.pid, - 'running': process.returncode is None - } - bot_info.append(info) - return bot_info + state = self._load_state() + + # Check if any bots are running + if not state: + print("No bots running") + return False + + # Track unique PIDs to avoid duplicates + seen_pids = set() + unique_bots = {} + + # Filter out duplicates based on PID + for bot_id, info in state.items(): + pid = info.get('pid') + if pid and pid not in seen_pids: + seen_pids.add(pid) + unique_bots[bot_id] = info + + if not unique_bots: + print("No bots running") + return False + + # Print header + print("\nRunning Bots:") + print("-" * 80) + print(f"{'ID':<20} {'PID':<8} {'Status':<10} {'Command':<40}") + print("-" * 80) + + # Print each bot's status + for bot_id, info in unique_bots.items(): + pid = info.get('pid') + config = info.get('config', '') + + # Check if process is still running + try: + os.kill(pid, 0) + status = "running" + except ProcessLookupError: + status = "stopped" + continue # Skip stopped processes + except Exception: + status = "unknown" + + # Get command line + try: + with open(f"/proc/{pid}/cmdline", 'rb') as f: + cmdline = f.read().replace(b'\0', b' ').decode() + except Exception: + cmdline = f"Unknown (PID: {pid})" + + print(f"{bot_id:<20} {pid:<8} {status:<10} {cmdline:<40}") + + print("-" * 80) + print() + + return True async def cleanup(self): """Clean up all running bots.""" - for bot_id in list(self.bots.keys()): - await self.stop_bot(bot_id) + if self.monitor_task: + self.monitor_task.cancel() + try: + await self.monitor_task + except asyncio.CancelledError: + pass + + await self.stop_bot("all") async def main(): parser = argparse.ArgumentParser(description='Icecast IRC Bot Manager') parser.add_argument('--config', type=str, help='Path to config file or directory') parser.add_argument('command', choices=['start', 'stop', 'restart', 'list'], help='Command to execute') - parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart commands') - + parser.add_argument('bot_id', nargs='?', help='Bot ID for start/stop/restart commands, or "all" to stop all bots') + args = parser.parse_args() manager = BotManager() + should_cleanup = False # Only cleanup for certain commands try: if args.command == 'list': - bot_info = await manager.list_bots() - if bot_info: - print(json.dumps(bot_info, indent=2)) - else: - print("No bots running") - + if await manager.list_bots(): + # If list_bots returns True, we've printed the list + pass elif args.command == 'start': + should_cleanup = True # Need cleanup for start command if not args.config: print("Error: --config required for start command") sys.exit(1) config_path = Path(args.config) if config_path.is_dir(): # Start all bots in directory + success = True for config_file in config_path.glob('*.yaml'): - await manager.start_bot(config_file) + if not await manager.start_bot(config_file): + success = False + if not success: + sys.exit(1) else: # Start single bot - await manager.start_bot(config_path) + if not await manager.start_bot(config_path): + sys.exit(1) + + # If we started any bots successfully, keep running until interrupted + if manager.bots: + try: + # Keep the manager running + while True: + await asyncio.sleep(1) + if not manager.bots: + break + except asyncio.CancelledError: + pass elif args.command == 'stop': + # Don't need cleanup for stop command as it already cleans up + should_cleanup = False if not args.bot_id: - print("Error: bot_id required for stop command") + print("Error: bot_id required for stop command (use 'all' to stop all bots)") + sys.exit(1) + if not await manager.stop_bot(args.bot_id): sys.exit(1) - await manager.stop_bot(args.bot_id) elif args.command == 'restart': + should_cleanup = True # Need cleanup for restart command if not args.bot_id: print("Error: bot_id required for restart command") sys.exit(1) - await manager.restart_bot(args.bot_id) + if args.bot_id == "all": + print("Error: restart all is not supported") + sys.exit(1) + if not await manager.restart_bot(args.bot_id): + sys.exit(1) + + # If we restarted successfully, keep running until interrupted + if manager.bots: + try: + # Keep the manager running + while True: + await asyncio.sleep(1) + if not manager.bots: + break + except asyncio.CancelledError: + pass except KeyboardInterrupt: - logger.info("Shutting down...") - await manager.cleanup() - except Exception as e: - logger.error(f"Unhandled error: {e}") + should_cleanup = True # Need cleanup for keyboard interrupt + except Exception: + should_cleanup = True # Need cleanup for errors sys.exit(1) + finally: + # Only clean up if we need to + if should_cleanup: + await manager.cleanup() if __name__ == '__main__': - # Set up signal handlers + # Set up signal handlers for graceful shutdown + def signal_handler(sig, frame): + # Don't exit immediately, let the cleanup happen + asyncio.get_event_loop().stop() + for sig in (signal.SIGTERM, signal.SIGINT): - signal.signal(sig, lambda s, f: sys.exit(0)) + signal.signal(sig, signal_handler) # Run the manager asyncio.run(main()) \ No newline at end of file diff --git a/icecast-irc-bot-manager.service b/icecast-irc-bot-manager.service index e6728d5..d02c75e 100644 --- a/icecast-irc-bot-manager.service +++ b/icecast-irc-bot-manager.service @@ -13,8 +13,8 @@ Environment=PATH=/opt/icecast-irc-bot/venv/bin:$PATH ExecStart=/opt/icecast-irc-bot/venv/bin/python3 /usr/local/bin/icecast-irc-bot-manager --config /etc/icecast-irc-bot/config.yaml Restart=on-failure RestartSec=5s -StandardOutput=append:/var/log/icecast-irc-bot/bot.log -StandardError=append:/var/log/icecast-irc-bot/error.log +StandardOutput=journal +StandardError=journal # Security settings NoNewPrivileges=yes diff --git a/main.py b/main.py old mode 100644 new mode 100755 index a1b6e76..f62d30a --- a/main.py +++ b/main.py @@ -1,142 +1,34 @@ #!/usr/bin/env python3 -# Set up logging configuration before imports -import logging +import re +import json import os +import sys +import asyncio +import aiohttp +import time +import argparse +import yaml +from pathlib import Path +from typing import List, Optional +import sys +import inspect +import socket +import tempfile +import signal -class AsifFilter(logging.Filter): - def filter(self, record): - # Block messages from asif module or if they start with "Joined channel" - return not (record.module == 'asif' or - record.name.startswith('asif') or - (isinstance(record.msg, str) and record.msg.startswith('Joined channel'))) - -# ANSI color codes +# ANSI color codes for backward compatibility if needed class Colors: - COLORS = [ - '\033[94m', # BLUE - '\033[96m', # CYAN - '\033[92m', # GREEN - '\033[93m', # YELLOW - '\033[95m', # MAGENTA - '\033[91m', # RED - ] - ENDC = '\033[0m' - BOLD = '\033[1m' - - # Additional colors for log levels - GREY = '\033[37m' - WHITE = '\033[97m' - RED = '\033[91m' - ORANGE = '\033[38;5;208m' # Using 256-color code for orange - CYAN = '\033[96m' - MAGENTA = '\033[95m' - - # Track used colors - used_colors = {} # botname -> color mapping - - @classmethod - def get_color_for_bot(cls, botname: str) -> str: - """Get a consistent color for a bot based on its name.""" - # If this bot already has a color, return it - if botname in cls.used_colors: - return cls.used_colors[botname] - - # If we still have unused colors, use the next available one - unused_colors = [c for c in cls.COLORS if c not in cls.used_colors.values()] - if unused_colors: - color = unused_colors[0] - else: - # If we're out of unique colors, fall back to hash-based selection - color = cls.COLORS[hash(botname) % len(cls.COLORS)] - - cls.used_colors[botname] = color - return color - -class ColoredLevelFormatter(logging.Formatter): - """Custom formatter that adds colors to log levels and ensures fixed width.""" - - # Color mapping for different log levels - LEVEL_COLORS = { - 'DEBUG': Colors.CYAN, - 'INFO': Colors.WHITE, - 'WARNING': Colors.ORANGE, - 'ERROR': Colors.RED, - 'CRITICAL': Colors.MAGENTA - } - - def format(self, record): - # Save original level name - original_level = record.levelname - # Pad level name to 8 characters (length of 'WARNING') - colored_level = f"{self.LEVEL_COLORS.get(original_level, Colors.WHITE)}{original_level:8}{Colors.ENDC}" - record.levelname = colored_level - result = super().format(record) - # Restore original level name - record.levelname = original_level - return result - -# Defer logging setup until after config is loaded -def setup_logging(name: str, config: dict) -> logging.Logger: - """Set up logging with the specified configuration. - - Args: - name: The name for this logger instance - config: The logging configuration dictionary containing: - - level: The log level to use - - format: The log format template - - datefmt: The date format template - - Returns: - logging.Logger: The configured logger instance - """ - # Get logging configuration with defaults - log_config = config.get('logging', {}) - log_level = log_config.get('level', 'INFO') - log_format = log_config.get('format', '%(asctime)s - %(levelname)s - %(message)s') - date_format = log_config.get('datefmt', '%H:%M:%S') - - # Convert string level to logging constant - numeric_level = getattr(logging, log_level.upper(), logging.INFO) - - # Create a logger with the given name - logger = logging.getLogger(name) - logger.setLevel(numeric_level) - - # Create console handler with colored formatter - console_handler = logging.StreamHandler() - console_handler.setFormatter( - ColoredLevelFormatter(log_format, datefmt=date_format) - ) - - # Add AsifFilter to the logger - logger.addFilter(AsifFilter()) - - # Add the console handler to logger - logger.addHandler(console_handler) - - # Prevent propagation to root logger - logger.propagate = False - - return logger - -# Set up error logging for asif -def create_error_handler(bot_name: str, config: dict) -> logging.FileHandler: - """Create an error handler for the given bot instance. - - Args: - bot_name: The name of the bot instance - config: The logging configuration dictionary - """ - handler = logging.FileHandler(f'ERROR_{bot_name}.log') - # Get error log format from config or use default - log_config = config.get('logging', {}) - error_format = log_config.get('error_format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s') - date_format = log_config.get('datefmt', '%H:%M:%S') - - # Use the colored formatter for error logs too - handler.setFormatter(ColoredLevelFormatter(error_format, datefmt=date_format)) - return handler + BLUE = "\033[94m" + CYAN = "\033[96m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + MAGENTA = "\033[95m" + RED = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + WHITE = "\033[97m" + ORANGE = "\033[38;5;208m" from asif import Client @@ -149,21 +41,11 @@ def patch_client_bg(self, coro): except SystemExit: raise # Re-raise SystemExit except Exception: - self._log.exception("async: Coroutine raised exception") + pass return asyncio.ensure_future(runner()) -# Patch asif's Client class to use error logger and patched _bg +# Patch asif's Client class def silent_client_init(self, *args, bot_name: str = None, config: dict = None, **kwargs): - # Create a logger that writes to ERROR.log - error_logger = logging.getLogger(f'asif.client.{bot_name}' if bot_name else 'asif.client') - error_handler = create_error_handler(bot_name, config) if bot_name else logging.FileHandler('ERROR.log') - error_logger.addHandler(error_handler) - error_logger.propagate = False # Don't send to console - error_logger.setLevel(logging.INFO) # Capture all messages - - # Store the logger - self._log = error_logger - # Patch the _bg method self._bg = patch_client_bg.__get__(self) @@ -175,9 +57,7 @@ original_init = Client.__init__ Client.__init__ = silent_client_init import asyncio -import re import aiohttp -import json import time import argparse import yaml @@ -185,58 +65,9 @@ from pathlib import Path from typing import List, Optional import sys import inspect -import os import socket import tempfile -class BotLoggerAdapter(logging.LoggerAdapter): - # Class variables to track maximum lengths - max_nick_length = 0 - max_endpoint_length = 0 - instances = [] # Keep track of all instances to update padding - - def __init__(self, logger, extra): - super().__init__(logger, extra) - botname = extra['botname'] - nick, endpoint = botname.split('@') - self.nick = nick - self.endpoint = endpoint - - # Update max lengths (without ANSI codes) - old_max_nick = BotLoggerAdapter.max_nick_length - old_max_endpoint = BotLoggerAdapter.max_endpoint_length - - BotLoggerAdapter.max_nick_length = max( - BotLoggerAdapter.max_nick_length, - len(nick) - ) - BotLoggerAdapter.max_endpoint_length = max( - BotLoggerAdapter.max_endpoint_length, - len(endpoint) - ) - - # If max lengths changed, update all existing instances - if (old_max_nick != BotLoggerAdapter.max_nick_length or - old_max_endpoint != BotLoggerAdapter.max_endpoint_length): - for instance in BotLoggerAdapter.instances: - instance.update_padding() - - # Add self to instances list - BotLoggerAdapter.instances.append(self) - - # Initial padding calculation - self.update_padding() - - def update_padding(self): - """Update the colored botname with current padding requirements.""" - # Right-align nick, then @ symbol, then colored endpoint - nick_padding = " " * (BotLoggerAdapter.max_nick_length - len(self.nick)) - endpoint_padding = " " * (BotLoggerAdapter.max_endpoint_length - len(self.endpoint)) - self.colored_botname = f"{nick_padding}{self.nick}@{Colors.BOLD}{Colors.get_color_for_bot(self.nick+'@'+self.endpoint)}{self.endpoint}{Colors.ENDC}{endpoint_padding}" - - def process(self, msg, kwargs): - return f'[{self.colored_botname}] {msg}', kwargs - class RestartManager: """Manages bot restarts using Unix Domain Sockets. @@ -261,11 +92,20 @@ class RestartManager: if self.socket_path.exists(): self.socket_path.unlink() + # Ensure the parent directory exists + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + # Create the Unix Domain Socket server - self.server = await asyncio.start_unix_server( - self._handle_restart_request, - str(self.socket_path) - ) + try: + self.server = await asyncio.start_unix_server( + self._handle_restart_request, + str(self.socket_path) + ) + print(f"Restart manager server started at {self.socket_path}") + except Exception as e: + print(f"Error starting restart manager server: {e}") + # Continue without the restart manager + pass async def _handle_restart_request(self, reader, writer): """Handle an incoming restart request.""" @@ -276,7 +116,7 @@ class RestartManager: writer.close() await writer.wait_closed() except Exception as e: - logging.error(f"Error handling restart request: {e}") + pass def cleanup(self): """Clean up the socket file.""" @@ -286,7 +126,7 @@ class RestartManager: if self.socket_path.exists(): self.socket_path.unlink() except Exception as e: - logging.error(f"Error cleaning up restart manager: {e}") + pass @staticmethod async def signal_restart(bot_id: str): @@ -303,16 +143,7 @@ class RestartManager: writer.close() await writer.wait_closed() except Exception as e: - logging.error(f"Error signaling restart to bot {bot_id}: {e}") - -class IcecastBot: - def __init__(self, config_path: Optional[str] = None): - # Store config path for potential restarts - self.config_path = config_path - - # Load config - self.config = self.load_config(config_path) - ... + pass class IcecastBot: """An IRC bot that monitors an Icecast stream and announces track changes. @@ -342,42 +173,45 @@ class IcecastBot: Args: config_path: Path to the YAML configuration file. If None, uses default path. """ + print(f"Initializing IcecastBot with config path: {config_path}") # Store config path for potential restarts self.config_path = config_path # Load config + print("Loading configuration...") self.config = self.load_config(config_path) + print(f"Configuration loaded: {self.config}") # Create unique bot ID from nick and endpoint self.bot_id = f"{self.config['irc']['nick']}_{self.config['stream']['endpoint']}" + print(f"Bot ID: {self.bot_id}") # Initialize restart manager + print("Initializing restart manager...") self.restart_manager = RestartManager(self.bot_id) - # Set up bot-specific logger + # Set up bot name bot_name = f'{self.config["irc"]["nick"]}@{self.config["stream"]["endpoint"]}' - base_logger = setup_logging( - f'icecast_bot.{bot_name}', - self.config - ) + print(f"Bot name: {bot_name}") - self.logger = BotLoggerAdapter( - base_logger, - {'botname': bot_name} - ) - - self.logger.info(f"Starting Icecast IRC Bot v{self.VERSION}") - - # Initialize IRC bot with config and bot name for logging - self.bot = Client( - host=self.config['irc']['host'], - port=self.config['irc']['port'], - user=self.config['irc']['user'], - realname=self.config['irc']['realname'], - nick=self.config['irc']['nick'], - bot_name=bot_name, - config=self.config - ) + # Initialize IRC bot with config and bot name + print("Creating IRC client...") + try: + self.bot = Client( + host=self.config['irc']['host'], + port=self.config['irc']['port'], + user=self.config['irc']['user'], + realname=self.config['irc']['realname'], + nick=self.config['irc']['nick'], + bot_name=bot_name, + config=self.config + ) + print("IRC client created successfully") + except Exception as e: + print(f"Error creating IRC client: {e}") + import traceback + traceback.print_exc() + raise # Set up instance variables self.channel_name = self.config['irc']['channel'] @@ -431,40 +265,32 @@ class IcecastBot: Returns: dict: The loaded and validated configuration dictionary with default values applied. """ + print(f"Loading config from: {config_path}") if config_path is None: config_path = Path(__file__).parent / 'config.yaml' + print(f"No config path provided, using default: {config_path}") # Load config file try: + print(f"Opening config file: {config_path}") with open(config_path) as f: config = yaml.safe_load(f) + print(f"Config loaded successfully: {config}") except FileNotFoundError: + print(f"Config file not found: {config_path}, using defaults") config = { 'irc': {}, 'stream': {}, 'announce': { 'format': "\x02Now playing:\x02 {song}", - 'ignore_patterns': ['Unknown', 'Unable to fetch metadata'] - }, - 'logging': { - 'level': 'INFO', - 'format': '%(asctime)s - %(levelname)s - %(message)s', - 'error_format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - 'datefmt': '%H:%M:%S' + 'ignore_patterns': ["Unknown", "Unable to fetch metadata", "Error fetching metadata"] } } - - # Ensure logging config exists with defaults - if 'logging' not in config: - config['logging'] = {} - if 'format' not in config['logging']: - config['logging']['format'] = '%(asctime)s - %(levelname)s - %(message)s' - if 'error_format' not in config['logging']: - config['logging']['error_format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - if 'datefmt' not in config['logging']: - config['logging']['datefmt'] = '%H:%M:%S' - if 'level' not in config['logging']: - config['logging']['level'] = 'INFO' + except Exception as e: + print(f"Error loading config: {e}") + import traceback + traceback.print_exc() + raise return config @@ -492,16 +318,15 @@ class IcecastBot: async def connected(): try: self.channel = await self.bot.join(self.channel_name) - self.logger.info(f"Connected to IRC and joined {self.channel_name}") except Exception as e: - self.logger.error(f"Error joining channel: {e}") + pass if self.should_monitor: await self.start_monitoring() @self.bot.on_join() async def on_join(channel): - # Silently store the channel without logging + # Store the channel if not self.channel: self.channel = channel @@ -521,38 +346,29 @@ class IcecastBot: pattern = f"^({self.config['irc']['nick']}[:,] )?{re.escape(self.cmd_prefix)}{cmd}($| .*$)" else: pattern = f"^{re.escape(self.cmd_prefix)}{cmd}($| .*$)" - self.logger.debug(f"Created command pattern for '{cmd}': {pattern}") return re.compile(pattern) # Global message handler for debugging @self.bot.on_message() async def debug_message(message): - """Debug handler for logging all received messages. + """Handler for all received messages. Args: - message: The IRC message object to log. + message: The IRC message object. """ try: - # Log full message details only at DEBUG level - self.logger.debug( - f"Received message: " - f"recipient={getattr(message, 'recipient', None)!r}, " - f"sender={getattr(message, 'sender', None)!r}, " - f"text={getattr(message, 'text', None)!r}" - ) - # Test each command pattern msg = getattr(message, 'text', None) if msg and isinstance(msg, str): for cmd in ['np', 'help', 'restart', 'quit', 'reconnect', 'stop', 'start']: pattern = create_command_pattern(cmd) if pattern.match(msg): - self.logger.debug(f"Command matched: {cmd}") + pass - except Exception as e: - self.logger.error(f"Error in debug message handler: {e}") + except Exception: + pass - # Command handlers with additional debug logging + # Command handlers @self.bot.on_message(create_command_pattern('np')) async def now_playing(message): """!np: Show the currently playing song @@ -563,25 +379,19 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.debug(f"np handler called with message: {getattr(message, 'text', None)!r}") recipient = getattr(message, 'recipient', None) # Check if recipient is a Channel object is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#') - self.logger.debug(f"Recipient check - is_channel: {is_channel}, recipient: {recipient}") if not self.allow_private_commands and not is_channel: - self.logger.debug("Ignoring private np command") return try: if is_channel: await recipient.message(self.reply.format(song=self.current_song)) - self.logger.debug("Successfully sent np reply") - else: - self.logger.debug("Could not send np reply - invalid recipient") except Exception as e: - self.logger.error(f"Error in np handler: {e}", exc_info=True) + pass self.command_handlers['now_playing'] = now_playing @self.bot.on_message(create_command_pattern('help')) @@ -594,15 +404,12 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.debug(f"help handler called with message: {getattr(message, 'text', None)!r}") recipient = getattr(message, 'recipient', None) # Check if recipient is a Channel object is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#') - self.logger.debug(f"Recipient check - is_channel: {is_channel}, recipient: {recipient}") if not self.allow_private_commands and not is_channel: - self.logger.debug("Ignoring private help command") return try: @@ -610,20 +417,14 @@ class IcecastBot: msg_text = getattr(message, 'text', '') parts = msg_text.strip().split() - self.logger.debug(f"Available command patterns: {list(self.pattern_to_method.keys())}") - self.logger.debug(f"Admin commands: {list(self.admin_commands)}") - if len(parts) > 1: # Specific command help requested pattern = parts[1].lower() if pattern.startswith(self.cmd_prefix): pattern = pattern[len(self.cmd_prefix):] # Remove prefix if included - self.logger.debug(f"Looking up help for command pattern: {pattern}") - # Find the command handler using our mapping method_name = self.pattern_to_method.get(pattern) - self.logger.debug(f"Found method name: {method_name}") if method_name: handler = self.command_handlers.get(method_name) @@ -692,11 +493,8 @@ class IcecastBot: if is_channel: await recipient.message(help_text) - self.logger.debug("Successfully sent help reply") - else: - self.logger.debug("Could not send help reply - invalid recipient") except Exception as e: - self.logger.error(f"Error in help handler: {e}", exc_info=True) + pass self.command_handlers['help_command'] = help_command @self.bot.on_message(create_command_pattern('restart')) @@ -710,7 +508,6 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.info(f"Restart command received from {message.sender}") await self.stop_monitoring() try: @@ -719,7 +516,7 @@ class IcecastBot: await RestartManager.signal_restart(self.bot_id) self.should_exit = True except Exception as e: - self.logger.error(f"Error during restart: {e}") + pass self.command_handlers['restart_bot'] = restart_bot @self.bot.on_message(create_command_pattern('quit')) @@ -732,14 +529,13 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.info(f"Quit command received from {message.sender}") await self.stop_monitoring() try: await self.bot.quit("Shutting down...") self.should_exit = True except Exception as e: - self.logger.error(f"Error during shutdown: {e}") + pass self.command_handlers['quit_bot'] = quit_bot @self.bot.on_message(create_command_pattern('reconnect')) @@ -753,8 +549,6 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.info(f"Reconnect command received from {message.sender}") - success = await self.restart_monitoring() if not success and hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'): await message.recipient.message("Stream reconnection may have failed. Check logs for details.") @@ -771,7 +565,6 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.info(f"Stop command received from {message.sender}") await self.stop_monitoring() if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'): await message.recipient.message("Stream monitoring stopped.") @@ -788,7 +581,6 @@ class IcecastBot: Args: message: IRC message object """ - self.logger.info(f"Start command received from {message.sender}") await self.start_monitoring() if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'): await message.recipient.message("Stream monitoring started.") @@ -804,23 +596,16 @@ class IcecastBot: Command patterns are extracted from the first line of each handler's docstring, which must be in the format "!command: description". """ - self.logger.debug("Building command mappings...") - - # Get all command handlers for method_name, handler in self.command_handlers.items(): if not handler.__doc__: continue - self.logger.debug(f"Checking handler: {method_name}") - # Check if this is a command handler by looking at the docstring if handler.__doc__.strip().startswith('!'): # Extract command pattern from docstring first_line = handler.__doc__.strip().split('\n')[0] pattern = first_line.split(':', 1)[0].strip('!') - self.logger.debug(f"Found command in docstring: {pattern} -> {method_name}") - # Store both mappings self.pattern_to_method[pattern] = method_name self.method_to_pattern[method_name] = pattern @@ -830,19 +615,8 @@ class IcecastBot: for cell in handler.__closure__: if isinstance(cell.cell_contents, type(self.admin_required)): self.admin_commands.add(pattern) - self.logger.debug(f"Marked {pattern} as admin command") break - - self.logger.debug(f"Mapped command pattern '{pattern}' to method '{method_name}'") - # Log all mappings for debugging - self.logger.debug("Final command mappings:") - for pattern, method in self.pattern_to_method.items(): - self.logger.debug(f" {pattern} -> {method}") - self.logger.debug("Admin commands:") - for cmd in sorted(self.admin_commands): - self.logger.debug(f" {cmd}") - def is_admin(self, user: str) -> bool: """Check if a user has admin privileges. @@ -858,7 +632,6 @@ class IcecastBot: else: nickname = user.split('!')[0] if '!' in user else user except Exception as e: - self.logger.error(f"Error extracting nickname: {e}") return False return '*' in self.admin_users or nickname in self.admin_users @@ -873,7 +646,6 @@ class IcecastBot: self.should_monitor = True self.monitor_task = asyncio.create_task(self.monitor_metadata()) self.is_monitoring = True - self.logger.info("Started metadata monitoring task") async def stop_monitoring(self): """Stop the metadata monitoring task. @@ -894,7 +666,7 @@ class IcecastBot: self.current_process.kill() await self.current_process.wait() except Exception as e: - self.logger.error(f"Error terminating subprocess: {e}") + pass finally: self.current_process = None @@ -906,10 +678,9 @@ class IcecastBot: except asyncio.CancelledError: pass except Exception as e: - self.logger.error(f"Error cancelling monitor task: {e}") + pass finally: self.monitor_task = None - self.logger.info("Stopped metadata monitoring task") async def restart_monitoring(self): """Restart the metadata monitoring task and verify the reconnection. @@ -946,7 +717,6 @@ class IcecastBot: return False except Exception as e: - self.logger.error(f"Error during stream reconnection: {e}") if notify_channel and hasattr(notify_channel, 'name') and notify_channel.name.startswith('#'): await notify_channel.message(f"Failed to reconnect to stream: {str(e)}") return False @@ -971,7 +741,6 @@ class IcecastBot: for base_url in base_urls: try: url = f"{base_url}/status-json.xsl" - self.logger.debug(f"Attempting to fetch metadata from: {url}") async with aiohttp.ClientSession() as session: async with session.get(url) as response: @@ -992,16 +761,12 @@ class IcecastBot: return title except aiohttp.ClientError as e: - self.logger.debug(f"Failed to fetch from {url}: {e}") continue except json.JSONDecodeError as e: - self.logger.debug(f"Failed to parse JSON from {url}: {e}") continue - self.logger.warning("Unable to fetch metadata from any URL") return "Unable to fetch metadata" except Exception as e: - self.logger.error(f"Error fetching JSON metadata: {e}", exc_info=True) return "Error fetching metadata" async def monitor_metadata(self): @@ -1030,7 +795,6 @@ class IcecastBot: ) self.current_process = process # Store the process reference - self.logger.info("Started stream monitoring") last_data_received = time.time() buffer = b"" last_json_check = time.time() @@ -1048,28 +812,22 @@ class IcecastBot: empty_chunks_count = 0 # Reset counter on successful data else: empty_chunks_count += 1 - self.logger.warning(f"Received empty chunk from stream ({empty_chunks_count}/{max_empty_chunks})") if empty_chunks_count >= max_empty_chunks: - self.logger.error("Too many empty chunks in a row, restarting connection") break if time.time() - last_data_received > data_timeout: - self.logger.error(f"No data received for {data_timeout} seconds, restarting connection") break current_time = time.time() # Periodic health check if current_time - self.last_health_check >= self.health_check_interval: - self.logger.info( - f"Monitor status: Active - Last data received {int(current_time - last_data_received)}s ago" - ) + pass self.last_health_check = current_time # Look for metadata marker but fetch from JSON if b"StreamTitle='" in buffer: new_song = await self.fetch_json_metadata() if new_song and new_song != self.current_song and "Unable to fetch metadata" not in new_song: - self.logger.info(f"Now Playing: {new_song}") self.current_song = new_song await self.announce_song(new_song) @@ -1088,7 +846,6 @@ class IcecastBot: if time.time() - last_data_received > data_timeout: break if new_song and new_song != self.current_song: - self.logger.info(f"Now Playing (fallback): {new_song}") self.current_song = new_song await self.announce_song(new_song) last_json_check = current_time @@ -1096,13 +853,10 @@ class IcecastBot: await asyncio.sleep(0.1) except asyncio.TimeoutError: - self.logger.warning("Timeout while reading stream data") if time.time() - last_data_received > data_timeout: - self.logger.error("Stream read timeout exceeded limit, restarting connection") break continue except Exception as e: - self.logger.error(f"Error in stream processing loop: {e}") break # Check if process is still running and terminate if needed @@ -1111,19 +865,15 @@ class IcecastBot: process.terminate() await asyncio.wait_for(process.wait(), timeout=5.0) except asyncio.TimeoutError: - self.logger.warning("Process did not terminate gracefully, killing it") - process.kill() - await process.wait() + pass except Exception as e: - self.logger.error(f"Error terminating process: {e}") + pass finally: self.current_process = None - self.logger.warning("Stream monitor ended, restarting in 5 seconds...") await asyncio.sleep(5) except Exception as e: - self.logger.error(f"Stream monitor error: {e}") await asyncio.sleep(5) async def announce_song(self, song: str): @@ -1142,34 +892,89 @@ class IcecastBot: # Use the stored channel object directly if hasattr(self.channel, 'name') and self.channel.name.startswith('#'): await self.channel.message(self.reply.format(song=song)) - self.logger.debug(f"Successfully announced song: {song}") - else: - self.logger.error(f"Could not announce song - invalid channel object: {self.channel}") except Exception as e: - self.logger.error(f"Error announcing song: {e}", exc_info=True) + pass async def start(self): """Start the IRC bot and begin processing events.""" + print("Starting IcecastBot...") try: + # Create a state file for the manager to detect + state_file = Path(tempfile.gettempdir()) / 'icecast-irc-bots.json' + try: + # Load existing state if it exists + state = {} + if state_file.exists(): + try: + with open(state_file, 'r') as f: + state = json.load(f) + except json.JSONDecodeError: + # File exists but is not valid JSON + state = {} + + # Add this bot to the state + state[self.bot_id] = { + 'pid': os.getpid(), + 'config': str(self.config_path) + } + + # Save the state + with open(state_file, 'w') as f: + json.dump(state, f) + print(f"Created state file at {state_file}") + + # Register a signal handler to remove this bot from the state file on exit + def cleanup_state_file(signum, frame): + try: + if state_file.exists(): + with open(state_file, 'r') as f: + current_state = json.load(f) + if self.bot_id in current_state: + del current_state[self.bot_id] + with open(state_file, 'w') as f: + json.dump(current_state, f) + print(f"Removed {self.bot_id} from state file") + except Exception as e: + print(f"Error cleaning up state file: {e}") + sys.exit(0) + + # Register signal handlers + signal.signal(signal.SIGTERM, cleanup_state_file) + signal.signal(signal.SIGINT, cleanup_state_file) + + except Exception as e: + print(f"Error creating state file: {e}") + # Start the restart manager + print("Starting restart manager...") await self.restart_manager.start() + print("Restart manager started") # Start the bot + print("Starting IRC bot...") await self.bot.run() + print("IRC bot started") + except Exception as e: + print(f"Error starting bot: {e}") + import traceback + traceback.print_exc() finally: + print("In start() finally block") if self.should_exit: + print("Bot should exit, cleaning up...") # Clean up any remaining tasks if self.monitor_task: + print("Canceling monitor task...") self.monitor_task.cancel() try: await self.monitor_task except asyncio.CancelledError: pass - except Exception as e: - self.logger.error(f"Error during cleanup: {e}") # Clean up restart manager + print("Cleaning up restart manager...") self.restart_manager.cleanup() + print("Restart manager cleaned up") def format_help_section(self, section_config: dict, prefix: str) -> List[str]: """Format a help section according to the template. @@ -1231,9 +1036,8 @@ class IcecastBot: if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'): await message.recipient.message(help_text) - self.logger.debug("Successfully sent fallback help reply") except Exception as e: - self.logger.error(f"Error in help fallback handler: {e}", exc_info=True) + pass def admin_required(self, f): """Decorator to mark a command as requiring admin privileges. @@ -1286,10 +1090,10 @@ async def run_multiple_bots(config_paths: List[str]): # Create task for each bot tasks.append(asyncio.create_task(run_single_bot(bot))) except Exception as e: - logging.error(f"Failed to initialize bot with config {config_path}: {e}") + pass if not bots: - logging.error("No bots were successfully initialized") + pass return # Wait for all bots to complete @@ -1301,32 +1105,46 @@ async def run_single_bot(bot: IcecastBot): Args: bot: The IcecastBot instance to run """ + print(f"Running single bot with ID: {bot.bot_id}") try: + print("Starting bot...") await bot.start() + print("Bot start completed") # Check if we should restart this bot if bot.restart_manager.should_restart: + print("Bot should restart") # Clean up + print("Cleaning up restart manager...") bot.restart_manager.cleanup() # Create and start a new instance + print("Creating new bot instance...") new_bot = IcecastBot(bot.config_path) + print("Starting new bot instance...") await run_single_bot(new_bot) # If should_exit is True but should_restart is False, just exit cleanly elif bot.should_exit: - bot.logger.info("Bot shutting down...") + print("Bot should exit cleanly") bot.restart_manager.cleanup() except Exception as e: - bot.logger.error(f"Error running bot {bot.config['irc']['nick']}: {e}") + print(f"Error in run_single_bot: {e}") + import traceback + traceback.print_exc() finally: + print("In run_single_bot finally block") # Ensure cleanup happens if bot.monitor_task: + print("Canceling monitor task...") bot.monitor_task.cancel() try: await bot.monitor_task + print("Monitor task canceled") except asyncio.CancelledError: + print("Monitor task canceled with CancelledError") pass if __name__ == "__main__": + print("Starting Icecast IRC Bot...") parser = argparse.ArgumentParser(description='Icecast IRC Bot') parser.add_argument('configs', nargs='*', help='Paths to config files') parser.add_argument('--config', type=str, help='Path to single config file') @@ -1339,19 +1157,25 @@ if __name__ == "__main__": parser.add_argument('--cmd-prefix', type=str, help='Command prefix character(s)') args = parser.parse_args() + print(f"Arguments parsed: {args}") # Set up the event loop loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + print("Event loop created") async def run_bot(): try: + print("Starting bot...") if args.configs: # Multi-bot mode + print(f"Running in multi-bot mode with configs: {args.configs}") await run_multiple_bots(args.configs) else: # Single-bot mode + print(f"Running in single-bot mode with config: {args.config}") bot = IcecastBot(args.config) + print("Bot instance created") # Apply any command line overrides to the config if args.irc_host: @@ -1371,16 +1195,25 @@ if __name__ == "__main__": bot.config['commands'] = {} bot.config['commands']['prefix'] = args.cmd_prefix + print("Starting single bot...") await run_single_bot(bot) except Exception as e: - logging.error(f"Unhandled exception: {e}") + print(f"Error in run_bot: {e}") + import traceback + traceback.print_exc() sys.exit(1) try: # Run the bot + print("Running event loop...") loop.run_until_complete(run_bot()) + except Exception as e: + print(f"Error in main: {e}") + import traceback + traceback.print_exc() finally: try: + print("Cleaning up...") # Cancel any remaining tasks for task in asyncio.all_tasks(loop): task.cancel() @@ -1388,6 +1221,8 @@ if __name__ == "__main__": loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(loop), return_exceptions=True)) # Finally close the loop loop.close() + print("Cleanup complete") except Exception as e: - logging.error(f"Error during cleanup: {e}") - sys.exit(1) + print(f"Error during cleanup: {e}") + import traceback + traceback.print_exc() diff --git a/pyproject.toml b/pyproject.toml index a919de9..c4895b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ requires-python = ">=3.11" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "regex" +path = "VERSION" +pattern = "^(?P[0-9]+\\.[0-9]+\\.[0-9]+)$" + [tool.hatch.build.targets.wheel] packages = ["."]