inital commit yo

This commit is contained in:
strangeprogram 2024-07-15 20:58:54 -06:00
parent 1e136f5c11
commit fd1fb0f40e
4 changed files with 831 additions and 1 deletions

View File

@ -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 <channel>`: Join a new channel
- `leave <channel>`: Leave a channel
- `nick`: Change nickname (generates a new random Pokémon-based nickname)
- `UPDATECONF <address> <port>`: Update hub configuration
- `UPDATECONF.IRC <server> <port> <channel> [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

1
plugins/__init__.py Normal file
View File

@ -0,0 +1 @@
from .base_plugin import BasePlugin

342
pylon.py Normal file
View File

@ -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()

390
shard.py Normal file
View File

@ -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()