diff --git a/README.md b/README.md index b12527e..ae8ff05 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,99 @@ -# pylons +# Pylons: Modern Async Eggdrop in Pure Python +## Overview +Pylons is a flexible and extensible IRC bot network using a hub-and-leaf architecture. It implements a central hub bot managing multiple leaf bots, allowing for distributed IRC presence and easy scalability. + +## Version History +- v1.0.0: Initial release with basic hub and leaf functionality +- v1.1.0: Added plugin support and dynamic command handling +- v1.2.0: Implemented encryption for hub-leaf communication +- v1.3.0: Added support for IPv6 and improved error handling +- v1.4.0: Introduced dynamic configuration updates (UPDATECONF commands) +- v1.5.0: Improved nickname collision handling, added random username/realname generation, and enhanced ban evasion techniques + +## Features +- Centralized hub bot managing multiple leaf bots +- Secure communication between hub and leaf bots using custom encryption +- Plugin system for easy extension of functionality +- Support for both IPv4 and IPv6 +- Dynamic nick generation using Pokémon names with collision avoidance +- Random, RFC-compliant username and realname generation for improved ban evasion +- Automatic reconnection and error recovery +- Runtime configuration updates +- Kickban detection and automatic rejoin attempts +- Improved handling of Unicode characters to prevent crashes + +## How It Works +1. The hub bot starts and listens for incoming connections from leaf bots. +2. Leaf bots connect to the hub and receive their initial configuration and available commands. +3. The hub bot manages all leaf bots, broadcasting commands and updates as necessary. +4. Leaf bots connect to IRC servers and channels based on their configuration from the hub. +5. Commands entered in the hub are executed across all connected leaf bots. +6. Dynamic Plugin creation that allows on-the-fly unloading and loading to broadcast new configurations to the links + +## Command List +- `test`: Send a test message to the channel +- `join `: Join a new channel +- `leave `: Leave a channel +- `nick`: Change nickname (generates a new random Pokémon-based nickname) +- `UPDATECONF
`: Update hub configuration +- `UPDATECONF.IRC [channel_password] [-ssl]`: Update IRC configuration + +## Setup and Usage +### Hub Bot +1. Clone the repository: + ``` + git clone https://github.com/strangeprogram/pylons.git + cd pylons + ``` +2. Run the hub bot: + ``` + python pylon.py --hub-address 0.0.0.0 --hub-port 8888 --server irc.example.com --port 6667 --channel "#your-channel" + ``` + Add `--ssl` if your IRC server uses SSL. + +### Leaf Bot +1. Run the leaf bot, pointing it to your hub: + ``` + python shard.py hub.example.com 8888 + ``` + Replace `hub.example.com` with the address of your hub bot. + +## New Features and Improvements +- **Nickname Collision Handling**: The hub now maintains a dictionary of used nicknames, ensuring unique nicks across all leaf bots. +- **Random Username and Realname Generation**: Each leaf bot now generates random, RFC-compliant usernames and realnames for each connection, making it harder to create regex patterns for bans. +- **Kickban Detection**: Leaf bots now detect kicks and bans, attempting to rejoin channels automatically with a limited number of retries. +- **Unicode Handling**: Improved handling of non-UTF-8 characters in IRC messages to prevent bot crashes. +- **Enhanced SSL Support**: Better SSL context handling for more secure connections. + +## Plugin Development +To create a new plugin: +1. Create a new Python file in the `plugins` directory. +2. Define a class that inherits from `BasePlugin`. +3. Implement the `on_command` method and define a `commands` property. + +Example: +```python +from hub_bot import BasePlugin + +class MyPlugin(BasePlugin): + async def on_command(self, sender, channel, command, args): + if command == "hello": + return {"type": "action", "action": "send_message", "channel": channel, "message": "Hello, World!"} + + @property + def commands(self): + return {"hello": "Say hello"} +``` + +## Requirements +- Python 3.7+ +- asyncio + +## License +This project is licensed under the GPL License - see the [LICENSE](LICENSE) file for details. + +## Links +- [Project Homepage](https://github.com/strangeprogram/pylons) +- [Issue Tracker](https://github.com/strangeprogram/pylons/issues) +- [IRC Channel](#): #dev on irc.supernets.org diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..2f72576 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1 @@ +from .base_plugin import BasePlugin diff --git a/pylon.py b/pylon.py new file mode 100644 index 0000000..edff7be --- /dev/null +++ b/pylon.py @@ -0,0 +1,342 @@ +import asyncio +import json +import random +import logging +import argparse +import socket +import ssl +import base64 +import os +import inspect +import importlib + +POKEMON_NAMES = [ + "Bulbasaur", "Ivysaur", "Venusaur", "Charmander", "Charmeleon", "Charizard", + "Squirtle", "Wartortle", "Blastoise", "Caterpie", "Metapod", "Butterfree", + "Weedle", "Kakuna", "Beedrill", "Pidgey", "Pidgeotto", "Pidgeot", "Rattata", + "Raticate", "Spearow", "Fearow", "Ekans", "Arbok", "Pikachu", "Raichu", + "Sandshrew", "Sandslash", "Nidoran", "Nidorina", "Nidoqueen", "Nidoran♂", + "Nidorino", "Nidoking", "Clefairy", "Clefable", "Vulpix", "Ninetales", + "Jigglypuff", "Wigglytuff", "Zubat", "Golbat", "Oddish", "Gloom", "Vileplume", + "Paras", "Parasect", "Venonat", "Venomoth", "Diglett", "Dugtrio", "Meowth", + "Persian", "Psyduck", "Golduck", "Mankey", "Primeape", "Growlithe", "Arcanine", + "Poliwag", "Poliwhirl", "Poliwrath", "Abra", "Kadabra", "Alakazam", "Machop", + "Machoke", "Machamp", "Bellsprout", "Weepinbell", "Victreebel", "Tentacool", + "Tentacruel", "Geodude", "Graveler", "Golem", "Ponyta", "Rapidash", "Slowpoke", + "Slowbro", "Magnemite", "Magneton", "Farfetchd", "Doduo", "Dodrio", "Seel", + "Dewgong", "Grimer", "Muk", "Shellder", "Cloyster", "Gastly", "Haunter", + "Gengar", "Onix", "Drowzee", "Hypno", "Krabby", "Kingler", "Voltorb", + "Electrode", "Exeggcute", "Exeggutor", "Cubone", "Marowak", "Hitmonlee", + "Hitmonchan", "Lickitung", "Koffing", "Weezing", "Rhyhorn", "Rhydon", "Chansey", + "Tangela", "Kangaskhan", "Horsea", "Seadra", "Goldeen", "Seaking", "Staryu", + "Starmie", "MrMime", "Scyther", "Jynx", "Electabuzz", "Magmar", "Pinsir", + "Tauros", "Magikarp", "Gyarados", "Lapras", "Ditto", "Eevee", "Vaporeon", + "Jolteon", "Flareon", "Porygon", "Omanyte", "Omastar", "Kabuto", "Kabutops", + "Aerodactyl", "Snorlax", "Articuno", "Zapdos", "Moltres", "Dratini", + "Dragonair", "Dragonite", "Mewtwo", "Mew" +] + +def generate_nick(): + return f"{random.choice(POKEMON_NAMES)}{random.randint(100, 999)}" + +class SimpleEncryption: + def __init__(self, key): + self.key = key + + def encrypt(self, data): + encrypted = bytearray() + for i, char in enumerate(data): + encrypted.append(ord(char) ^ self.key[i % len(self.key)]) + return base64.b64encode(encrypted).decode() + + def decrypt(self, data): + encrypted = base64.b64decode(data) + decrypted = bytearray() + for i, byte in enumerate(encrypted): + decrypted.append(byte ^ self.key[i % len(self.key)]) + return decrypted.decode() + +class BasePlugin: + def __init__(self, bot): + self.bot = bot + + async def on_command(self, sender, channel, command, args): + pass + + @property + def commands(self): + return {} + +class CommandHub: + def __init__(self, hub_address, hub_port, irc_server, irc_port, irc_channel, use_ssl=False, channel_password=None, server_password=None): + self.hub_config = { + "address": hub_address, + "port": hub_port, + } + self.leaf_bots = set() + self.irc_config = { + "server": irc_server, + "port": irc_port, + "channel": irc_channel, + "use_ssl": use_ssl, + "channel_password": channel_password, + "password": server_password, + } + self.encryption_key = os.urandom(32) + self.encryption = SimpleEncryption(self.encryption_key) + self.plugins = [] + self.commands = { + "test": "Send a test message to the channel", + "join": "Join a new channel", + "leave": "Leave a channel", + "nick": "Change nickname", + "UPDATECONF": "Update hub configuration", + "UPDATECONF.IRC": "Update IRC configuration" + } + self.used_nicks = {} + self.load_plugins() + + def load_plugins(self): + new_plugins = [] + plugins_dir = os.path.join(os.path.dirname(__file__), "plugins") + for filename in os.listdir(plugins_dir): + if filename.endswith(".py") and not filename.startswith("__"): + module_name = f"plugins.{filename[:-3]}" + module = importlib.import_module(module_name) + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, BasePlugin) and obj != BasePlugin: + plugin = obj(self) + self.plugins.append(plugin) + self.commands.update(plugin.commands) + new_plugins.append(name) + logging.info(f"Loaded new plugin: {name}") + return new_plugins + + def generate_unique_nick(self, base_nick): + count = 0 + while f"{base_nick}{count if count else ''}" in self.used_nicks: + count += 1 + new_nick = f"{base_nick}{count if count else ''}" + self.used_nicks[new_nick] = new_nick + return new_nick + + def release_nick(self, nick): + if nick in self.used_nicks: + del self.used_nicks[nick] + + async def handle_leaf_connection(self, reader, writer): + addr = writer.get_extra_info('peername') + logging.info(f"New leaf bot connected: {addr}") + + writer.write(self.encryption_key) + await writer.drain() + + leaf_config = self.irc_config.copy() + leaf_config["nickname"] = self.generate_unique_nick(generate_nick()) + + encrypted_config = self.encryption.encrypt(json.dumps(leaf_config)) + writer.write(encrypted_config.encode() + b'\n') + await writer.drain() + + encrypted_commands = self.encryption.encrypt(json.dumps(self.commands)) + writer.write(encrypted_commands.encode() + b'\n') + await writer.drain() + + self.leaf_bots.add(writer) + try: + while True: + data = await reader.readline() + if not data: + break + encrypted_message = data.decode().strip() + message = self.encryption.decrypt(encrypted_message) + logging.info(f"Received from leaf bot {addr}: {message}") + await self.process_leaf_message(writer, message) + finally: + self.leaf_bots.remove(writer) + writer.close() + await writer.wait_closed() + logging.info(f"Leaf bot disconnected: {addr}") + + async def process_leaf_message(self, writer, message): + try: + data = json.loads(message) + if data['type'] == 'command': + response = await self.execute_command(data['sender'], data['channel'], data['command'], data.get('args', [])) + encrypted_response = self.encryption.encrypt(json.dumps(response)) + writer.write(encrypted_response.encode() + b'\n') + await writer.drain() + elif data['type'] == 'nick_update': + if 'old_nick' in data: + self.release_nick(data['old_nick']) + if 'new_nick' in data: + new_nick = self.generate_unique_nick(data['new_nick']) + response = {"type": "action", "action": "set_nick", "nickname": new_nick} + encrypted_response = self.encryption.encrypt(json.dumps(response)) + writer.write(encrypted_response.encode() + b'\n') + await writer.drain() + elif data['type'] == 'alert': + logging.warning(f"Alert from leaf bot: {data['message']}") + else: + logging.warning(f"Unknown message type from leaf bot: {data['type']}") + except json.JSONDecodeError: + logging.error(f"Received invalid JSON from leaf bot: {message}") + except Exception as e: + logging.error(f"Error processing leaf message: {e}") + + async def execute_command(self, sender, channel, command, args): + if command == "test": + return {"type": "action", "action": "send_message", "channel": self.irc_config['channel'], "message": "Test message from hub"} + elif command == "join": + return {"type": "action", "action": "join_channel", "channel": args[0] if args else channel} + elif command == "leave": + return {"type": "action", "action": "leave_channel", "channel": args[0] if args else channel} + elif command == "nick": + new_nick = self.generate_unique_nick(generate_nick()) + return {"type": "action", "action": "change_nick", "nickname": new_nick} + elif command == "request_nick": + base_nick = args[0] if args else generate_nick() + new_nick = self.generate_unique_nick(base_nick) + return {"type": "action", "action": "set_nick", "nickname": new_nick} + elif command == "release_nick": + if args: + self.release_nick(args[0]) + return {"type": "action", "action": "nick_released"} + elif command == "UPDATECONF": + return self.update_hub_config(args) + elif command == "UPDATECONF.IRC": + return self.update_irc_config(args) + else: + for plugin in self.plugins: + result = await plugin.on_command(sender, channel, command, args) + if result: + return result + return {"type": "error", "message": "Unknown command"} + + def update_hub_config(self, params): + if len(params) >= 2: + self.hub_config["address"] = params[0] + self.hub_config["port"] = int(params[1]) + elif len(params) == 1: + if params[0].isdigit(): + self.hub_config["port"] = int(params[0]) + else: + self.hub_config["address"] = params[0] + logging.info(f"Updated hub configuration: {self.hub_config}") + return {"type": "action", "action": "update_hub_config", "config": self.hub_config} + + def update_irc_config(self, params): + if len(params) >= 3: + self.irc_config["server"] = params[0] + self.irc_config["port"] = int(params[1]) + self.irc_config["channel"] = params[2] + self.irc_config["channel_password"] = params[3] if len(params) > 3 else None + self.irc_config["use_ssl"] = True if len(params) > 4 and params[4] == "-ssl" else False + logging.info(f"Updated IRC configuration: {self.irc_config}") + return {"type": "action", "action": "update_irc_config", "config": self.irc_config} + + async def broadcast_command(self, command, params): + response = await self.execute_command("Console", "Hub", command, params) + encrypted_response = self.encryption.encrypt(json.dumps(response)) + for bot in self.leaf_bots: + try: + bot.write(encrypted_response.encode() + b'\n') + await bot.drain() + except Exception as e: + logging.error(f"Error broadcasting command to leaf bot: {e}") + + async def run_hub_server(self): + servers = [] + + # Try to start IPv6 server + try: + server_ipv6 = await asyncio.start_server( + self.handle_leaf_connection, '::', self.hub_config['port'], family=socket.AF_INET6) + servers.append(server_ipv6) + logging.info(f'Serving on IPv6: {server_ipv6.sockets[0].getsockname()}') + except Exception as e: + logging.warning(f"Failed to start server on IPv6: {e}") + + # Try to start IPv4 server + try: + server_ipv4 = await asyncio.start_server( + self.handle_leaf_connection, self.hub_config['address'], self.hub_config['port'], family=socket.AF_INET) + servers.append(server_ipv4) + logging.info(f'Serving on IPv4: {server_ipv4.sockets[0].getsockname()}') + except Exception as e: + logging.warning(f"Failed to start server on IPv4: {e}") + + if not servers: + logging.error("Failed to start any servers. Exiting.") + return + + await asyncio.gather(*(server.serve_forever() for server in servers)) + + async def console_input(self): + while True: + try: + command = await asyncio.get_event_loop().run_in_executor(None, input, "Enter command: ") + parts = command.split() + if parts: + await self.broadcast_command(parts[0], parts[1:]) + else: + logging.warning("Empty command entered") + except Exception as e: + logging.error(f"Error processing console input: {e}") + + async def run(self): + await asyncio.gather( + self.run_hub_server(), + self.console_input() + ) + +def setup_logger(debug=False): + level = logging.DEBUG if debug else logging.INFO + logging.basicConfig(level=level, + format='%(asctime)s | %(levelname)8s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + +async def main(args): + setup_logger(args.debug) + while True: + try: + hub_bot = CommandHub( + args.hub_address, + args.hub_port, + args.server, + args.port, + args.channel, + use_ssl=args.ssl, + channel_password=args.key, + server_password=args.password, + ) + await hub_bot.run() + except Exception as e: + logging.error(f"HubBot crashed: {e}") + logging.info("Restarting HubBot in 30 seconds...") + await asyncio.sleep(30) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="IRC Hub Bot") + parser.add_argument("--hub-address", default="0.0.0.0", help="Address for the hub to listen on") + parser.add_argument("--hub-port", type=int, default=8888, help="Port for the hub to listen on") + parser.add_argument("--server", required=True, help="The IRC server address") + parser.add_argument("--port", type=int, default=6667, help="The IRC server port") + parser.add_argument("--channel", required=True, help="The IRC channel to join") + parser.add_argument("--ssl", action="store_true", help="Use SSL for IRC connection") + parser.add_argument("--key", help="The key (password) for the IRC channel, if required") + parser.add_argument("--password", help="The password for the IRC server, if required") + parser.add_argument("--debug", action="store_true", help="Enable debug mode") + args = parser.parse_args() + + if args.ssl and args.port == 6667: + args.port = 6697 # Default SSL port + + try: + asyncio.run(main(args)) + except KeyboardInterrupt: + print("Hub bot stopped by user.") + except Exception as e: + logging.error(f"Fatal error in main loop: {e}") + import traceback + traceback.print_exc() diff --git a/shard.py b/shard.py new file mode 100644 index 0000000..f33566d --- /dev/null +++ b/shard.py @@ -0,0 +1,390 @@ +import asyncio +import json +import ssl +import logging +import argparse +import socket +import base64 +import os +import string +import random + +def ssl_ctx(verify: bool = False): + ctx = ssl.create_default_context() if verify else ssl._create_unverified_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + +def get_ip_type(host, port): + try: + addrinfo = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) + if addrinfo[0][0] == socket.AF_INET6: + return socket.AF_INET6 + else: + return socket.AF_INET + except socket.gaierror: + return socket.AF_INET # Default to IPv4 if resolution fails + +class SimpleEncryption: + def __init__(self, key): + self.key = key + + def encrypt(self, data): + encrypted = bytearray() + for i, char in enumerate(data): + encrypted.append(ord(char) ^ self.key[i % len(self.key)]) + return base64.b64encode(encrypted).decode() + + def decrypt(self, data): + encrypted = base64.b64decode(data) + decrypted = bytearray() + for i, byte in enumerate(encrypted): + decrypted.append(byte ^ self.key[i % len(self.key)]) + return decrypted.decode() + +class BasePlugin: + def __init__(self, bot): + self.bot = bot + + async def on_command(self, sender, channel, command, args): + pass + + async def on_message(self, sender, channel, message): + pass + + @property + def commands(self): + return {} + +def generate_random_string(length=8): + # Start with a letter (RFC 2812 compliant) + first_char = random.choice(string.ascii_letters) + # The rest can be letters, digits, or certain special characters + rest_chars = ''.join(random.choices(string.ascii_letters + string.digits + '-_[]{}\\`|', k=length-1)) + return first_char + rest_chars + +class LeafBot: + def __init__(self, hub_host, hub_port): + self.hub_config = { + "address": hub_host, + "port": hub_port + } + self.hub_reader = None + self.hub_writer = None + self.irc_reader = None + self.irc_writer = None + self.irc_config = None + self.commands = None + self.nickname = None + self.username = generate_random_string() + self.realname = generate_random_string() + self.encryption = None + self.plugins = [] + self.irc_lock = asyncio.Lock() + + async def connect_to_hub(self): + while True: + try: + logging.info(f"Attempting to connect to hub at {self.hub_config['address']}:{self.hub_config['port']}") + self.hub_reader, self.hub_writer = await asyncio.open_connection(self.hub_config['address'], self.hub_config['port']) + + logging.info("Waiting for encryption key...") + encryption_key = await asyncio.wait_for(self.hub_reader.readexactly(32), timeout=30) + logging.info(f"Received encryption key: {encryption_key.hex()}") + self.encryption = SimpleEncryption(encryption_key) + + logging.info("Waiting for encrypted config...") + encrypted_config = await asyncio.wait_for(self.hub_reader.readline(), timeout=30) + if not encrypted_config: + raise ConnectionError("Failed to receive config from hub") + logging.info(f"Received encrypted config: {encrypted_config}") + decrypted_config = self.encryption.decrypt(encrypted_config.decode().strip()) + logging.info(f"Decrypted config: {decrypted_config}") + self.irc_config = json.loads(decrypted_config) + + logging.info("Waiting for encrypted commands...") + encrypted_commands = await asyncio.wait_for(self.hub_reader.readline(), timeout=30) + if not encrypted_commands: + raise ConnectionError("Failed to receive commands from hub") + logging.info(f"Received encrypted commands: {encrypted_commands}") + decrypted_commands = self.encryption.decrypt(encrypted_commands.decode().strip()) + logging.info(f"Decrypted commands: {decrypted_commands}") + self.commands = json.loads(decrypted_commands) + + self.nickname = self.irc_config.get('nickname', 'LeafBot') + + logging.info(f"Successfully connected to hub at {self.hub_config['address']}:{self.hub_config['port']}") + logging.info(f"Received IRC config: {self.irc_config}") + logging.info(f"Received commands: {self.commands}") + return + except Exception as e: + logging.error(f"Failed to connect to hub: {e}") + await asyncio.sleep(30) # Wait before retry + + async def connect_to_irc(self): + try: + if not self.irc_config: + raise ValueError("IRC Configuration not received from hub") + + ip_type = get_ip_type(self.irc_config['server'], self.irc_config['port']) + ssl_context = ssl_ctx() if self.irc_config['use_ssl'] else None + self.irc_reader, self.irc_writer = await asyncio.open_connection( + host=self.irc_config['server'], + port=self.irc_config['port'], + ssl=ssl_context, + family=ip_type + ) + + if self.irc_config.get('password'): + await self.raw(f"PASS {self.irc_config['password']}") + await self.raw(f"NICK {self.nickname}") + await self.raw(f"USER {self.username} 0 * :{self.realname}") + + while True: + data = await self.irc_reader.readline() + if not data: + raise ConnectionResetError("IRC connection closed") + + try: + message = data.decode('utf-8', errors='ignore').strip() + except UnicodeDecodeError: + logging.warning("Ignored a non-UTF-8 character in IRC message") + continue + + if message: + await self.handle_irc_message(message) + + if "376" in message or "422" in message: # End of MOTD or MOTD is missing + await self.join_channel() + break + + logging.info(f"Connected to IRC server {self.irc_config['server']}:{self.irc_config['port']} and joining {self.irc_config['channel']}") + except Exception as e: + logging.error(f"Error in IRC connection: {e}") + raise + + async def join_channel(self): + if self.irc_config.get('channel_password'): + await self.raw(f"JOIN {self.irc_config['channel']} {self.irc_config['channel_password']}") + else: + await self.raw(f"JOIN {self.irc_config['channel']}") + + async def raw(self, data): + logging.debug(f"Sending to IRC: {data}") + self.irc_writer.write(f"{data}\r\n".encode('utf-8')[:512]) + await self.irc_writer.drain() + + async def handle_irc_message(self, data): + logging.debug(f"Received IRC message: {data}") + parts = data.split() + if parts[0] == 'PING': + await self.raw(f"PONG {parts[1]}") + elif len(parts) > 1: + if parts[1] == '433': # Nick already in use + await self.request_nick() + await self.raw(f"NICK {self.nickname}") + elif parts[1] == 'PRIVMSG': + await self.handle_privmsg(parts) + elif parts[1] == 'KICK': + await self.handle_kick(parts) + + async def request_nick(self): + await self.send_to_hub(json.dumps({ + "type": "command", + "sender": "LeafBot", + "channel": "Hub", + "command": "request_nick", + "args": [] + })) + response = await self.wait_for_hub_response() + if response['type'] == 'action' and response['action'] == 'set_nick': + self.nickname = response['nickname'] + else: + raise ValueError("Unexpected response from hub for nick request") + + async def wait_for_hub_response(self): + while True: + encrypted_message = await self.hub_reader.readline() + if not encrypted_message: + raise ConnectionResetError("Hub connection closed") + message = self.encryption.decrypt(encrypted_message.decode().strip()) + return json.loads(message) + + async def handle_privmsg(self, parts): + sender = parts[0].split('!')[0][1:] + channel = parts[2] + message = ' '.join(parts[3:])[1:] + + for plugin in self.plugins: + await plugin.on_message(sender, channel, message) + + async def handle_kick(self, parts): + if parts[3] == self.nickname: + self.rejoin_attempts = 0 + while self.rejoin_attempts < 3: + await asyncio.sleep(5) + try: + await self.join_channel() + logging.info("Successfully rejoined channel after kick") + return + except Exception as e: + logging.error(f"Failed to rejoin channel: {e}") + self.rejoin_attempts += 1 + + logging.error("Max rejoin attempts reached. Possible ban detected.") + await self.send_to_hub(json.dumps({ + "type": "alert", + "message": f"Possible ban detected on {self.irc_config['channel']}. Unable to rejoin." + })) + + async def send_to_hub(self, message): + encrypted_message = self.encryption.encrypt(message) + self.hub_writer.write(f"{encrypted_message}\r\n".encode()) + await self.hub_writer.drain() + + async def handle_hub_message(self, encrypted_message): + message = self.encryption.decrypt(encrypted_message) + data = json.loads(message) + if data['type'] == 'action': + if data['action'] == 'send_message': + await self.raw(f"PRIVMSG {data['channel']} :{data['message']}") + elif data['action'] == 'join_channel': + await self.raw(f"JOIN {data['channel']}") + elif data['action'] == 'leave_channel': + await self.raw(f"PART {data['channel']}") + elif data['action'] in ['change_nick', 'set_nick']: + old_nickname = self.nickname + self.nickname = data['nickname'] + await self.raw(f"NICK {self.nickname}") + logging.info(f"Nickname changed from {old_nickname} to {self.nickname}") + elif data['action'] == 'update_hub_config': + await self.update_hub_config(data['config']) + elif data['action'] == 'update_irc_config': + await self.update_irc_config(data['config']) + else: + logging.warning(f"Unknown action: {data['action']}") + elif data['type'] == 'error': + logging.error(f"Error from hub: {data['message']}") + else: + logging.warning(f"Unknown message type from hub: {data['type']}") + + async def update_hub_config(self, new_config): + logging.info(f"Updating hub configuration: {new_config}") + old_address = self.hub_config['address'] + old_port = self.hub_config['port'] + self.hub_config = new_config + if self.hub_config['address'] != old_address or self.hub_config['port'] != old_port: + logging.info("Hub address or port changed. Reconnecting...") + if self.hub_writer: + self.hub_writer.close() + await self.hub_writer.wait_closed() + await self.connect_to_hub() + + async def update_irc_config(self, new_config): + logging.info(f"Updating IRC configuration: {new_config}") + old_server = self.irc_config['server'] + old_channel = self.irc_config['channel'] + old_nickname = self.nickname + + self.irc_config = new_config + + if self.irc_config['server'] != old_server: + logging.info("Server changed. Reconnecting...") + if self.irc_writer: + self.irc_writer.close() + await self.irc_writer.wait_closed() + await self.connect_to_irc() + elif self.irc_config['channel'] != old_channel: + logging.info("Channel changed. Joining new channel...") + if old_channel: + await self.raw(f"PART {old_channel}") + await self.join_channel() + + if old_nickname != self.nickname: + await self.send_to_hub(json.dumps({ + "type": "nick_update", + "old_nick": old_nickname, + "new_nick": self.nickname + })) + + async def run_irc(self): + while True: + try: + data = await self.irc_reader.readline() + if not data: + raise ConnectionResetError("IRC connection closed") + try: + message = data.decode('utf-8', errors='ignore').strip() + except UnicodeDecodeError: + logging.warning("Ignored a non-UTF-8 character in IRC message") + continue + if message: + await self.handle_irc_message(message) + except Exception as e: + logging.error(f"Error in IRC connection: {e}") + # Attempt to reconnect + await self.connect_to_irc() + + async def run_hub(self): + while True: + try: + encrypted_message = await self.hub_reader.readline() + if not encrypted_message: + raise ConnectionResetError("Hub connection closed") + message = encrypted_message.decode().strip() + if message: + await self.handle_hub_message(message) + except Exception as e: + logging.error(f"Error in hub connection: {e}") + await asyncio.sleep(30) + + async def run(self): + while True: + try: + # Connect to hub and get configuration + await self.connect_to_hub() + + # Now that we have the config, connect to IRC + await self.connect_to_irc() + + # Run both IRC and hub connections concurrently + await asyncio.gather( + self.run_irc(), + self.run_hub() + ) + except Exception as e: + logging.error(f"Unexpected error: {e}") + await asyncio.sleep(30) + +def setup_logger(debug=False): + level = logging.DEBUG if debug else logging.INFO + logging.basicConfig(level=level, + format='%(asctime)s | %(levelname)8s | %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + +async def main(hub_host, hub_port, debug): + setup_logger(debug) + while True: + try: + leaf_bot = LeafBot(hub_host, hub_port) + await leaf_bot.run() + except Exception as e: + logging.error(f"LeafBot crashed: {e}") + logging.info("Restarting LeafBot in 30 seconds...") + await asyncio.sleep(30) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Leaf Bot for IRC") + parser.add_argument("hub_host", help="Hostname or IP address of the hub bot") + parser.add_argument("hub_port", type=int, help="Port number of the hub bot") + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + args = parser.parse_args() + + try: + asyncio.run(main(args.hub_host, args.hub_port, args.debug)) + except KeyboardInterrupt: + print("Leaf bot stopped by user.") + except Exception as e: + logging.error(f"Fatal error in main loop: {e}") + import traceback + traceback.print_exc()