removed all logging due to broken implementation - known good state
This commit is contained in:
parent
252fdf4db1
commit
afff3d1161
79
README.md
79
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
|
||||
|
||||
|
||||
@ -31,9 +31,3 @@ 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
|
||||
@ -3,94 +3,369 @@
|
||||
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
|
||||
# Check both local bots and state file
|
||||
state = self._load_state()
|
||||
process = None
|
||||
|
||||
try:
|
||||
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()
|
||||
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}")
|
||||
|
||||
del self.bots[bot_id]
|
||||
logger.info(f"Stopped bot {bot_id}")
|
||||
return True
|
||||
# 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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop bot {bot_id}: {e}")
|
||||
# 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())
|
||||
@ -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
|
||||
|
||||
@ -16,6 +16,11 @@ requires-python = ">=3.11"
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "regex"
|
||||
path = "VERSION"
|
||||
pattern = "^(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user