2025-02-23 00:55:11 -08:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
import re
|
|
|
|
|
import json
|
2025-02-24 11:08:25 -08:00
|
|
|
import os
|
2025-02-24 21:48:51 -08:00
|
|
|
import sys
|
|
|
|
|
import asyncio
|
|
|
|
|
import aiohttp
|
|
|
|
|
import time
|
|
|
|
|
import argparse
|
|
|
|
|
import yaml
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
import inspect
|
|
|
|
|
import socket
|
|
|
|
|
import tempfile
|
|
|
|
|
import signal
|
2025-02-23 09:02:46 -08:00
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
# Import our custom logger
|
|
|
|
|
from logger import log, debug, info, warning, error, critical, exception, get_logger
|
|
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# ANSI color codes for backward compatibility if needed
|
2025-02-24 14:26:48 -08:00
|
|
|
class Colors:
|
2025-02-24 21:48:51 -08:00
|
|
|
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"
|
2025-02-23 09:02:46 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
from asif import Client
|
|
|
|
|
|
|
|
|
|
# Patch asif's Client class
|
|
|
|
|
def patch_client_bg(self, coro):
|
|
|
|
|
"""Patch for Client._bg to properly handle SystemExit"""
|
|
|
|
|
async def runner():
|
|
|
|
|
try:
|
|
|
|
|
await coro
|
|
|
|
|
except SystemExit:
|
|
|
|
|
raise # Re-raise SystemExit
|
|
|
|
|
except Exception:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
return asyncio.ensure_future(runner())
|
|
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# Patch asif's Client class
|
2025-02-24 14:26:48 -08:00
|
|
|
def silent_client_init(self, *args, bot_name: str = None, config: dict = None, **kwargs):
|
2025-02-24 11:08:25 -08:00
|
|
|
# Patch the _bg method
|
|
|
|
|
self._bg = patch_client_bg.__get__(self)
|
|
|
|
|
|
2025-02-23 09:02:46 -08:00
|
|
|
# Call the original __init__
|
|
|
|
|
original_init(self, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
# Save original __init__ and replace it
|
|
|
|
|
original_init = Client.__init__
|
|
|
|
|
Client.__init__ = silent_client_init
|
|
|
|
|
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
class RestartManager:
|
2025-02-24 23:12:15 -08:00
|
|
|
"""Manages restart requests for the bot.
|
2025-02-24 14:26:48 -08:00
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
Uses a Unix domain socket to listen for restart commands.
|
2025-02-24 14:26:48 -08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, bot_id: str):
|
|
|
|
|
"""Initialize the restart manager.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
bot_id: Unique identifier for this bot instance
|
|
|
|
|
"""
|
2025-02-24 23:12:15 -08:00
|
|
|
# Get a logger for this class
|
|
|
|
|
self.logger = get_logger("RestartManager")
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
self.bot_id = bot_id
|
|
|
|
|
self.socket_path = Path(tempfile.gettempdir()) / f"icecast_bot_{bot_id}.sock"
|
|
|
|
|
self.server = None
|
|
|
|
|
self.should_restart = False
|
2025-02-25 00:00:45 -08:00
|
|
|
self.quiet_requested = False
|
|
|
|
|
self.unquiet_requested = False
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
async def start(self):
|
|
|
|
|
"""Start the restart manager server."""
|
|
|
|
|
# Clean up any existing socket
|
|
|
|
|
if self.socket_path.exists():
|
|
|
|
|
self.socket_path.unlink()
|
|
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# Ensure the parent directory exists
|
|
|
|
|
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Create the Unix Domain Socket server
|
2025-02-24 21:48:51 -08:00
|
|
|
try:
|
|
|
|
|
self.server = await asyncio.start_unix_server(
|
|
|
|
|
self._handle_restart_request,
|
|
|
|
|
str(self.socket_path)
|
|
|
|
|
)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Restart manager server started at {self.socket_path}")
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error starting restart manager server: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
# Continue without the restart manager
|
|
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
async def _handle_restart_request(self, reader, writer):
|
|
|
|
|
"""Handle an incoming restart request."""
|
|
|
|
|
try:
|
|
|
|
|
data = await reader.read()
|
|
|
|
|
if data == b'restart':
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Received restart request")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.should_restart = True
|
2025-02-25 00:00:45 -08:00
|
|
|
elif data == b'quiet':
|
|
|
|
|
self.logger.info("Received quiet request")
|
|
|
|
|
self.quiet_requested = True
|
|
|
|
|
elif data == b'unquiet':
|
|
|
|
|
self.logger.info("Received unquiet request")
|
|
|
|
|
self.unquiet_requested = True
|
2025-02-24 14:26:48 -08:00
|
|
|
writer.close()
|
|
|
|
|
await writer.wait_closed()
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error handling restart request: {e}")
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
def cleanup(self):
|
2025-02-24 23:12:15 -08:00
|
|
|
"""Clean up the restart manager resources."""
|
|
|
|
|
if self.server:
|
|
|
|
|
self.logger.debug("Closing restart manager server")
|
|
|
|
|
self.server.close()
|
|
|
|
|
|
|
|
|
|
if self.socket_path.exists():
|
|
|
|
|
self.logger.debug(f"Removing socket file: {self.socket_path}")
|
|
|
|
|
try:
|
2025-02-24 14:26:48 -08:00
|
|
|
self.socket_path.unlink()
|
2025-02-24 23:12:15 -08:00
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error(f"Error removing socket file: {e}")
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
@staticmethod
|
|
|
|
|
async def signal_restart(bot_id: str):
|
|
|
|
|
"""Signal a specific bot to restart.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
bot_id: ID of the bot to restart
|
|
|
|
|
"""
|
2025-02-24 23:12:15 -08:00
|
|
|
# Get a logger for this static method
|
|
|
|
|
logger = get_logger("RestartManager")
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
socket_path = Path(tempfile.gettempdir()) / f"icecast_bot_{bot_id}.sock"
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"Sending restart signal to bot {bot_id} at {socket_path}")
|
2025-02-24 14:26:48 -08:00
|
|
|
try:
|
|
|
|
|
reader, writer = await asyncio.open_unix_connection(str(socket_path))
|
|
|
|
|
writer.write(b'restart')
|
|
|
|
|
await writer.drain()
|
|
|
|
|
writer.close()
|
|
|
|
|
await writer.wait_closed()
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"Restart signal sent to bot {bot_id}")
|
2025-02-24 14:26:48 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Error signaling restart to bot {bot_id}: {e}")
|
2025-02-24 11:08:25 -08:00
|
|
|
|
2025-02-23 00:55:11 -08:00
|
|
|
class IcecastBot:
|
2025-02-24 14:26:48 -08:00
|
|
|
"""An IRC bot that monitors an Icecast stream and announces track changes.
|
|
|
|
|
|
|
|
|
|
This bot connects to an IRC server and channel, monitors a specified Icecast stream
|
|
|
|
|
for metadata changes, and announces new tracks. It supports various commands for
|
|
|
|
|
both regular users and administrators.
|
|
|
|
|
|
|
|
|
|
Features:
|
|
|
|
|
- Configurable via YAML files
|
|
|
|
|
- Multiple URL patterns for metadata fetching
|
|
|
|
|
- Automatic reconnection and error recovery
|
|
|
|
|
- Admin commands with permission system
|
|
|
|
|
- Customizable help messages and announcements
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def get_version():
|
|
|
|
|
"""Get the current version from VERSION file."""
|
|
|
|
|
with open('VERSION') as f:
|
|
|
|
|
return f.read().strip()
|
|
|
|
|
|
|
|
|
|
VERSION = get_version()
|
2025-02-24 11:17:23 -08:00
|
|
|
|
2025-02-23 00:55:11 -08:00
|
|
|
def __init__(self, config_path: Optional[str] = None):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Initialize the bot with the given configuration.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config_path: Path to the YAML configuration file. If None, uses default path.
|
|
|
|
|
"""
|
2025-02-24 23:12:15 -08:00
|
|
|
# Get a logger for this class
|
|
|
|
|
self.logger = get_logger("IcecastBot")
|
|
|
|
|
|
|
|
|
|
self.logger.info(f"Initializing IcecastBot with config path: {config_path}")
|
2025-02-24 14:26:48 -08:00
|
|
|
# Store config path for potential restarts
|
|
|
|
|
self.config_path = config_path
|
|
|
|
|
|
2025-02-23 00:55:11 -08:00
|
|
|
# Load config
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Loading configuration...")
|
2025-02-23 00:55:11 -08:00
|
|
|
self.config = self.load_config(config_path)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Configuration loaded: {self.config}")
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Create unique bot ID from nick and endpoint
|
|
|
|
|
self.bot_id = f"{self.config['irc']['nick']}_{self.config['stream']['endpoint']}"
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Bot ID: {self.bot_id}")
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
# Initialize restart manager
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Initializing restart manager...")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.restart_manager = RestartManager(self.bot_id)
|
|
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# Set up bot name
|
2025-02-24 14:26:48 -08:00
|
|
|
bot_name = f'{self.config["irc"]["nick"]}@{self.config["stream"]["endpoint"]}'
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Bot name: {bot_name}")
|
2025-02-23 09:02:46 -08:00
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# Initialize IRC bot with config and bot name
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Creating IRC client...")
|
2025-02-24 21:48:51 -08:00
|
|
|
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
|
|
|
|
|
)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("IRC client created successfully")
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error creating IRC client: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 21:48:51 -08:00
|
|
|
raise
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Set up instance variables
|
2025-02-23 00:55:11 -08:00
|
|
|
self.channel_name = self.config['irc']['channel']
|
|
|
|
|
self.stream_url = self.config['stream']['url']
|
|
|
|
|
self.stream_endpoint = self.config['stream']['endpoint']
|
|
|
|
|
self.current_song = "Unknown"
|
|
|
|
|
self.reply = self.config['announce']['format']
|
|
|
|
|
self.ignore_patterns = self.config['announce']['ignore_patterns']
|
|
|
|
|
self.channel = None
|
|
|
|
|
self.last_health_check = time.time()
|
|
|
|
|
self.health_check_interval = self.config['stream']['health_check_interval']
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
# Get command settings from config
|
|
|
|
|
self.cmd_prefix = self.config.get('commands', {}).get('prefix', '!')
|
|
|
|
|
self.require_nick_prefix = self.config.get('commands', {}).get('require_nick_prefix', False)
|
|
|
|
|
self.allow_private_commands = self.config.get('commands', {}).get('allow_private_commands', False)
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Get help format templates
|
|
|
|
|
help_config = self.config.get('help', {})
|
|
|
|
|
self.help_specific_format = help_config.get('specific_format', "\x02{prefix}{cmd}\x02: {desc}")
|
|
|
|
|
self.help_list_format = help_config.get('list_format', "(\x02{cmd}\x02, {desc})")
|
|
|
|
|
self.help_list_separator = help_config.get('list_separator', " | ")
|
|
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
# Control flags
|
|
|
|
|
self.monitor_task = None
|
|
|
|
|
self.should_monitor = True
|
|
|
|
|
self.is_monitoring = False
|
2025-02-25 00:00:45 -08:00
|
|
|
self.should_announce = not self.config.get('announce', {}).get('quiet_on_start', False) # Respect quiet_on_start config
|
|
|
|
|
if not self.should_announce:
|
|
|
|
|
self.logger.info("Starting in quiet mode (announcements disabled) due to configuration")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.admin_users = self.config.get('admin', {}).get('users', ['*'])
|
|
|
|
|
self.current_process = None
|
|
|
|
|
self.should_exit = False
|
|
|
|
|
|
|
|
|
|
# Initialize command mappings
|
|
|
|
|
self.pattern_to_method = {}
|
|
|
|
|
self.method_to_pattern = {}
|
|
|
|
|
self.admin_commands = set()
|
|
|
|
|
self.command_handlers = {}
|
|
|
|
|
|
|
|
|
|
# Set up handlers
|
2025-02-23 00:55:11 -08:00
|
|
|
self.setup_handlers()
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
# Build command mappings
|
|
|
|
|
self._build_command_mappings()
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def load_config(config_path: Optional[str] = None) -> dict:
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Load and validate the bot configuration from a YAML file.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
config_path: Path to the YAML configuration file. If None, uses default path.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: The loaded and validated configuration dictionary with default values applied.
|
|
|
|
|
"""
|
2025-02-24 23:12:15 -08:00
|
|
|
logger = get_logger("IcecastBot.config")
|
|
|
|
|
|
|
|
|
|
logger.debug(f"Loading config from: {config_path}")
|
2025-02-23 00:55:11 -08:00
|
|
|
if config_path is None:
|
|
|
|
|
config_path = Path(__file__).parent / 'config.yaml'
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"No config path provided, using default: {config_path}")
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
# Load config file
|
|
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.debug(f"Opening config file: {config_path}")
|
2025-02-23 00:55:11 -08:00
|
|
|
with open(config_path) as f:
|
|
|
|
|
config = yaml.safe_load(f)
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.debug(f"Config loaded successfully")
|
2025-02-23 00:55:11 -08:00
|
|
|
except FileNotFoundError:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.warning(f"Config file not found: {config_path}, using defaults")
|
2025-02-23 00:55:11 -08:00
|
|
|
config = {
|
|
|
|
|
'irc': {},
|
|
|
|
|
'stream': {},
|
|
|
|
|
'announce': {
|
|
|
|
|
'format': "\x02Now playing:\x02 {song}",
|
2025-02-24 21:48:51 -08:00
|
|
|
'ignore_patterns': ["Unknown", "Unable to fetch metadata", "Error fetching metadata"]
|
2025-02-23 00:55:11 -08:00
|
|
|
}
|
|
|
|
|
}
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Error loading config: {e}")
|
|
|
|
|
exception("Failed to load configuration")
|
2025-02-24 21:48:51 -08:00
|
|
|
raise
|
2025-02-24 14:26:48 -08:00
|
|
|
|
2025-02-23 00:55:11 -08:00
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
def should_announce_song(self, song: str) -> bool:
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Check if a song should be announced based on configured ignore patterns.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
song: The song title to check.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True if the song should be announced, False if it matches any ignore patterns.
|
|
|
|
|
"""
|
2025-02-24 22:44:06 -08:00
|
|
|
try:
|
|
|
|
|
if not song:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Empty song title, not announcing")
|
2025-02-24 22:44:06 -08:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if not self.ignore_patterns:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("No ignore patterns configured, announcing all songs")
|
2025-02-24 22:44:06 -08:00
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
# Check each pattern
|
|
|
|
|
for pattern in self.ignore_patterns:
|
|
|
|
|
try:
|
|
|
|
|
if not pattern:
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(pattern, str):
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Invalid ignore pattern (not a string): {pattern}")
|
2025-02-24 22:44:06 -08:00
|
|
|
continue
|
|
|
|
|
if pattern.lower() in song.lower():
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Song '{song}' matched ignore pattern '{pattern}', not announcing")
|
2025-02-24 22:44:06 -08:00
|
|
|
return False
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error checking ignore pattern '{pattern}': {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
continue
|
|
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Song '{song}' passed all ignore patterns, will announce")
|
2025-02-24 22:44:06 -08:00
|
|
|
return True
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Exception in should_announce_song: {str(e)}")
|
|
|
|
|
exception("Failed to check if song should be announced")
|
2025-02-24 22:44:06 -08:00
|
|
|
# Default to not announcing if there's an error
|
|
|
|
|
return False
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
def setup_handlers(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Set up all IRC event handlers and command patterns.
|
|
|
|
|
|
|
|
|
|
This method configures:
|
|
|
|
|
- Connection and join handlers
|
|
|
|
|
- Command pattern creation
|
|
|
|
|
- Message debugging
|
|
|
|
|
- Command handlers (np, help, admin commands)
|
|
|
|
|
"""
|
2025-02-23 00:55:11 -08:00
|
|
|
@self.bot.on_connected()
|
|
|
|
|
async def connected():
|
|
|
|
|
try:
|
|
|
|
|
self.channel = await self.bot.join(self.channel_name)
|
|
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
if self.should_monitor:
|
|
|
|
|
await self.start_monitoring()
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_join()
|
|
|
|
|
async def on_join(channel):
|
2025-02-24 21:48:51 -08:00
|
|
|
# Store the channel
|
2025-02-23 00:55:11 -08:00
|
|
|
if not self.channel:
|
|
|
|
|
self.channel = channel
|
|
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
def create_command_pattern(cmd: str) -> re.Pattern:
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Create a regex pattern for matching IRC commands.
|
|
|
|
|
|
|
|
|
|
The pattern handles optional nick prefixes (if configured) and
|
|
|
|
|
command prefixes.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
cmd: The command name without prefix.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
re.Pattern: Compiled regex pattern for matching the command.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
if self.require_nick_prefix:
|
|
|
|
|
pattern = f"^({self.config['irc']['nick']}[:,] )?{re.escape(self.cmd_prefix)}{cmd}($| .*$)"
|
|
|
|
|
else:
|
|
|
|
|
pattern = f"^{re.escape(self.cmd_prefix)}{cmd}($| .*$)"
|
|
|
|
|
return re.compile(pattern)
|
|
|
|
|
|
|
|
|
|
# Global message handler for debugging
|
|
|
|
|
@self.bot.on_message()
|
|
|
|
|
async def debug_message(message):
|
2025-02-24 21:48:51 -08:00
|
|
|
"""Handler for all received messages.
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Args:
|
2025-02-24 21:48:51 -08:00
|
|
|
message: The IRC message object.
|
2025-02-24 14:26:48 -08:00
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
try:
|
|
|
|
|
# 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):
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
|
2025-02-24 21:48:51 -08:00
|
|
|
# Command handlers
|
2025-02-24 11:08:25 -08:00
|
|
|
@self.bot.on_message(create_command_pattern('np'))
|
2025-02-23 00:55:11 -08:00
|
|
|
async def now_playing(message):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""!np: Show the currently playing song
|
|
|
|
|
|
|
|
|
|
Displays the current song title from the Icecast stream.
|
|
|
|
|
The song title is fetched from the stream's metadata or JSON status.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
recipient = getattr(message, 'recipient', None)
|
|
|
|
|
|
|
|
|
|
# Check if recipient is a Channel object
|
|
|
|
|
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Always allow np command in private messages
|
|
|
|
|
# Only check allow_private_commands for other commands
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
try:
|
2025-03-08 17:30:19 -08:00
|
|
|
# For private messages, we need to ensure we have a valid recipient
|
|
|
|
|
if not is_channel:
|
|
|
|
|
# In private messages, send the response to the sender
|
|
|
|
|
self.logger.debug(f"Sending now playing info to user {message.sender}")
|
|
|
|
|
await message.sender.message(self.reply.format(song=self.current_song))
|
|
|
|
|
else:
|
|
|
|
|
# In channels, send to the channel
|
|
|
|
|
self.logger.debug(f"Sending now playing info to channel {recipient}")
|
2025-02-24 11:08:25 -08:00
|
|
|
await recipient.message(self.reply.format(song=self.current_song))
|
|
|
|
|
except Exception as e:
|
2025-03-08 17:30:19 -08:00
|
|
|
self.logger.error(f"Error sending now playing info: {str(e)}")
|
|
|
|
|
try:
|
|
|
|
|
# Try to send an error message back to the user
|
|
|
|
|
await message.sender.message(f"Error sending now playing info: {str(e)}")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
self.command_handlers['now_playing'] = now_playing
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
@self.bot.on_message(create_command_pattern('help'))
|
|
|
|
|
async def help_command(message):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""!help: Show available commands
|
|
|
|
|
|
|
|
|
|
Display all available commands or detailed help for a specific command.
|
|
|
|
|
Usage: !help [command]
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
recipient = getattr(message, 'recipient', None)
|
|
|
|
|
|
|
|
|
|
# Check if recipient is a Channel object
|
|
|
|
|
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Always allow help command in private messages
|
|
|
|
|
# Only check allow_private_commands for non-help commands
|
2025-02-24 14:26:48 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
try:
|
2025-02-24 14:26:48 -08:00
|
|
|
# Parse message to check if a specific command was requested
|
|
|
|
|
msg_text = getattr(message, 'text', '')
|
|
|
|
|
parts = msg_text.strip().split()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Find the command handler using our mapping
|
|
|
|
|
method_name = self.pattern_to_method.get(pattern)
|
|
|
|
|
|
|
|
|
|
if method_name:
|
|
|
|
|
handler = self.command_handlers.get(method_name)
|
|
|
|
|
if handler and handler.__doc__:
|
2025-03-08 17:30:19 -08:00
|
|
|
# Check if command is hidden and we're in a channel
|
|
|
|
|
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
|
|
|
|
|
help_text = f"Unknown command: {pattern}"
|
|
|
|
|
else:
|
|
|
|
|
# Get the first line of the docstring
|
|
|
|
|
first_line = handler.__doc__.strip().split('\n')[0]
|
|
|
|
|
# Format it using the template and add (admin only) if needed
|
|
|
|
|
desc = first_line.split(':', 1)[1].strip()
|
|
|
|
|
help_text = self.help_specific_format.format(
|
|
|
|
|
prefix=self.cmd_prefix,
|
|
|
|
|
cmd=pattern,
|
|
|
|
|
desc=desc
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check if user has permission for this command
|
|
|
|
|
if pattern in self.admin_commands and not self.is_admin(message.sender):
|
|
|
|
|
help_text = "You don't have permission to use this command."
|
2025-02-24 14:26:48 -08:00
|
|
|
else:
|
|
|
|
|
help_text = f"No help available for command: {pattern}"
|
|
|
|
|
else:
|
|
|
|
|
help_text = f"Unknown command: {pattern}"
|
|
|
|
|
else:
|
|
|
|
|
# Build command list with proper formatting
|
|
|
|
|
formatted_groups = []
|
|
|
|
|
|
|
|
|
|
# Add general commands first
|
|
|
|
|
general_commands = []
|
|
|
|
|
for pattern, method_name in self.pattern_to_method.items():
|
|
|
|
|
if pattern not in self.admin_commands: # If not an admin command
|
|
|
|
|
handler = self.command_handlers.get(method_name)
|
|
|
|
|
if handler and handler.__doc__:
|
2025-03-08 17:30:19 -08:00
|
|
|
# Skip hidden commands in channel help
|
|
|
|
|
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
|
|
|
|
|
continue
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
first_line = handler.__doc__.strip().split('\n')[0]
|
|
|
|
|
desc = first_line.split(':', 1)[1].strip()
|
|
|
|
|
general_commands.append(
|
|
|
|
|
self.help_list_format.format(
|
|
|
|
|
cmd=f"{self.cmd_prefix}{pattern}",
|
|
|
|
|
desc=desc
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if general_commands:
|
|
|
|
|
formatted_groups.append(self.help_list_separator.join(general_commands))
|
|
|
|
|
|
|
|
|
|
# Add admin commands if user is admin
|
|
|
|
|
if self.is_admin(message.sender):
|
|
|
|
|
admin_commands = []
|
|
|
|
|
for pattern in sorted(self.admin_commands):
|
|
|
|
|
method_name = self.pattern_to_method.get(pattern)
|
|
|
|
|
if method_name:
|
|
|
|
|
handler = self.command_handlers.get(method_name)
|
|
|
|
|
if handler and handler.__doc__:
|
2025-03-08 17:30:19 -08:00
|
|
|
# Skip hidden commands in channel help
|
|
|
|
|
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
|
|
|
|
|
continue
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
first_line = handler.__doc__.strip().split('\n')[0]
|
|
|
|
|
desc = first_line.split(':', 1)[1].strip()
|
|
|
|
|
# Don't add (admin only) in the list view
|
|
|
|
|
admin_commands.append(
|
|
|
|
|
self.help_list_format.format(
|
|
|
|
|
cmd=f"{self.cmd_prefix}{pattern}",
|
|
|
|
|
desc=desc
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if admin_commands:
|
|
|
|
|
formatted_groups.append("Admin: " + self.help_list_separator.join(admin_commands))
|
|
|
|
|
|
|
|
|
|
help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | " + " | ".join(formatted_groups)
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Send the help text to the appropriate recipient
|
|
|
|
|
if not is_channel:
|
|
|
|
|
# In private messages, send the response to the sender
|
|
|
|
|
self.logger.debug(f"Sending help info to user {message.sender}")
|
|
|
|
|
await message.sender.message(help_text)
|
|
|
|
|
else:
|
|
|
|
|
# In channels, send to the channel
|
|
|
|
|
self.logger.debug(f"Sending help info to channel {recipient}")
|
2025-02-24 11:08:25 -08:00
|
|
|
await recipient.message(help_text)
|
|
|
|
|
except Exception as e:
|
2025-03-08 17:30:19 -08:00
|
|
|
self.logger.error(f"Error sending help info: {str(e)}")
|
|
|
|
|
try:
|
|
|
|
|
# Try to send an error message back to the user
|
|
|
|
|
await message.sender.message(f"Error sending help info: {str(e)}")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
self.command_handlers['help_command'] = help_command
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('restart'))
|
2025-02-24 14:26:48 -08:00
|
|
|
@self.admin_required
|
2025-02-24 11:08:25 -08:00
|
|
|
async def restart_bot(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!restart: Restart the bot
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Gracefully shuts down the bot and signals the bot.sh script
|
|
|
|
|
to restart it. This ensures a clean restart.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
await self.stop_monitoring()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self.bot.quit("Restarting...")
|
2025-02-24 14:26:48 -08:00
|
|
|
# Signal restart through Unix Domain Socket
|
|
|
|
|
await RestartManager.signal_restart(self.bot_id)
|
|
|
|
|
self.should_exit = True
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
self.command_handlers['restart_bot'] = restart_bot
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('quit'))
|
2025-02-24 14:26:48 -08:00
|
|
|
@self.admin_required
|
2025-02-24 11:08:25 -08:00
|
|
|
async def quit_bot(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!quit: Shutdown the bot
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Gracefully shuts down the bot and exits without restarting.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
await self.stop_monitoring()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self.bot.quit("Shutting down...")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.should_exit = True
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
self.command_handlers['quit_bot'] = quit_bot
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('reconnect'))
|
2025-02-24 14:26:48 -08:00
|
|
|
@self.admin_required
|
2025-02-24 11:08:25 -08:00
|
|
|
async def reconnect_stream(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!reconnect: Reconnect to the stream
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Attempts to reconnect to the stream and verifies the connection.
|
|
|
|
|
Reports success or failure back to the channel.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
success = await self.restart_monitoring()
|
2025-02-24 14:26:48 -08:00
|
|
|
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.")
|
|
|
|
|
self.command_handlers['reconnect_stream'] = reconnect_stream
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('stop'))
|
2025-02-24 14:26:48 -08:00
|
|
|
@self.admin_required
|
2025-02-24 11:08:25 -08:00
|
|
|
async def stop_monitoring(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!stop: Stop stream monitoring
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Stops monitoring the stream for metadata changes.
|
|
|
|
|
The bot remains connected to IRC.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
await self.stop_monitoring()
|
2025-02-24 14:26:48 -08:00
|
|
|
if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
|
|
|
|
|
await message.recipient.message("Stream monitoring stopped.")
|
|
|
|
|
self.command_handlers['stop_monitoring'] = stop_monitoring
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('start'))
|
2025-02-24 14:26:48 -08:00
|
|
|
@self.admin_required
|
2025-02-24 11:08:25 -08:00
|
|
|
async def start_monitoring(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!start: Start stream monitoring
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
Starts monitoring the stream for metadata changes.
|
|
|
|
|
Will announce new songs in the channel.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
await self.start_monitoring()
|
2025-02-24 14:26:48 -08:00
|
|
|
if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
|
|
|
|
|
await message.recipient.message("Stream monitoring started.")
|
|
|
|
|
self.command_handlers['start_monitoring'] = start_monitoring
|
|
|
|
|
|
2025-02-25 00:00:45 -08:00
|
|
|
@self.bot.on_message(create_command_pattern('quiet'))
|
|
|
|
|
@self.admin_required
|
|
|
|
|
async def quiet_bot(message):
|
2025-02-25 03:09:20 -08:00
|
|
|
"""!quiet: Disable song announcements
|
2025-02-25 00:00:45 -08:00
|
|
|
|
|
|
|
|
Continues monitoring the stream for metadata changes,
|
|
|
|
|
but stops announcing songs in the channel.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
|
|
|
|
self.should_announce = False
|
|
|
|
|
self.logger.info("Song announcements disabled by admin command")
|
|
|
|
|
if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
|
|
|
|
|
await message.recipient.message("Song announcements disabled. Bot will continue monitoring but remain quiet.")
|
|
|
|
|
self.command_handlers['quiet_bot'] = quiet_bot
|
|
|
|
|
|
|
|
|
|
@self.bot.on_message(create_command_pattern('unquiet'))
|
|
|
|
|
@self.admin_required
|
|
|
|
|
async def unquiet_bot(message):
|
2025-03-08 17:30:19 -08:00
|
|
|
"""!unquiet: Re-enable song announcements
|
2025-02-25 00:00:45 -08:00
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
The opposite of !quiet - allows the bot to resume announcing songs.
|
2025-02-25 00:00:45 -08:00
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
2025-03-08 17:30:19 -08:00
|
|
|
try:
|
|
|
|
|
self.should_announce = True
|
|
|
|
|
await message.recipient.message("Song announcements re-enabled.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
pass
|
2025-02-25 00:00:45 -08:00
|
|
|
self.command_handlers['unquiet_bot'] = unquiet_bot
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
@self.bot.on_message(create_command_pattern('announce'))
|
|
|
|
|
@self.hidden_command
|
|
|
|
|
@self.admin_required
|
|
|
|
|
async def announce_message(message):
|
|
|
|
|
"""!announce: Send a message from the bot to all channels
|
|
|
|
|
|
|
|
|
|
Allows admins to use the bot to send a message to all channels.
|
|
|
|
|
Can be used in private message or in a channel.
|
|
|
|
|
|
|
|
|
|
Usage: !announce <message>
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: IRC message object
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Extract the message to announce (everything after the command)
|
|
|
|
|
match = re.match(r"^.*?announce\s+(.*?)$", message.text)
|
|
|
|
|
if match and match.group(1).strip():
|
|
|
|
|
announcement = match.group(1).strip()
|
|
|
|
|
|
|
|
|
|
# Always send to the joined channel, regardless of where the command was received
|
|
|
|
|
if self.channel and hasattr(self.channel, 'name') and self.channel.name.startswith('#'):
|
|
|
|
|
await self.channel.message(announcement)
|
|
|
|
|
else:
|
|
|
|
|
# If we somehow don't have a valid channel, inform the admin
|
|
|
|
|
self.logger.warning("No valid channel to announce to")
|
|
|
|
|
# Only send this response if in a private message
|
|
|
|
|
if hasattr(message.recipient, 'name') and not message.recipient.name.startswith('#'):
|
|
|
|
|
await message.recipient.message("No valid channel to announce to.")
|
|
|
|
|
else:
|
|
|
|
|
await message.recipient.message("Usage: !announce <message>")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error(f"Error in announce_message: {str(e)}")
|
|
|
|
|
try:
|
|
|
|
|
await message.recipient.message(f"Error processing announcement: {str(e)}")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.command_handlers['announce_message'] = announce_message
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
def _build_command_mappings(self):
|
|
|
|
|
"""Build bidirectional mappings between command patterns and method names.
|
|
|
|
|
|
|
|
|
|
Creates two mappings:
|
|
|
|
|
- pattern_to_method: Maps IRC command pattern (e.g. 'np') to method name
|
|
|
|
|
- method_to_pattern: Maps method name to IRC command pattern
|
|
|
|
|
|
|
|
|
|
Command patterns are extracted from the first line of each handler's docstring,
|
|
|
|
|
which must be in the format "!command: description".
|
|
|
|
|
"""
|
|
|
|
|
for method_name, handler in self.command_handlers.items():
|
|
|
|
|
if not handler.__doc__:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# 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('!')
|
|
|
|
|
|
|
|
|
|
# Store both mappings
|
|
|
|
|
self.pattern_to_method[pattern] = method_name
|
|
|
|
|
self.method_to_pattern[method_name] = pattern
|
|
|
|
|
|
|
|
|
|
# Check if this is an admin command by looking at the decorator
|
|
|
|
|
if hasattr(handler, '__closure__') and handler.__closure__:
|
|
|
|
|
for cell in handler.__closure__:
|
|
|
|
|
if isinstance(cell.cell_contents, type(self.admin_required)):
|
|
|
|
|
self.admin_commands.add(pattern)
|
|
|
|
|
break
|
|
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
def is_admin(self, user: str) -> bool:
|
|
|
|
|
"""Check if a user has admin privileges.
|
|
|
|
|
|
|
|
|
|
Args:
|
2025-02-24 14:26:48 -08:00
|
|
|
user: Full IRC user string (nickname!username@hostname) or User object.
|
|
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
Returns:
|
2025-02-24 14:26:48 -08:00
|
|
|
bool: True if user has admin privileges, False otherwise.
|
2025-02-24 11:08:25 -08:00
|
|
|
"""
|
2025-02-23 00:55:11 -08:00
|
|
|
try:
|
2025-02-24 11:08:25 -08:00
|
|
|
if hasattr(user, 'name'):
|
|
|
|
|
nickname = user.name
|
|
|
|
|
else:
|
|
|
|
|
nickname = user.split('!')[0] if '!' in user else user
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return '*' in self.admin_users or nickname in self.admin_users
|
|
|
|
|
|
|
|
|
|
async def start_monitoring(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Start the metadata monitoring task.
|
|
|
|
|
|
|
|
|
|
Creates an asyncio task to monitor the stream for metadata changes.
|
|
|
|
|
Only starts if not already monitoring.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
if not self.is_monitoring:
|
|
|
|
|
self.should_monitor = True
|
|
|
|
|
self.monitor_task = asyncio.create_task(self.monitor_metadata())
|
|
|
|
|
self.is_monitoring = True
|
|
|
|
|
|
|
|
|
|
async def stop_monitoring(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Stop the metadata monitoring task.
|
|
|
|
|
|
|
|
|
|
Terminates the curl subprocess if running and cancels the monitoring task.
|
|
|
|
|
Ensures proper cleanup of resources.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
self.should_monitor = False
|
|
|
|
|
self.is_monitoring = False
|
|
|
|
|
|
|
|
|
|
# First, terminate the subprocess if it exists
|
|
|
|
|
if self.current_process and self.current_process.returncode is None:
|
|
|
|
|
try:
|
|
|
|
|
self.current_process.terminate()
|
|
|
|
|
try:
|
|
|
|
|
await asyncio.wait_for(self.current_process.wait(), timeout=2.0)
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
self.current_process.kill()
|
|
|
|
|
await self.current_process.wait()
|
|
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
finally:
|
|
|
|
|
self.current_process = None
|
|
|
|
|
|
|
|
|
|
# Then cancel the monitoring task
|
|
|
|
|
if self.monitor_task:
|
|
|
|
|
self.monitor_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await self.monitor_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
finally:
|
|
|
|
|
self.monitor_task = None
|
|
|
|
|
|
|
|
|
|
async def restart_monitoring(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Restart the metadata monitoring task and verify the reconnection.
|
|
|
|
|
|
|
|
|
|
Stops the current monitoring task, starts a new one, and verifies
|
|
|
|
|
that metadata can be fetched successfully.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
bool: True if reconnection was successful and verified, False otherwise.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
await self.stop_monitoring()
|
|
|
|
|
|
|
|
|
|
# Store the channel for status updates
|
|
|
|
|
notify_channel = self.channel
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self.start_monitoring()
|
|
|
|
|
|
|
|
|
|
# Wait for up to 10 seconds to confirm data is being received
|
|
|
|
|
start_time = time.time()
|
|
|
|
|
while time.time() - start_time < 10:
|
|
|
|
|
if self.current_process and self.current_process.returncode is None:
|
|
|
|
|
# Try to fetch metadata to confirm connection
|
|
|
|
|
test_song = await self.fetch_json_metadata()
|
|
|
|
|
if test_song and "Unable to fetch metadata" not in test_song:
|
|
|
|
|
if notify_channel and hasattr(notify_channel, 'name') and notify_channel.name.startswith('#'):
|
|
|
|
|
await notify_channel.message("Stream reconnected successfully.")
|
|
|
|
|
return True
|
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
|
|
|
|
# If we get here, the connection wasn't confirmed
|
|
|
|
|
if notify_channel and hasattr(notify_channel, 'name') and notify_channel.name.startswith('#'):
|
|
|
|
|
await notify_channel.message("Stream reconnection attempt completed, but status uncertain. Check logs for details.")
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
except Exception as 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
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
async def fetch_json_metadata(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Fetch metadata from the Icecast JSON status endpoint.
|
|
|
|
|
|
|
|
|
|
Tries multiple URL patterns to find the correct status endpoint.
|
|
|
|
|
Handles connection errors and JSON parsing gracefully.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
str: The current song title, or an error message if fetching failed.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Fetching metadata from {self.stream_url}/{self.stream_endpoint}")
|
2025-02-24 11:08:25 -08:00
|
|
|
# Try different URL patterns
|
|
|
|
|
base_urls = [
|
|
|
|
|
self.stream_url, # Original URL
|
|
|
|
|
self.stream_url.replace('live.', 'listen.'), # Try listen. instead of live.
|
|
|
|
|
'/'.join(self.stream_url.split('/')[:-1]) if '/' in self.stream_url else self.stream_url # Try parent path
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for base_url in base_urls:
|
|
|
|
|
try:
|
|
|
|
|
url = f"{base_url}/status-json.xsl"
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Trying URL: {url}")
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
2025-02-24 22:44:06 -08:00
|
|
|
async with session.get(url, timeout=10) as response:
|
2025-02-24 11:08:25 -08:00
|
|
|
if response.status == 200:
|
|
|
|
|
data = await response.text()
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Received response from {url}")
|
2025-02-24 22:44:06 -08:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
json_data = json.loads(data)
|
|
|
|
|
if 'icestats' in json_data:
|
|
|
|
|
sources = json_data['icestats'].get('source', [])
|
|
|
|
|
if not isinstance(sources, list):
|
|
|
|
|
sources = [sources]
|
|
|
|
|
|
|
|
|
|
# Find our stream
|
|
|
|
|
for src in sources:
|
|
|
|
|
if src.get('listenurl', '').endswith(self.stream_endpoint):
|
|
|
|
|
title = src.get('title') or src.get('song') or src.get('current_song')
|
|
|
|
|
if title:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Found title: {title}")
|
2025-02-24 22:44:06 -08:00
|
|
|
return title
|
|
|
|
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"JSON decode error for {url}: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
continue
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
except aiohttp.ClientError as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Client error for {url}: {str(e)}")
|
2025-02-24 11:08:25 -08:00
|
|
|
continue
|
|
|
|
|
except json.JSONDecodeError as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"JSON decode error for {url}: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
continue
|
|
|
|
|
except asyncio.TimeoutError:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Timeout fetching metadata from {url}")
|
2025-02-24 22:44:06 -08:00
|
|
|
continue
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Unexpected error fetching from {url}: {str(e)}")
|
2025-02-24 11:08:25 -08:00
|
|
|
continue
|
|
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("All URL patterns failed, returning 'Unable to fetch metadata'")
|
2025-02-23 00:55:11 -08:00
|
|
|
return "Unable to fetch metadata"
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Exception in fetch_json_metadata: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 22:44:06 -08:00
|
|
|
return f"Error fetching metadata: {str(e)}"
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
async def monitor_metadata(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Monitor the Icecast stream for metadata changes.
|
|
|
|
|
|
|
|
|
|
Uses curl to read the stream and detect metadata changes.
|
|
|
|
|
Handles stream errors, reconnection, and health checks.
|
|
|
|
|
Announces new songs when detected.
|
|
|
|
|
"""
|
2025-02-23 00:55:11 -08:00
|
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
while self.should_monitor:
|
2025-02-23 00:55:11 -08:00
|
|
|
try:
|
|
|
|
|
cmd = [
|
|
|
|
|
'curl',
|
|
|
|
|
'-s',
|
|
|
|
|
'-H', 'Icy-MetaData: 1',
|
|
|
|
|
'--no-buffer',
|
|
|
|
|
f"{self.stream_url}/{self.stream_endpoint}"
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
process = await asyncio.create_subprocess_exec(
|
|
|
|
|
*cmd,
|
|
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
|
|
stderr=asyncio.subprocess.PIPE
|
|
|
|
|
)
|
2025-02-24 11:08:25 -08:00
|
|
|
self.current_process = process # Store the process reference
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Started curl process to monitor stream: {self.stream_url}/{self.stream_endpoint}")
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
last_data_received = time.time()
|
2025-02-23 00:55:11 -08:00
|
|
|
buffer = b""
|
|
|
|
|
last_json_check = time.time()
|
|
|
|
|
json_check_interval = 60 # Fallback interval if ICY updates fail
|
2025-02-24 11:08:25 -08:00
|
|
|
data_timeout = 180 # 3 minutes without data is considered a failure
|
|
|
|
|
empty_chunks_count = 0
|
|
|
|
|
max_empty_chunks = 5 # After 5 empty chunks in a row, reconnect
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
while True:
|
2025-02-24 11:08:25 -08:00
|
|
|
try:
|
|
|
|
|
chunk = await asyncio.wait_for(process.stdout.read(8192), timeout=30.0)
|
|
|
|
|
if chunk:
|
|
|
|
|
last_data_received = time.time()
|
|
|
|
|
buffer += chunk
|
|
|
|
|
empty_chunks_count = 0 # Reset counter on successful data
|
|
|
|
|
else:
|
|
|
|
|
empty_chunks_count += 1
|
|
|
|
|
if empty_chunks_count >= max_empty_chunks:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Received {max_empty_chunks} empty chunks in a row, reconnecting")
|
2025-02-24 11:08:25 -08:00
|
|
|
break
|
|
|
|
|
if time.time() - last_data_received > data_timeout:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Data timeout exceeded ({data_timeout}s), reconnecting")
|
2025-02-24 11:08:25 -08:00
|
|
|
break
|
|
|
|
|
|
|
|
|
|
current_time = time.time()
|
|
|
|
|
|
|
|
|
|
# Periodic health check
|
|
|
|
|
if current_time - self.last_health_check >= self.health_check_interval:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Performing periodic health check")
|
2025-02-24 11:08:25 -08:00
|
|
|
self.last_health_check = current_time
|
|
|
|
|
|
|
|
|
|
# Look for metadata marker but fetch from JSON
|
|
|
|
|
if b"StreamTitle='" in buffer:
|
2025-02-24 22:44:06 -08:00
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Detected StreamTitle marker in buffer, fetching metadata")
|
2025-02-24 22:44:06 -08:00
|
|
|
new_song = await self.fetch_json_metadata()
|
|
|
|
|
|
|
|
|
|
# Check if we should announce the song
|
|
|
|
|
if new_song and new_song != self.current_song and "Unable to fetch metadata" not in new_song:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Song changed from '{self.current_song}' to '{new_song}'")
|
2025-02-24 22:44:06 -08:00
|
|
|
self.current_song = new_song
|
|
|
|
|
|
|
|
|
|
# Try to announce the song
|
|
|
|
|
try:
|
|
|
|
|
await self.announce_song(new_song)
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error announcing song: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 22:44:06 -08:00
|
|
|
else:
|
|
|
|
|
# No song change or unable to fetch metadata
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"No song change detected or unable to fetch metadata: {new_song}")
|
2025-02-24 22:44:06 -08:00
|
|
|
|
|
|
|
|
# Clear buffer after metadata marker
|
|
|
|
|
marker_pos = buffer.find(b"StreamTitle='")
|
|
|
|
|
end_pos = buffer.find(b"';", marker_pos)
|
|
|
|
|
if end_pos > marker_pos:
|
|
|
|
|
buffer = buffer[end_pos + 2:]
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Buffer cleared after metadata marker")
|
2025-02-24 22:44:06 -08:00
|
|
|
else:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Could not find end of metadata marker, truncating buffer")
|
2025-02-24 22:44:06 -08:00
|
|
|
buffer = buffer[-8192:] # Keep last 8KB to avoid losing the end marker
|
|
|
|
|
|
|
|
|
|
# Update last check time
|
|
|
|
|
last_json_check = current_time
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error processing metadata marker: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 22:44:06 -08:00
|
|
|
# Reset buffer to avoid getting stuck in a loop
|
|
|
|
|
buffer = b""
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
# Keep buffer size reasonable
|
|
|
|
|
if len(buffer) > 65536:
|
|
|
|
|
buffer = buffer[-32768:]
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Buffer size exceeded limit, truncated to 32KB")
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
# Fallback JSON check if ICY updates aren't coming through
|
|
|
|
|
if current_time - last_json_check >= json_check_interval:
|
2025-02-24 22:44:06 -08:00
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Performing fallback JSON check")
|
2025-02-24 22:44:06 -08:00
|
|
|
new_song = await self.fetch_json_metadata()
|
|
|
|
|
|
|
|
|
|
if "Unable to fetch metadata" in new_song:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("Unable to fetch metadata in fallback check")
|
2025-02-24 22:44:06 -08:00
|
|
|
if time.time() - last_data_received > data_timeout:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("Data timeout exceeded, breaking loop")
|
2025-02-24 22:44:06 -08:00
|
|
|
break
|
|
|
|
|
elif new_song and new_song != self.current_song:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Song changed in fallback check from '{self.current_song}' to '{new_song}'")
|
2025-02-24 22:44:06 -08:00
|
|
|
self.current_song = new_song
|
|
|
|
|
try:
|
|
|
|
|
await self.announce_song(new_song)
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error announcing song in fallback check: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 22:44:06 -08:00
|
|
|
else:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"No song change detected in fallback check: {new_song}")
|
2025-02-24 22:44:06 -08:00
|
|
|
|
|
|
|
|
last_json_check = current_time
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error in fallback JSON check: {str(e)}")
|
2025-02-24 22:44:06 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 22:44:06 -08:00
|
|
|
# Still update the check time to avoid rapid retries
|
|
|
|
|
last_json_check = current_time
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
|
|
|
|
except asyncio.TimeoutError:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("Timeout reading from stream")
|
2025-02-24 11:08:25 -08:00
|
|
|
if time.time() - last_data_received > data_timeout:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Data timeout exceeded ({data_timeout}s), reconnecting")
|
2025-02-23 00:55:11 -08:00
|
|
|
break
|
2025-02-24 11:08:25 -08:00
|
|
|
continue
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error reading from stream: {str(e)}")
|
2025-02-24 11:08:25 -08:00
|
|
|
break
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
# Check if process is still running and terminate if needed
|
|
|
|
|
if process.returncode is None:
|
|
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug("Terminating curl process")
|
2025-02-24 11:08:25 -08:00
|
|
|
process.terminate()
|
|
|
|
|
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
|
|
|
except asyncio.TimeoutError:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("Timeout waiting for curl process to terminate")
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error terminating curl process: {str(e)}")
|
2025-02-24 11:08:25 -08:00
|
|
|
finally:
|
|
|
|
|
self.current_process = None
|
|
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Reconnecting to stream after short delay")
|
2025-02-23 00:55:11 -08:00
|
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error in monitor_metadata: {str(e)}")
|
|
|
|
|
import traceback
|
|
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-23 00:55:11 -08:00
|
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
|
|
|
|
async def announce_song(self, song: str):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Announce a song in the IRC channel.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
song: The song title to announce.
|
|
|
|
|
|
|
|
|
|
Only announces if:
|
|
|
|
|
- The channel object is valid
|
|
|
|
|
- The song doesn't match any ignore patterns
|
|
|
|
|
- The announcement format is configured
|
2025-02-25 00:00:45 -08:00
|
|
|
- The should_announce flag is True
|
2025-02-24 14:26:48 -08:00
|
|
|
"""
|
2025-02-23 00:55:11 -08:00
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Attempting to announce song: {song}")
|
2025-02-25 00:00:45 -08:00
|
|
|
if not self.should_announce:
|
|
|
|
|
self.logger.info(f"Song announcements are disabled, not announcing: {song}")
|
|
|
|
|
return
|
|
|
|
|
|
2025-02-23 00:55:11 -08:00
|
|
|
if self.channel and self.should_announce_song(song):
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Song passed filters, preparing to announce")
|
2025-02-24 11:08:25 -08:00
|
|
|
# Use the stored channel object directly
|
|
|
|
|
if hasattr(self.channel, 'name') and self.channel.name.startswith('#'):
|
2025-02-24 22:44:06 -08:00
|
|
|
try:
|
|
|
|
|
formatted_message = self.reply.format(song=song)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Sending message to channel {self.channel.name}: {formatted_message}")
|
2025-02-24 22:44:06 -08:00
|
|
|
await self.channel.message(self.reply.format(song=song))
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Successfully announced song: {song}")
|
2025-02-24 22:44:06 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error sending message to channel: {str(e)}")
|
|
|
|
|
exception("Failed to send message to channel")
|
2025-02-24 22:44:06 -08:00
|
|
|
else:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning(f"Channel object invalid or not a channel: {self.channel}")
|
2025-02-24 22:44:06 -08:00
|
|
|
else:
|
|
|
|
|
if not self.channel:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.warning("Channel object is None or invalid")
|
2025-02-24 22:44:06 -08:00
|
|
|
elif not self.should_announce_song(song):
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.debug(f"Song '{song}' matched ignore patterns, not announcing")
|
2025-02-23 00:55:11 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Exception in announce_song: {str(e)}")
|
|
|
|
|
exception("Failed to announce song")
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
async def start(self):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Start the IRC bot and begin processing events."""
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Starting IcecastBot...")
|
2025-02-24 14:26:48 -08:00
|
|
|
try:
|
2025-02-24 21:48:51 -08:00
|
|
|
# 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)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Created state file at {state_file}")
|
2025-02-24 21:48:51 -08:00
|
|
|
|
|
|
|
|
# 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)
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info(f"Removed {self.bot_id} from state file")
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error cleaning up state file: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
# Register signal handlers
|
|
|
|
|
signal.signal(signal.SIGTERM, cleanup_state_file)
|
|
|
|
|
signal.signal(signal.SIGINT, cleanup_state_file)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error creating state file: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Start the restart manager
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Starting restart manager...")
|
2025-02-24 14:26:48 -08:00
|
|
|
await self.restart_manager.start()
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Restart manager started")
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
# Start the bot
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Starting IRC bot...")
|
2025-02-24 14:26:48 -08:00
|
|
|
await self.bot.run()
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("IRC bot started")
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Error starting bot: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 14:26:48 -08:00
|
|
|
finally:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("In start() finally block")
|
2025-02-24 14:26:48 -08:00
|
|
|
if self.should_exit:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Bot should exit, cleaning up...")
|
2025-02-24 14:26:48 -08:00
|
|
|
# Clean up any remaining tasks
|
|
|
|
|
if self.monitor_task:
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Canceling monitor task...")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.monitor_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await self.monitor_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
# Clean up restart manager
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Cleaning up restart manager...")
|
2025-02-24 14:26:48 -08:00
|
|
|
self.restart_manager.cleanup()
|
2025-02-24 23:12:15 -08:00
|
|
|
self.logger.info("Restart manager cleaned up")
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
def format_help_section(self, section_config: dict, prefix: str) -> List[str]:
|
|
|
|
|
"""Format a help section according to the template.
|
|
|
|
|
|
|
|
|
|
Extracts the first line of each command's docstring to use as the
|
|
|
|
|
help text. Falls back to the template's command descriptions if
|
|
|
|
|
docstring is not available.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
section_config: Configuration dictionary for the section.
|
|
|
|
|
prefix: Command prefix to use.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List[str]: List of formatted help lines for each command.
|
|
|
|
|
"""
|
|
|
|
|
commands = []
|
|
|
|
|
for cmd, desc in section_config['commands'].items():
|
|
|
|
|
# Try to get the docstring from the command handler
|
|
|
|
|
handler = None
|
|
|
|
|
for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
|
|
|
|
|
if name.endswith(f"_{cmd}"): # e.g. now_playing for np
|
|
|
|
|
handler = method
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if handler and handler.__doc__:
|
|
|
|
|
# Extract the first line of the docstring
|
|
|
|
|
first_line = handler.__doc__.strip().split('\n')[0]
|
2025-02-25 03:23:24 -08:00
|
|
|
# Remove the command prefix and colon
|
2025-02-24 14:26:48 -08:00
|
|
|
desc = first_line.split(':', 1)[1].strip()
|
|
|
|
|
|
|
|
|
|
commands.append(section_config['format'].format(
|
|
|
|
|
cmd=f"\x02{prefix}{cmd}\x02", # Bold the command
|
|
|
|
|
desc=desc
|
|
|
|
|
))
|
|
|
|
|
return commands
|
|
|
|
|
|
|
|
|
|
async def help_command_fallback(self, message):
|
|
|
|
|
"""Fallback help command implementation using hardcoded format.
|
|
|
|
|
|
|
|
|
|
Used when the help template is not configured or fails to process.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message: The IRC message object that triggered this command.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
prefix = self.cmd_prefix
|
|
|
|
|
# Format commands with bold prefix and aligned descriptions
|
|
|
|
|
base_cmds = f"\x02{prefix}np\x02 (current song) • \x02{prefix}help\x02 (this help)"
|
|
|
|
|
help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | Commands: {base_cmds}"
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Check if we're in a channel
|
|
|
|
|
recipient = getattr(message, 'recipient', None)
|
|
|
|
|
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
if self.is_admin(message.sender):
|
2025-03-08 17:30:19 -08:00
|
|
|
admin_cmds = []
|
|
|
|
|
|
|
|
|
|
# Basic admin commands
|
|
|
|
|
basic_admin = (
|
2025-02-24 14:26:48 -08:00
|
|
|
f"\x02{prefix}start\x02/\x02stop\x02 (monitoring) • "
|
|
|
|
|
f"\x02{prefix}reconnect\x02 (stream) • "
|
|
|
|
|
f"\x02{prefix}restart\x02 (bot) • "
|
|
|
|
|
f"\x02{prefix}quit\x02 (shutdown)"
|
|
|
|
|
)
|
2025-03-08 17:30:19 -08:00
|
|
|
admin_cmds.append(basic_admin)
|
|
|
|
|
|
|
|
|
|
# Add announce command only in private messages
|
|
|
|
|
if not is_channel:
|
|
|
|
|
admin_cmds.append(f"\x02{prefix}announce\x02 (send message)")
|
|
|
|
|
|
|
|
|
|
help_text += f" | Admin: {' • '.join(admin_cmds)}"
|
2025-02-24 14:26:48 -08:00
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Send the help text to the appropriate recipient
|
|
|
|
|
if not is_channel:
|
|
|
|
|
# In private messages, send the response to the sender
|
|
|
|
|
self.logger.debug(f"Sending fallback help info to user {message.sender}")
|
|
|
|
|
await message.sender.message(help_text)
|
|
|
|
|
else:
|
|
|
|
|
# In channels, send to the channel
|
|
|
|
|
self.logger.debug(f"Sending fallback help info to channel {recipient}")
|
|
|
|
|
await recipient.message(help_text)
|
2025-02-24 14:26:48 -08:00
|
|
|
except Exception as e:
|
2025-03-08 17:30:19 -08:00
|
|
|
self.logger.error(f"Error sending fallback help info: {str(e)}")
|
|
|
|
|
try:
|
|
|
|
|
# Try to send an error message back to the user
|
|
|
|
|
await message.sender.message(f"Error sending fallback help info: {str(e)}")
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2025-02-24 14:26:48 -08:00
|
|
|
|
|
|
|
|
def admin_required(self, f):
|
|
|
|
|
"""Decorator to mark a command as requiring admin privileges.
|
|
|
|
|
|
|
|
|
|
Also automatically adds the command to the admin_commands set
|
|
|
|
|
for help message grouping.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
f: The command handler function to wrap.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The wrapped function that checks for admin privileges.
|
|
|
|
|
"""
|
|
|
|
|
async def wrapped(message, *args, **kwargs):
|
|
|
|
|
recipient = getattr(message, 'recipient', None)
|
|
|
|
|
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# For admin commands, allow them in private messages regardless of allow_private_commands setting
|
|
|
|
|
# Just check if the user is an admin
|
|
|
|
|
if not is_channel: # If it's a private message
|
|
|
|
|
if not self.is_admin(message.sender):
|
|
|
|
|
return # Silently ignore if not an admin
|
|
|
|
|
return await f(message, *args, **kwargs) # Allow command if admin
|
|
|
|
|
|
|
|
|
|
# Channel message handling remains the same
|
2025-02-24 14:26:48 -08:00
|
|
|
if not self.is_admin(message.sender):
|
2025-03-08 17:30:19 -08:00
|
|
|
await recipient.message("You don't have permission to use this command.")
|
2025-02-24 14:26:48 -08:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
return await f(message, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
# Copy the docstring and other attributes
|
|
|
|
|
wrapped.__doc__ = f.__doc__
|
|
|
|
|
wrapped.__name__ = f.__name__
|
|
|
|
|
|
2025-03-08 17:30:19 -08:00
|
|
|
# Preserve the is_hidden_command attribute if it exists
|
|
|
|
|
if hasattr(f, 'is_hidden_command'):
|
|
|
|
|
wrapped.is_hidden_command = f.is_hidden_command
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Add the command pattern to admin_commands set
|
|
|
|
|
if f.__doc__ and f.__doc__.strip().startswith('!'):
|
|
|
|
|
pattern = f.__doc__.strip().split(':', 1)[0].strip('!')
|
|
|
|
|
self.admin_commands.add(pattern)
|
|
|
|
|
|
|
|
|
|
return wrapped
|
2025-03-08 17:30:19 -08:00
|
|
|
|
|
|
|
|
def hidden_command(self, f):
|
|
|
|
|
"""Decorator to mark a command as hidden from help output in channels.
|
|
|
|
|
|
|
|
|
|
Hidden commands will only appear in help output when the help command
|
|
|
|
|
is used in a private message.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
f: The command handler function to wrap.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
The wrapped function with a hidden flag.
|
|
|
|
|
"""
|
|
|
|
|
# Set a flag on the function to mark it as hidden
|
|
|
|
|
f.is_hidden_command = True
|
|
|
|
|
return f
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
async def run_multiple_bots(config_paths: List[str]):
|
2025-02-24 14:26:48 -08:00
|
|
|
"""Run multiple bot instances concurrently.
|
|
|
|
|
|
|
|
|
|
Each bot runs independently and can be stopped without affecting others.
|
|
|
|
|
"""
|
2025-02-24 11:08:25 -08:00
|
|
|
bots = []
|
2025-02-24 14:26:48 -08:00
|
|
|
tasks = []
|
2025-02-24 11:08:25 -08:00
|
|
|
for config_path in config_paths:
|
|
|
|
|
try:
|
|
|
|
|
bot = IcecastBot(config_path)
|
|
|
|
|
bots.append(bot)
|
2025-02-24 14:26:48 -08:00
|
|
|
# Create task for each bot
|
|
|
|
|
tasks.append(asyncio.create_task(run_single_bot(bot)))
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
if not bots:
|
2025-02-24 21:48:51 -08:00
|
|
|
pass
|
2025-02-24 11:08:25 -08:00
|
|
|
return
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Wait for all bots to complete
|
|
|
|
|
await asyncio.gather(*tasks)
|
|
|
|
|
|
|
|
|
|
async def run_single_bot(bot: IcecastBot):
|
|
|
|
|
"""Run a single bot instance.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
bot: The IcecastBot instance to run
|
|
|
|
|
"""
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info(f"Running single bot with ID: {bot.bot_id}")
|
2025-02-24 14:26:48 -08:00
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Starting bot...")
|
2025-02-25 00:00:45 -08:00
|
|
|
|
|
|
|
|
# Start a background task to check for quiet/unquiet requests
|
|
|
|
|
async def check_quiet_unquiet():
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
# Check for quiet request
|
|
|
|
|
if bot.restart_manager.quiet_requested:
|
|
|
|
|
bot.logger.info("Processing quiet request")
|
|
|
|
|
bot.should_announce = False
|
|
|
|
|
bot.restart_manager.quiet_requested = False
|
|
|
|
|
if bot.channel and hasattr(bot.channel, 'name') and bot.channel.name.startswith('#'):
|
|
|
|
|
await bot.channel.message("Song announcements disabled via terminal command.")
|
|
|
|
|
|
|
|
|
|
# Check for unquiet request
|
|
|
|
|
if bot.restart_manager.unquiet_requested:
|
|
|
|
|
bot.logger.info("Processing unquiet request")
|
|
|
|
|
bot.should_announce = True
|
|
|
|
|
bot.restart_manager.unquiet_requested = False
|
|
|
|
|
if bot.channel and hasattr(bot.channel, 'name') and bot.channel.name.startswith('#'):
|
|
|
|
|
await bot.channel.message("Song announcements enabled via terminal command.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
bot.logger.error(f"Error in quiet/unquiet check: {e}")
|
|
|
|
|
|
|
|
|
|
await asyncio.sleep(1) # Check every second
|
|
|
|
|
|
|
|
|
|
# Start the background task
|
|
|
|
|
quiet_check_task = asyncio.create_task(check_quiet_unquiet())
|
|
|
|
|
|
|
|
|
|
# Start the bot
|
2025-02-24 14:26:48 -08:00
|
|
|
await bot.start()
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Bot start completed")
|
2025-02-24 14:26:48 -08:00
|
|
|
|
2025-02-25 00:00:45 -08:00
|
|
|
# Cancel the quiet check task
|
|
|
|
|
quiet_check_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await quiet_check_task
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
pass
|
|
|
|
|
|
2025-02-24 14:26:48 -08:00
|
|
|
# Check if we should restart this bot
|
|
|
|
|
if bot.restart_manager.should_restart:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Bot should restart")
|
2025-02-24 14:26:48 -08:00
|
|
|
# Clean up
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Cleaning up restart manager...")
|
2025-02-24 14:26:48 -08:00
|
|
|
bot.restart_manager.cleanup()
|
|
|
|
|
# Create and start a new instance
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Creating new bot instance...")
|
2025-02-24 14:26:48 -08:00
|
|
|
new_bot = IcecastBot(bot.config_path)
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Starting new bot instance...")
|
2025-02-24 14:26:48 -08:00
|
|
|
await run_single_bot(new_bot)
|
|
|
|
|
# If should_exit is True but should_restart is False, just exit cleanly
|
|
|
|
|
elif bot.should_exit:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Bot should exit cleanly")
|
2025-02-24 14:26:48 -08:00
|
|
|
bot.restart_manager.cleanup()
|
|
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.error(f"Error in run_single_bot: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 14:26:48 -08:00
|
|
|
finally:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("In run_single_bot finally block")
|
2025-02-24 14:26:48 -08:00
|
|
|
# Ensure cleanup happens
|
|
|
|
|
if bot.monitor_task:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Canceling monitor task...")
|
2025-02-24 14:26:48 -08:00
|
|
|
bot.monitor_task.cancel()
|
|
|
|
|
try:
|
|
|
|
|
await bot.monitor_task
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Monitor task canceled")
|
2025-02-24 14:26:48 -08:00
|
|
|
except asyncio.CancelledError:
|
2025-02-24 23:12:15 -08:00
|
|
|
bot.logger.info("Monitor task canceled with CancelledError")
|
2025-02-24 14:26:48 -08:00
|
|
|
pass
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2025-02-24 23:12:15 -08:00
|
|
|
# Initialize the logger
|
|
|
|
|
logger = get_logger("main")
|
|
|
|
|
|
|
|
|
|
logger.info("Starting Icecast IRC Bot...")
|
2025-02-23 00:55:11 -08:00
|
|
|
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')
|
|
|
|
|
parser.add_argument('--irc-host', type=str, help='IRC server host')
|
|
|
|
|
parser.add_argument('--irc-port', type=int, help='IRC server port')
|
|
|
|
|
parser.add_argument('--irc-nick', type=str, help='IRC nickname')
|
|
|
|
|
parser.add_argument('--irc-channel', type=str, help='IRC channel')
|
|
|
|
|
parser.add_argument('--stream-url', type=str, help='Icecast stream URL (base url; do not include /stream or .mp3)')
|
|
|
|
|
parser.add_argument('--stream-endpoint', type=str, help='Stream endpoint (e.g. /stream)')
|
2025-02-24 11:08:25 -08:00
|
|
|
parser.add_argument('--cmd-prefix', type=str, help='Command prefix character(s)')
|
2025-02-23 00:55:11 -08:00
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"Arguments parsed: {args}")
|
2025-02-23 00:55:11 -08:00
|
|
|
|
2025-02-24 11:08:25 -08:00
|
|
|
# Set up the event loop
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
asyncio.set_event_loop(loop)
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Event loop created")
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
async def run_bot():
|
|
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Starting bot...")
|
2025-02-24 11:08:25 -08:00
|
|
|
if args.configs:
|
|
|
|
|
# Multi-bot mode
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"Running in multi-bot mode with configs: {args.configs}")
|
2025-02-24 11:08:25 -08:00
|
|
|
await run_multiple_bots(args.configs)
|
|
|
|
|
else:
|
|
|
|
|
# Single-bot mode
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info(f"Running in single-bot mode with config: {args.config}")
|
2025-02-24 11:08:25 -08:00
|
|
|
bot = IcecastBot(args.config)
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Bot instance created")
|
2025-02-24 11:08:25 -08:00
|
|
|
|
|
|
|
|
# Apply any command line overrides to the config
|
|
|
|
|
if args.irc_host:
|
|
|
|
|
bot.config['irc']['host'] = args.irc_host
|
|
|
|
|
if args.irc_port:
|
|
|
|
|
bot.config['irc']['port'] = args.irc_port
|
|
|
|
|
if args.irc_nick:
|
|
|
|
|
bot.config['irc']['nick'] = args.irc_nick
|
|
|
|
|
if args.irc_channel:
|
|
|
|
|
bot.config['irc']['channel'] = args.irc_channel
|
|
|
|
|
if args.stream_url:
|
|
|
|
|
bot.config['stream']['url'] = args.stream_url
|
|
|
|
|
if args.stream_endpoint:
|
|
|
|
|
bot.config['stream']['endpoint'] = args.stream_endpoint
|
|
|
|
|
if args.cmd_prefix:
|
|
|
|
|
if 'commands' not in bot.config:
|
|
|
|
|
bot.config['commands'] = {}
|
|
|
|
|
bot.config['commands']['prefix'] = args.cmd_prefix
|
|
|
|
|
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Starting single bot...")
|
2025-02-24 14:26:48 -08:00
|
|
|
await run_single_bot(bot)
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Error in run_bot: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 11:08:25 -08:00
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Run the bot
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Running event loop...")
|
2025-02-24 11:08:25 -08:00
|
|
|
loop.run_until_complete(run_bot())
|
2025-02-24 21:48:51 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Error in main: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
2025-02-24 11:08:25 -08:00
|
|
|
finally:
|
|
|
|
|
try:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Cleaning up...")
|
2025-02-24 11:08:25 -08:00
|
|
|
# Cancel any remaining tasks
|
|
|
|
|
for task in asyncio.all_tasks(loop):
|
|
|
|
|
task.cancel()
|
|
|
|
|
# Give tasks a chance to cancel
|
|
|
|
|
loop.run_until_complete(asyncio.gather(*asyncio.all_tasks(loop), return_exceptions=True))
|
|
|
|
|
# Finally close the loop
|
|
|
|
|
loop.close()
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.info("Cleanup complete")
|
2025-02-24 11:08:25 -08:00
|
|
|
except Exception as e:
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Error during cleanup: {e}")
|
2025-02-24 21:48:51 -08:00
|
|
|
import traceback
|
2025-02-24 23:12:15 -08:00
|
|
|
logger.error(f"Traceback: {traceback.format_exc()}")
|