Compare commits

..

6 Commits
v1.2.0 ... HEAD

7 changed files with 230 additions and 113 deletions

1
.gitignore vendored
View File

@ -30,6 +30,7 @@ env/
.config/
config*.yaml
!config.yaml.example
.archive/
# Logs
*.log

View File

@ -7,7 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.2.0] - 2024-07-01
## [1.2.2] - 2025-02-25
### Added
- Improved stream reconnection verification with log entries
- Enhanced metadata endpoint discovery with multiple URL patterns
- Expanded error handling and logging for metadata fetching
### Changed
- Refactored stream monitoring for better stability
- Updated metadata parsing to handle varied source formats
- General project cleanup and housekeeping
### Fixed
- Stream reconnection edge cases and timeout handling
- JSON metadata parsing reliability
- Various validations
## [1.2.1] - 2025-02-25
### Added
- Improved stream reconnection logic with automatic retry
- Enhanced metadata fetching with better error handling
- Expanded logging for better troubleshooting
### Fixed
- Issue with metadata parsing for certain stream formats
- Stream monitoring stability improvements
- Connection timeout handling
## [1.2.0] - 2025-02-25
### Added
- New admin commands:
@ -26,7 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the BotManager class to support the new terminal commands
- Updated configuration example to include the new quiet_on_start option
## [1.0.1] - 2024-02-24
## [1.0.1] - 2025-02-24
### Added
- Configurable logging levels via config.yaml
@ -64,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other
- Added version information
## [1.0.0] - 2024-02-23
## [1.0.0] - 2025-02-23
### Added
- Initial release
@ -75,7 +105,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Multi-bot support
- Configuration via YAML files
[Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.0...HEAD
[Unreleased]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.2...HEAD
[1.2.2]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.1...v1.2.2
[1.2.1]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.2.0...v1.2.1
[1.2.0]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.1...v1.2.0
[1.0.1]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/compare/v1.0.0...v1.0.1
[1.0.0]: https://code.cottongin.xyz/cottongin/Icecast-metadata-IRC-announcer/releases/tag/v1.0.0

38
TODO.md
View File

@ -1,16 +1,26 @@
## TODO
## TODO 🚧
- Better, more clear, logging
- Add a help command
- Add commands to control the stream connection from IRC
- Also to start/stop the service entirely
- Add a version command
- Add a list of commands to the README
- ⭐️ Better, more clear, logging
- Notification system for stream admin (and bot admin)
- Send notification if unexpected change in metadata
- Loss of network/stream
- Live notifications (alt. for PodPing ish)
- Implement `quiet mode`
- ⭐️ Add whitelist feature
- Add commands:
- To check the status of the bot
- To check the status of the stream
- To check the status of the IRC connection
- Version command
- Enable joining multiple channels with a single bot instance
- Add a command to force the bot to reconnect to the stream
- Add a command to check the status of the bot
- Add a command to check the status of the stream
- Add a command to check the status of the IRC connection
- Add a command to check the status of the bot
- Add a command to check the status of the stream
- Move this to a TODO.md file :)
## Complete! 🎉
- ✅ Move this to a TODO.md file 😊
- 🟢 Added commands:
- ✅ Add `quiet mode` relevant commands
- ✅ Add a help command
- ✅ To control the stream connection from IRC
- ✅ To start/stop the service entirely
- ✅ To quiet/unquiet the metadata announcing
- ✅ Add a list of commands to the README

View File

@ -1 +1 @@
1.2.0
-testing

24
bot.sh
View File

