improved logging, added docs, better help support, much improved instancing, pip installable
This commit is contained in:
parent
6817437380
commit
252fdf4db1
137
DOCS.md
Normal file
137
DOCS.md
Normal file
@ -0,0 +1,137 @@
|
||||
# Icecast IRC Bot Documentation v1.1.0
|
||||
|
||||
This document is automatically generated from the codebase.
|
||||
|
||||
## Overview
|
||||
|
||||
An IRC bot that monitors an Icecast stream and announces track changes.
|
||||
|
||||
## Configuration
|
||||
|
||||
See `config.yaml.example` for a full example configuration file.
|
||||
|
||||
No command documentation available (help template not found)
|
||||
|
||||
## Methods
|
||||
|
||||
### admin_required
|
||||
|
||||
Decorator to mark a command as requiring admin privileges.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `f`: The command handler function to wrap.
|
||||
- `Returns`:
|
||||
|
||||
|
||||
### announce_song
|
||||
|
||||
Announce a song in the IRC channel.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `song`: The song title to announce.
|
||||
- `Only announces if`:
|
||||
|
||||
|
||||
### fetch_json_metadata
|
||||
|
||||
Fetch metadata from the Icecast JSON status endpoint.
|
||||
|
||||
**Returns:**
|
||||
|
||||
str: The current song title, or an error message if fetching failed.
|
||||
|
||||
|
||||
### format_help_section
|
||||
|
||||
Format a help section according to the template.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `section_config`: Configuration dictionary for the section.
|
||||
- `prefix`: Command prefix to use.
|
||||
- `Returns`:
|
||||
- `List[str]`: List of formatted help lines for each command.
|
||||
|
||||
|
||||
### get_version
|
||||
|
||||
Get the current version from VERSION file.
|
||||
|
||||
|
||||
### help_command_fallback
|
||||
|
||||
Fallback help command implementation using hardcoded format.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `message`: The IRC message object that triggered this command.
|
||||
|
||||
|
||||
### is_admin
|
||||
|
||||
Check if a user has admin privileges.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `user`: Full IRC user string (nickname!username@hostname) or User object.
|
||||
- `Returns`:
|
||||
- `bool`: True if user has admin privileges, False otherwise.
|
||||
|
||||
|
||||
### load_config
|
||||
|
||||
Load and validate the bot configuration from a YAML file.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `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.
|
||||
|
||||
|
||||
### monitor_metadata
|
||||
|
||||
Monitor the Icecast stream for metadata changes.
|
||||
|
||||
|
||||
### restart_monitoring
|
||||
|
||||
Restart the metadata monitoring task and verify the reconnection.
|
||||
|
||||
**Returns:**
|
||||
|
||||
bool: True if reconnection was successful and verified, False otherwise.
|
||||
|
||||
|
||||
### setup_handlers
|
||||
|
||||
Set up all IRC event handlers and command patterns.
|
||||
|
||||
|
||||
### should_announce_song
|
||||
|
||||
Check if a song should be announced based on configured ignore patterns.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
- `song`: The song title to check.
|
||||
- `Returns`:
|
||||
- `bool`: True if the song should be announced, False if it matches any ignore patterns.
|
||||
|
||||
|
||||
### start
|
||||
|
||||
Start the IRC bot and begin processing events.
|
||||
|
||||
|
||||
### start_monitoring
|
||||
|
||||
Start the metadata monitoring task.
|
||||
|
||||
|
||||
### stop_monitoring
|
||||
|
||||
Stop the metadata monitoring task.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Icecast-metadata-IRC-announcer
|
||||
|
||||
[](https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases/tag/v1.0.1)
|
||||
[](https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases)
|
||||
|
||||
A simple asynchronous Python bot that monitors an Icecast stream and announces track changes to an IRC channel. Supports running multiple instances with different configurations.
|
||||
|
||||
|
||||
@ -23,9 +23,17 @@ 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
|
||||
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
|
||||
99
generate_docs.py
Executable file
99
generate_docs.py
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import inspect
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from main import IcecastBot
|
||||
|
||||
def get_version():
|
||||
"""Get the current version from VERSION file."""
|
||||
try:
|
||||
with open('VERSION') as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return "Unknown"
|
||||
|
||||
def format_docstring(obj):
|
||||
"""Format a docstring into markdown."""
|
||||
doc = inspect.getdoc(obj)
|
||||
if not doc:
|
||||
return ""
|
||||
|
||||
# Split into description and args
|
||||
parts = doc.split('\n\n')
|
||||
formatted = [parts[0]] # Description
|
||||
|
||||
for part in parts[1:]:
|
||||
if part.startswith('Args:'):
|
||||
formatted.append("\n**Arguments:**\n")
|
||||
# Parse args section
|
||||
args = part.replace('Args:', '').strip().split('\n')
|
||||
for arg in args:
|
||||
if ':' in arg:
|
||||
name, desc = arg.split(':', 1)
|
||||
formatted.append(f"- `{name.strip()}`: {desc.strip()}")
|
||||
elif part.startswith('Returns:'):
|
||||
formatted.append("\n**Returns:**\n")
|
||||
formatted.append(part.replace('Returns:', '').strip())
|
||||
|
||||
return '\n'.join(formatted)
|
||||
|
||||
def generate_command_docs(config_path='config.yaml.example'):
|
||||
"""Generate command documentation from help templates."""
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
return "No command documentation available (config file not found)"
|
||||
|
||||
help_config = config.get('commands', {}).get('help', {})
|
||||
if not help_config:
|
||||
return "No command documentation available (help template not found)"
|
||||
|
||||
docs = ["## Commands\n"]
|
||||
|
||||
# Regular commands
|
||||
if 'commands' in help_config.get('sections', {}):
|
||||
docs.append("### Regular Commands\n")
|
||||
for cmd, desc in help_config['sections']['commands']['commands'].items():
|
||||
docs.append(f"- `{cmd}`: {desc}")
|
||||
docs.append("")
|
||||
|
||||
# Admin commands
|
||||
if 'admin' in help_config.get('sections', {}):
|
||||
docs.append("### Admin Commands\n")
|
||||
docs.append("These commands are only available to users listed in the `admin.users` config section.\n")
|
||||
for cmd, desc in help_config['sections']['admin']['commands'].items():
|
||||
docs.append(f"- `{cmd}`: {desc}")
|
||||
docs.append("")
|
||||
|
||||
return '\n'.join(docs)
|
||||
|
||||
def generate_docs():
|
||||
"""Generate full documentation in markdown format."""
|
||||
version = get_version()
|
||||
|
||||
docs = [
|
||||
f"# Icecast IRC Bot Documentation v{version}\n",
|
||||
"This document is automatically generated from the codebase.\n",
|
||||
"## Overview\n",
|
||||
format_docstring(IcecastBot),
|
||||
"\n## Configuration\n",
|
||||
"See `config.yaml.example` for a full example configuration file.\n",
|
||||
generate_command_docs(),
|
||||
"\n## Methods\n"
|
||||
]
|
||||
|
||||
# Document public methods
|
||||
for name, method in inspect.getmembers(IcecastBot, predicate=inspect.isfunction):
|
||||
if not name.startswith('_'): # Only public methods
|
||||
docs.append(f"### {name}\n")
|
||||
docs.append(format_docstring(method))
|
||||
docs.append("\n")
|
||||
|
||||
# Write to DOCS.md
|
||||
with open('DOCS.md', 'w') as f:
|
||||
f.write('\n'.join(docs))
|
||||
|
||||
if __name__ == '__main__':
|
||||
generate_docs()
|
||||
194
icecast-irc-bot-manager.py
Normal file
194
icecast-irc-bot-manager.py
Normal file
@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
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')
|
||||
|
||||
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.socket_dir = Path(tempfile.gettempdir())
|
||||
|
||||
async def start_bot(self, config_path: Path) -> bool:
|
||||
"""Start a bot instance with the given config.
|
||||
|
||||
Args:
|
||||
config_path: Path to the bot's config file
|
||||
|
||||
Returns:
|
||||
bool: True if bot was started successfully
|
||||
"""
|
||||
try:
|
||||
# Create unique name for this bot instance
|
||||
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
|
||||
|
||||
# Start the bot process
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable, '-m', 'main',
|
||||
'--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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start bot with config {config_path}: {e}")
|
||||
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
|
||||
|
||||
Returns:
|
||||
bool: True if bot was stopped successfully
|
||||
"""
|
||||
if bot_id not in self.bots:
|
||||
logger.warning(f"Bot {bot_id} is not running")
|
||||
return False
|
||||
|
||||
try:
|
||||
process = self.bots[bot_id]
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
del self.bots[bot_id]
|
||||
logger.info(f"Stopped bot {bot_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to stop bot {bot_id}: {e}")
|
||||
return False
|
||||
|
||||
async def restart_bot(self, bot_id: str) -> bool:
|
||||
"""Restart a running bot instance.
|
||||
|
||||
Args:
|
||||
bot_id: ID of the bot to restart
|
||||
|
||||
Returns:
|
||||
bool: True if bot was restarted successfully
|
||||
"""
|
||||
# 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
|
||||
if bot_id in self.bots:
|
||||
if not await self.stop_bot(bot_id):
|
||||
return False
|
||||
|
||||
# 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.
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of bot info dictionaries
|
||||
"""
|
||||
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
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up all running bots."""
|
||||
for bot_id in list(self.bots.keys()):
|
||||
await self.stop_bot(bot_id)
|
||||
|
||||
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')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
manager = BotManager()
|
||||
|
||||
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")
|
||||
|
||||
elif args.command == 'start':
|
||||
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
|
||||
for config_file in config_path.glob('*.yaml'):
|
||||
await manager.start_bot(config_file)
|
||||
else:
|
||||
# Start single bot
|
||||
await manager.start_bot(config_path)
|
||||
|
||||
elif args.command == 'stop':
|
||||
if not args.bot_id:
|
||||
print("Error: bot_id required for stop command")
|
||||
sys.exit(1)
|
||||
await manager.stop_bot(args.bot_id)
|
||||
|
||||
elif args.command == 'restart':
|
||||
if not args.bot_id:
|
||||
print("Error: bot_id required for restart command")
|
||||
sys.exit(1)
|
||||
await manager.restart_bot(args.bot_id)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
await manager.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Set up signal handlers
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
signal.signal(sig, lambda s, f: sys.exit(0))
|
||||
|
||||
# Run the manager
|
||||
asyncio.run(main())
|
||||
26
icecast-irc-bot-manager.service
Normal file
26
icecast-irc-bot-manager.service
Normal file
@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Icecast IRC Bot Manager
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
Documentation=https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=icecast-bot
|
||||
Group=icecast-bot
|
||||
Environment=VIRTUAL_ENV=/opt/icecast-irc-bot/venv
|
||||
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
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=full
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
71
install.sh
Executable file
71
install.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on any error
|
||||
set -e
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create icecast-bot user and group if they don't exist
|
||||
if ! getent group icecast-bot >/dev/null; then
|
||||
groupadd icecast-bot
|
||||
fi
|
||||
if ! getent passwd icecast-bot >/dev/null; then
|
||||
useradd -r -g icecast-bot -s /bin/false icecast-bot
|
||||
fi
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p /etc/icecast-irc-bot
|
||||
mkdir -p /var/log/icecast-irc-bot
|
||||
mkdir -p /opt/icecast-irc-bot
|
||||
|
||||
# Set ownership
|
||||
chown icecast-bot:icecast-bot /etc/icecast-irc-bot
|
||||
chown icecast-bot:icecast-bot /var/log/icecast-irc-bot
|
||||
chown icecast-bot:icecast-bot /opt/icecast-irc-bot
|
||||
|
||||
# Create and activate virtual environment
|
||||
python3 -m venv /opt/icecast-irc-bot/venv
|
||||
export VIRTUAL_ENV="/opt/icecast-irc-bot/venv"
|
||||
export PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
unset PYTHONHOME
|
||||
|
||||
# Install Python dependencies in virtual environment
|
||||
pip3 install --upgrade pip
|
||||
pip3 install hatchling
|
||||
pip3 install -r requirements.txt
|
||||
pip3 install .
|
||||
|
||||
# Install manager script
|
||||
cp icecast-irc-bot-manager.py /usr/local/bin/icecast-irc-bot-manager
|
||||
chmod +x /usr/local/bin/icecast-irc-bot-manager
|
||||
|
||||
# Update manager script shebang to use venv Python
|
||||
sed -i "1c#\!$VIRTUAL_ENV/bin/python3" /usr/local/bin/icecast-irc-bot-manager
|
||||
|
||||
# Install service file
|
||||
cp icecast-irc-bot-manager.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
# Copy example config if it doesn't exist
|
||||
if [ ! -f /etc/icecast-irc-bot/config.yaml ]; then
|
||||
cp config.yaml.example /etc/icecast-irc-bot/config.yaml
|
||||
chown icecast-bot:icecast-bot /etc/icecast-irc-bot/config.yaml
|
||||
chmod 640 /etc/icecast-irc-bot/config.yaml
|
||||
fi
|
||||
|
||||
echo "Installation complete!"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Configure your bot(s) in /etc/icecast-irc-bot/config.yaml"
|
||||
echo "2. Start the service: systemctl start icecast-irc-bot-manager"
|
||||
echo "3. Enable at boot: systemctl enable icecast-irc-bot-manager"
|
||||
echo
|
||||
echo "Usage:"
|
||||
echo "icecast-irc-bot-manager list # List running bots"
|
||||
echo "icecast-irc-bot-manager start --config FILE # Start a bot"
|
||||
echo "icecast-irc-bot-manager stop BOT_ID # Stop a bot"
|
||||
echo "icecast-irc-bot-manager restart BOT_ID # Restart a bot"
|
||||
31
pyproject.toml
Normal file
31
pyproject.toml
Normal file
@ -0,0 +1,31 @@
|
||||
[project]
|
||||
name = "icecast-irc-bot"
|
||||
dynamic = ["version"]
|
||||
description = "Icecast metadata IRC announcer bot"
|
||||
authors = [
|
||||
{name = "cottongin", email = "cottongin@cottongin.xyz"},
|
||||
]
|
||||
dependencies = [
|
||||
"asif",
|
||||
"aiohttp",
|
||||
"pyyaml",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
[tool.hatch.build]
|
||||
include = [
|
||||
"*.py",
|
||||
"README*",
|
||||
"LICENSE*",
|
||||
"config.yaml.example"
|
||||
]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {file = "VERSION"}
|
||||
Loading…
x
Reference in New Issue
Block a user