307 lines
11 KiB
Python
307 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""
|
|
Funguy Bot Class
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
import importlib
|
|
import simplematrixbotlib as botlib
|
|
from dotenv import load_dotenv
|
|
import time
|
|
import sys
|
|
import toml
|
|
import socket
|
|
import asyncio
|
|
from collections import defaultdict
|
|
|
|
from plugins.config import FunguyConfig
|
|
|
|
# Rate limiter settings
|
|
RATE_LIMIT_WINDOW = 5.0 # seconds
|
|
MAX_COMMANDS_PER_WINDOW = 5
|
|
|
|
|
|
class FunguyBot:
|
|
"""
|
|
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
|
"""
|
|
|
|
def __init__(self):
|
|
print("[INIT] Starting FunguyBot initialization...")
|
|
|
|
self.PLUGINS_DIR = "plugins"
|
|
self.PLUGINS = {}
|
|
self.config = None
|
|
self.bot = None
|
|
self.disabled_plugins = {}
|
|
|
|
# Rate limiter state: {sender: [(timestamp, room_id), ...]}
|
|
self._rate_limit_buckets = defaultdict(list)
|
|
|
|
load_dotenv() # load once here
|
|
self.setup_logging()
|
|
self.load_plugins()
|
|
self.load_config()
|
|
self.load_disabled_plugins()
|
|
|
|
print("[INIT] FunguyBot initialization complete!")
|
|
|
|
def setup_logging(self):
|
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
level_map = {
|
|
"DEBUG": logging.DEBUG,
|
|
"INFO": logging.INFO,
|
|
"WARNING": logging.WARNING,
|
|
"ERROR": logging.ERROR,
|
|
"CRITICAL": logging.CRITICAL
|
|
}
|
|
level = level_map.get(log_level, logging.INFO)
|
|
|
|
logging.basicConfig(
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
level=level
|
|
)
|
|
logging.getLogger().setLevel(level)
|
|
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
|
logging.getLogger("nio").setLevel(logging.WARNING)
|
|
logging.info(f"Logging configured with level: {log_level}")
|
|
|
|
def load_plugins(self):
|
|
for plugin_file in os.listdir(self.PLUGINS_DIR):
|
|
if plugin_file.endswith(".py") and plugin_file != "__init__.py":
|
|
plugin_name = os.path.splitext(plugin_file)[0]
|
|
try:
|
|
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
|
self.PLUGINS[plugin_name] = module
|
|
logging.info(f"Loaded plugin: {plugin_name}")
|
|
except Exception as e:
|
|
logging.error(f"Error loading plugin {plugin_name}: {e}")
|
|
|
|
def setup_plugins(self):
|
|
"""Call setup(bot) on any plugin that defines it, after self.bot exists."""
|
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
|
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
|
try:
|
|
plugin_module.setup(self.bot)
|
|
logging.info(f"Setup called for plugin: {plugin_name}")
|
|
except Exception as e:
|
|
logging.error(f"Error during setup of plugin {plugin_name}: {e}")
|
|
|
|
def reload_plugins(self):
|
|
self.PLUGINS.clear()
|
|
for plugin_name in list(sys.modules.keys()):
|
|
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
|
del sys.modules[plugin_name]
|
|
self.load_plugins()
|
|
if self.bot is not None:
|
|
self.setup_plugins()
|
|
|
|
def load_config(self):
|
|
self.config = FunguyConfig()
|
|
logging.info("Configuration loaded")
|
|
|
|
def load_disabled_plugins(self):
|
|
if os.path.exists('funguy.conf'):
|
|
with open('funguy.conf', 'r') as f:
|
|
config_data = toml.load(f)
|
|
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
|
|
|
def save_disabled_plugins(self):
|
|
existing_config = {}
|
|
if os.path.exists('funguy.conf'):
|
|
with open('funguy.conf', 'r') as f:
|
|
existing_config = toml.load(f)
|
|
existing_config['plugins'] = {'disabled': self.disabled_plugins}
|
|
with open('funguy.conf', 'w') as f:
|
|
toml.dump(existing_config, f)
|
|
|
|
def _check_rate_limit(self, sender: str) -> bool:
|
|
"""Return True if the sender is allowed to proceed, False if rate limited."""
|
|
now = time.monotonic()
|
|
bucket = self._rate_limit_buckets[sender]
|
|
# Prune old entries
|
|
bucket = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
|
|
self._rate_limit_buckets[sender] = bucket
|
|
if len(bucket) >= MAX_COMMANDS_PER_WINDOW:
|
|
return False
|
|
bucket.append(now)
|
|
return True
|
|
|
|
async def handle_commands(self, room, message):
|
|
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
|
|
|
|
# Rate limit check (applies to all commands)
|
|
sender = str(message.sender)
|
|
if not self._check_rate_limit(sender):
|
|
await self.bot.api.send_text_message(
|
|
room.room_id,
|
|
"⛔ You're sending commands too quickly. Please wait a few seconds."
|
|
)
|
|
return
|
|
|
|
# Admin commands
|
|
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
|
|
if sender == self.config.admin_user:
|
|
self.reload_plugins()
|
|
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully")
|
|
else:
|
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
|
|
return
|
|
|
|
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
|
|
if sender == self.config.admin_user:
|
|
args = match.args()
|
|
if len(args) != 2:
|
|
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
|
|
else:
|
|
plugin_name, room_id = args
|
|
await self.disable_plugin(room_id, plugin_name)
|
|
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'")
|
|
else:
|
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.")
|
|
return
|
|
|
|
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
|
|
if sender == self.config.admin_user:
|
|
args = match.args()
|
|
if len(args) != 2:
|
|
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
|
|
else:
|
|
plugin_name, room_id = args
|
|
await self.enable_plugin(room_id, plugin_name)
|
|
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'")
|
|
else:
|
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.")
|
|
return
|
|
|
|
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
|
|
if sender == self.config.admin_user:
|
|
self.rehash_config()
|
|
await self.bot.api.send_text_message(room.room_id, "Config rehashed")
|
|
else:
|
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
|
|
return
|
|
|
|
# Dispatch to active plugins
|
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
|
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
|
try:
|
|
await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config)
|
|
except Exception as e:
|
|
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
|
|
|
|
def rehash_config(self):
|
|
del self.config
|
|
self.config = FunguyConfig()
|
|
|
|
async def disable_plugin(self, room_id, plugin_name):
|
|
if room_id not in self.disabled_plugins:
|
|
self.disabled_plugins[room_id] = []
|
|
if plugin_name not in self.disabled_plugins[room_id]:
|
|
self.disabled_plugins[room_id].append(plugin_name)
|
|
self.save_disabled_plugins()
|
|
|
|
async def enable_plugin(self, room_id, plugin_name):
|
|
if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]:
|
|
self.disabled_plugins[room_id].remove(plugin_name)
|
|
self.save_disabled_plugins()
|
|
|
|
def test_connectivity(self, hostname, port=443):
|
|
logging.info(f"Testing connectivity to {hostname}:{port}...")
|
|
try:
|
|
ip_address = socket.gethostbyname(hostname)
|
|
logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}")
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(10)
|
|
result = sock.connect_ex((hostname, port))
|
|
sock.close()
|
|
if result == 0:
|
|
logging.info(f"✓ Socket connection successful to {hostname}:{port}")
|
|
return True
|
|
else:
|
|
logging.error(f"✗ Socket connection failed to {hostname}:{port} (error code: {result})")
|
|
return False
|
|
except socket.gaierror as e:
|
|
logging.error(f"✗ DNS resolution failed for {hostname}: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logging.error(f"✗ Connectivity test failed: {e}")
|
|
return False
|
|
|
|
def run(self):
|
|
print("\n" + "="*60)
|
|
print("FUNGUY BOT - STARTING")
|
|
print("="*60 + "\n")
|
|
|
|
MATRIX_URL = os.getenv("MATRIX_URL")
|
|
MATRIX_USER = os.getenv("MATRIX_USER")
|
|
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
|
|
|
if not MATRIX_URL or not MATRIX_USER or not MATRIX_PASS:
|
|
logging.error("Missing MATRIX_URL / MATRIX_USER / MATRIX_PASS in .env")
|
|
return
|
|
|
|
logging.info(f"Matrix URL: {MATRIX_URL}")
|
|
logging.info(f"Matrix User: {MATRIX_USER}")
|
|
|
|
hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0]
|
|
|
|
logging.info("="*40)
|
|
logging.info("RUNNING NETWORK DIAGNOSTICS")
|
|
logging.info("="*40)
|
|
|
|
if not self.test_connectivity(hostname, 443):
|
|
logging.error("Connectivity test failed. See above messages.")
|
|
return
|
|
|
|
logging.info("="*40)
|
|
logging.info("ATTEMPTING MATRIX CONNECTION")
|
|
logging.info("="*40)
|
|
|
|
try:
|
|
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS)
|
|
self.bot = botlib.Bot(creds, self.config)
|
|
|
|
self.setup_plugins()
|
|
self.bot.plugins = self.PLUGINS
|
|
|
|
@self.bot.listener.on_message_event
|
|
async def wrapper_handle_commands(room, message):
|
|
await self.handle_commands(room, message)
|
|
|
|
self.bot.run()
|
|
except Exception as e:
|
|
logging.error(f"Fatal error during bot startup: {e}", exc_info=True)
|
|
raise
|
|
|
|
def stop(self):
|
|
"""Cleanup resources before shutdown."""
|
|
if hasattr(self, 'bot') and self.bot is not None:
|
|
# try to stop any schedulers if needed
|
|
pass
|
|
logging.info("Bot stopped.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("\n" + "="*60)
|
|
print("FUNGUY BOT LAUNCHER")
|
|
print("="*60)
|
|
|
|
bot = None
|
|
try:
|
|
bot = FunguyBot()
|
|
bot.run()
|
|
except KeyboardInterrupt:
|
|
print("\n[!] Bot stopped by user")
|
|
if bot:
|
|
bot.stop()
|
|
sys.exit(0)
|
|
except Exception as e:
|
|
print(f"\n[!] Fatal error: {e}")
|
|
logging.error(f"Unhandled exception: {e}", exc_info=True)
|
|
if bot:
|
|
bot.stop()
|
|
sys.exit(1)
|