Files
FunguyBot/funguy.py
T

395 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 nonadmin 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 <plugin>")
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 <plugin>")
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 <plugin>")
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 <plugin>")
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)