Files
FunguyBot/funguy.py
T

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)