#!/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 = 3 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. Admin is always allowed.""" # Admin bypass if sender == self.config.admin_user: return True 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: logging.debug("Rate limit hit for %s", sender) return False bucket.append(now) return True # ------------------------------------------------------------------ # New: load/unload a single plugin at runtime # ------------------------------------------------------------------ async def load_plugin(self, plugin_name: str) -> bool: """Dynamically load a plugin module, add to PLUGINS, and call its setup().""" if plugin_name in self.PLUGINS: logging.info(f"Plugin '{plugin_name}' is already loaded.") return False try: module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}") self.PLUGINS[plugin_name] = module logging.info(f"Loaded plugin: {plugin_name}") # Call setup if the bot is already running if self.bot is not None and hasattr(module, "setup") and callable(module.setup): module.setup(self.bot) logging.info(f"Setup called for newly loaded plugin: {plugin_name}") return True except Exception as e: logging.error(f"Error loading plugin {plugin_name}: {e}") return False async def unload_plugin(self, plugin_name: str) -> bool: """Remove a plugin from PLUGINS and unload its module.""" if plugin_name not in self.PLUGINS: logging.info(f"Plugin '{plugin_name}' is not loaded.") return False try: del self.PLUGINS[plugin_name] module_path = f"{self.PLUGINS_DIR}.{plugin_name}" if module_path in sys.modules: del sys.modules[module_path] logging.info(f"Unloaded plugin: {plugin_name}") return True except Exception as e: logging.error(f"Error unloading plugin {plugin_name}: {e}") return False # ------------------------------------------------------------------ # New: restart the bot process # ------------------------------------------------------------------ async def restart_bot(self, room_id): await self.bot.api.send_text_message(room_id, "πŸ”„ Restarting bot...") await asyncio.sleep(1) logging.info("Restart command received – exiting.") sys.exit(0) async def handle_commands(self, room, message): match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) sender = str(message.sender) is_admin = (sender == self.config.admin_user) # Rate limit check (applies to all non‑admin commands) 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 is_admin: self.reload_plugins() await self.bot.api.send_text_message(room.room_id, "All 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("load"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "β›” Admin only.") return args = match.args() if len(args) != 1: await self.bot.api.send_text_message(room.room_id, "Usage: !load ") return success = await self.load_plugin(args[0]) msg = f"βœ… Plugin '{args[0]}' loaded." if success else f"❌ Could not load '{args[0]}'. See logs for details." await self.bot.api.send_text_message(room.room_id, msg) return if match.is_not_from_this_bot() and match.prefix() and match.command("unload"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "β›” Admin only.") return args = match.args() if len(args) != 1: await self.bot.api.send_text_message(room.room_id, "Usage: !unload ") return success = await self.unload_plugin(args[0]) msg = f"βœ… Plugin '{args[0]}' unloaded." if success else f"❌ Could not unload '{args[0]}'. See logs for details." await self.bot.api.send_text_message(room.room_id, msg) return if match.is_not_from_this_bot() and match.prefix() and match.command("disable"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "β›” Admin only.") return args = match.args() if len(args) != 1: await self.bot.api.send_text_message(room.room_id, "Usage: !disable ") return plugin_name = args[0] room_id = room.room_id await self.disable_plugin(room_id, plugin_name) await self.bot.api.send_text_message(room.room_id, f"🚫 Plugin '{plugin_name}' disabled in this room.") return if match.is_not_from_this_bot() and match.prefix() and match.command("enable"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "β›” Admin only.") return args = match.args() if len(args) != 1: await self.bot.api.send_text_message(room.room_id, "Usage: !enable ") return plugin_name = args[0] room_id = room.room_id await self.enable_plugin(room_id, plugin_name) await self.bot.api.send_text_message(room.room_id, f"βœ… Plugin '{plugin_name}' enabled in this room.") return if match.is_not_from_this_bot() and match.prefix() and match.command("restart"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "β›” Admin only.") return await self.restart_bot(room.room_id) return if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"): if not is_admin: await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload config.") return self.load_config() self.load_disabled_plugins() await self.bot.api.send_text_message(room.room_id, "πŸ”„ Configuration rehashed.") 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) 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)