@ -1,24 +0,0 @@
#!/bin/bash
# Check if any arguments were provided
if [ $# -eq 0 ]; then
echo "Usage: $0 <config.yaml> [additional configs...]"
exit 1
fi
while true; do
# Remove any existing restart flags before starting
rm -f .restart_flag_*
python main.py "$@"
# Check for any restart flags
if ls .restart_flag_* 1> /dev/null 2>&1; then
echo "Restart flag(s) found, restarting bot(s)..."
sleep 1
continue
else
echo "Bot(s) exited without restart flag, stopping..."
break
fi
done

View File

@ -1,12 +0,0 @@
#!/usr/bin/env python3
import asyncio
from main import run_multiple_bots
CONFIG_FILES = [
'config_station1.yaml',
'config_station2.yaml',
'config_station3.yaml'
]
if __name__ == "__main__":
asyncio.run(run_multiple_bots(CONFIG_FILES))

222
main.py
View File

@ -11,7 +11,6 @@ import argparse
import yaml
from pathlib import Path
from typing import List, Optional
import sys
import inspect
import socket
import tempfile
@ -59,17 +58,6 @@ def silent_client_init(self, *args, bot_name: str = None, config: dict = None, *
original_init = Client.__init__
Client.__init__ = silent_client_init
import asyncio
import aiohttp
import time
import argparse
import yaml
from pathlib import Path
from typing import List, Optional
import sys
import inspect
import socket
import tempfile
class RestartManager:
"""Manages restart requests for the bot.
@ -443,14 +431,26 @@ class IcecastBot:
# Check if recipient is a Channel object
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
if not self.allow_private_commands and not is_channel:
return
# Always allow np command in private messages
# Only check allow_private_commands for other commands
try:
if is_channel:
# 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}")
await recipient.message(self.reply.format(song=self.current_song))
except Exception as e:
pass
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
self.command_handlers['now_playing'] = now_playing
@self.bot.on_message(create_command_pattern('help'))
@ -468,8 +468,8 @@ class IcecastBot:
# Check if recipient is a Channel object
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
if not self.allow_private_commands and not is_channel:
return
# Always allow help command in private messages
# Only check allow_private_commands for non-help commands
try:
# Parse message to check if a specific command was requested
@ -488,21 +488,23 @@ class IcecastBot:
if method_name:
handler = self.command_handlers.get(method_name)
if handler and handler.__doc__:
# 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()
if pattern in self.admin_commands:
desc = f"{desc} (admin only)"
help_text = self.help_specific_format.format(
prefix=self.cmd_prefix,
cmd=pattern,
desc=desc
)
# 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."
# 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."
else:
help_text = f"No help available for command: {pattern}"
else:
@ -517,6 +519,10 @@ class IcecastBot:
if pattern not in self.admin_commands: # If not an admin command
handler = self.command_handlers.get(method_name)
if handler and handler.__doc__:
# Skip hidden commands in channel help
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
continue
first_line = handler.__doc__.strip().split('\n')[0]
desc = first_line.split(':', 1)[1].strip()
general_commands.append(
@ -536,6 +542,10 @@ class IcecastBot:
if method_name:
handler = self.command_handlers.get(method_name)
if handler and handler.__doc__:
# Skip hidden commands in channel help
if is_channel and hasattr(handler, 'is_hidden_command') and handler.is_hidden_command:
continue
first_line = handler.__doc__.strip().split('\n')[0]
desc = first_line.split(':', 1)[1].strip()
# Don't add (admin only) in the list view
@ -550,16 +560,28 @@ class IcecastBot:
help_text = f"\x02Icecast Bot v{self.VERSION}\x02 | " + " | ".join(formatted_groups)
if is_channel:
# 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}")
await recipient.message(help_text)
except Exception as e:
pass
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
self.command_handlers['help_command'] = help_command
@self.bot.on_message(create_command_pattern('restart'))
@self.admin_required
async def restart_bot(message):
"""!restart: Restart the bot (admin only)
"""!restart: Restart the bot
Gracefully shuts down the bot and signals the bot.sh script
to restart it. This ensures a clean restart.
@ -581,7 +603,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('quit'))
@self.admin_required
async def quit_bot(message):
"""!quit: Shutdown the bot (admin only)
"""!quit: Shutdown the bot
Gracefully shuts down the bot and exits without restarting.
@ -600,7 +622,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('reconnect'))
@self.admin_required
async def reconnect_stream(message):
"""!reconnect: Reconnect to the stream (admin only)
"""!reconnect: Reconnect to the stream
Attempts to reconnect to the stream and verifies the connection.
Reports success or failure back to the channel.
@ -616,7 +638,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('stop'))
@self.admin_required
async def stop_monitoring(message):
"""!stop: Stop stream monitoring (admin only)
"""!stop: Stop stream monitoring
Stops monitoring the stream for metadata changes.
The bot remains connected to IRC.
@ -632,7 +654,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('start'))
@self.admin_required
async def start_monitoring(message):
"""!start: Start stream monitoring (admin only)
"""!start: Start stream monitoring
Starts monitoring the stream for metadata changes.
Will announce new songs in the channel.
@ -648,7 +670,7 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('quiet'))
@self.admin_required
async def quiet_bot(message):
"""!quiet: Disable song announcements (admin only)
"""!quiet: Disable song announcements
Continues monitoring the stream for metadata changes,
but stops announcing songs in the channel.
@ -665,20 +687,59 @@ class IcecastBot:
@self.bot.on_message(create_command_pattern('unquiet'))
@self.admin_required
async def unquiet_bot(message):
"""!unquiet: Enable song announcements (admin only)
"""!unquiet: Re-enable song announcements
Resumes announcing songs in the channel.
The bot must already be monitoring the stream.
The opposite of !quiet - allows the bot to resume announcing songs.
Args:
message: IRC message object
"""
self.should_announce = True
self.logger.info("Song announcements enabled by admin command")
if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
await message.recipient.message("Song announcements enabled. Bot will now announce songs.")
try:
self.should_announce = True
await message.recipient.message("Song announcements re-enabled.")
except Exception as e:
pass
self.command_handlers['unquiet_bot'] = unquiet_bot
@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
def _build_command_mappings(self):
"""Build bidirectional mappings between command patterns and method names.
@ -1224,19 +1285,44 @@ class IcecastBot:
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}"
# Check if we're in a channel
recipient = getattr(message, 'recipient', None)
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
if self.is_admin(message.sender):
admin_cmds = (
admin_cmds = []
# Basic admin commands
basic_admin = (
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)"
)
help_text += f" | Admin: {admin_cmds}"
admin_cmds.append(basic_admin)
if hasattr(message.recipient, 'name') and message.recipient.name.startswith('#'):
await message.recipient.message(help_text)
# 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)}"
# 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)
except Exception as e:
pass
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
def admin_required(self, f):
"""Decorator to mark a command as requiring admin privileges.
@ -1254,12 +1340,16 @@ class IcecastBot:
recipient = getattr(message, 'recipient', None)
is_channel = hasattr(recipient, 'name') and recipient.name.startswith('#')
if not self.allow_private_commands and not is_channel:
return
# 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
if not self.is_admin(message.sender):
if is_channel:
await recipient.message("You don't have permission to use this command.")
await recipient.message("You don't have permission to use this command.")
return
return await f(message, *args, **kwargs)
@ -1268,6 +1358,10 @@ class IcecastBot:
wrapped.__doc__ = f.__doc__
wrapped.__name__ = f.__name__
# Preserve the is_hidden_command attribute if it exists
if hasattr(f, 'is_hidden_command'):
wrapped.is_hidden_command = f.is_hidden_command
# Add the command pattern to admin_commands set
if f.__doc__ and f.__doc__.strip().startswith('!'):
pattern = f.__doc__.strip().split(':', 1)[0].strip('!')
@ -1275,6 +1369,22 @@ class IcecastBot:
return wrapped
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
async def run_multiple_bots(config_paths: List[str]):
"""Run multiple bot instances concurrently.