inital commit yo
This commit is contained in:
parent
1e136f5c11
commit
fd1fb0f40e
99
README.md
99
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 <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
1
plugins/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from .base_plugin import BasePlugin
|
342
pylon.py
Normal file
342
pylon.py
Normal 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
390
shard.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user