Files
FunguyBot/funguy.py
T
hash c72ea72bae Add academic paper search, news aggregator, and Hacker News plugins with collapsible output
- Added arxiv.py plugin for searching academic papers on arXiv.org
- Added news.py plugin for fetching news from GNews API
- Added hackernews.py plugin for fetching stories from Hacker News
- All plugins now output results in collapsible <details> tags for better UX
- Enhanced funguy.py with improved error handling, logging, and plugin management
- Updated help.py and README.md with documentation for new plugins
- Added !plugins command to list loaded plugins
- Improved configuration loading and plugin disable/enable functionality
2026-05-04 04:36:35 -05:00

387 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Funguy Bot Class - A modular Matrix bot with plugin support
"""
# Importing necessary libraries and modules
import os # Operating System functions
import logging # Logging library for logging messages
import importlib # Library for dynamically importing modules
import simplematrixbotlib as botlib # Library for interacting with Matrix chat
from dotenv import load_dotenv # Library for loading environment variables from a .env file
import time # Time-related functions
import sys # System-specific parameters and functions
import toml # Library for parsing TOML configuration files
# Importing FunguyConfig class from plugins.config module
from plugins.config import FunguyConfig
class FunguyBot:
"""
A bot class for managing plugins and handling commands in a Matrix chat environment.
Methods:
- __init__: Constructor method for initializing the bot.
- load_dotenv: Method to load environment variables from a .env file.
- setup_logging: Method to configure logging settings.
- load_plugins: Method to load plugins from the specified directory.
- reload_plugins: Method to reload all plugins.
- load_config: Method to load configuration settings.
- load_disabled_plugins: Method to load disabled plugins from configuration file.
- save_disabled_plugins: Method to save disabled plugins to configuration file.
- handle_commands: Method to handle incoming commands and dispatch them to appropriate plugins.
- rehash_config: Method to rehash the configuration settings.
- disable_plugin: Method to disable a plugin for a specific room.
- enable_plugin: Method to enable a plugin for a specific room.
- run: Method to initialize and run the bot.
Properties:
- PLUGINS_DIR: Directory where plugins are stored
- PLUGINS: Dictionary to store loaded plugins
- config: Configuration object
- bot: Bot object
- disabled_plugins: Dictionary to store disabled plugins for each room
"""
def __init__(self):
"""
Constructor method for FunguyBot class.
"""
# Setting up instance variables
self.PLUGINS_DIR = "plugins" # Directory where plugins are stored
self.PLUGINS = {} # Dictionary to store loaded plugins
self.config = None # Configuration object
self.bot = None # Bot object
self.disabled_plugins = {} # Dictionary to store disabled plugins for each room
self.load_dotenv() # Loading environment variables from .env file
self.setup_logging() # Setting up logging configurations
self.load_plugins() # Loading plugins
self.load_config() # Loading bot configuration
self.load_disabled_plugins() # Loading disabled plugins from configuration file
def load_dotenv(self):
"""
Method to load environment variables from a .env file.
"""
load_dotenv()
def setup_logging(self):
"""
Method to configure logging settings.
"""
# Basic configuration for logging messages to console
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
# Configure logging format
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=getattr(logging, log_level, logging.INFO)
)
# Set specific loggers to appropriate levels
logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO))
logging.getLogger("simplematrixbotlib").setLevel(logging.WARNING)
logging.getLogger("nio").setLevel(logging.WARNING)
logging.info(f"Logging configured with level: {log_level}")
def load_plugins(self):
"""
Method to load plugins from the specified directory.
"""
# Iterating through files in the plugins directory
plugin_count = 0
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] # Extracting plugin name
try:
# Importing plugin module dynamically
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
self.PLUGINS[plugin_name] = module # Storing loaded plugin module
logging.info(f"Loaded plugin: {plugin_name}")
plugin_count += 1
except Exception as e:
logging.error(f"Error loading plugin {plugin_name}: {e}", exc_info=True)
logging.info(f"Total plugins loaded: {plugin_count}")
def setup_plugins(self):
"""
Method to call setup(bot) on any plugin that defines it.
This must be called AFTER self.bot is created (i.e. inside run()), so
that plugins which register custom event listeners (e.g. on_custom_event
for RoomMemberEvent) receive a valid bot instance.
"""
setup_count = 0
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}")
setup_count += 1
except Exception as e:
logging.error(f"Error during setup of plugin {plugin_name}: {e}", exc_info=True)
logging.info(f"Setup completed for {setup_count} plugins")
def reload_plugins(self):
"""
Method to reload all plugins.
"""
logging.info("Reloading plugins...")
# Clear loaded plugins dictionary
self.PLUGINS.clear()
# Unloading modules from sys.modules
modules_to_remove = []
for module_name in sys.modules.keys():
if module_name.startswith(self.PLUGINS_DIR + "."):
modules_to_remove.append(module_name)
for module_name in modules_to_remove:
del sys.modules[module_name]
# Reload plugins
self.load_plugins()
# Re-run setup for any plugin that needs it (bot already exists at this point)
if self.bot is not None:
self.setup_plugins()
logging.info("Plugins reloaded successfully")
def load_config(self):
"""
Method to load configuration settings.
"""
self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration
logging.info("Configuration loaded")
def load_disabled_plugins(self):
"""
Method to load disabled plugins from configuration file.
"""
# Checking if configuration file exists
if os.path.exists('funguy.conf'):
try:
# Loading configuration data from TOML file
with open('funguy.conf', 'r') as f:
config_data = toml.load(f)
# Extracting disabled plugins from configuration data
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
logging.info(f"Loaded disabled plugins configuration for {len(self.disabled_plugins)} rooms")
except Exception as e:
logging.error(f"Error loading disabled plugins configuration: {e}")
self.disabled_plugins = {}
else:
logging.info("No funguy.conf found, starting with empty disabled plugins list")
def save_disabled_plugins(self):
"""
Method to save disabled plugins to configuration file.
"""
existing_config = {}
# Checking if configuration file exists
if os.path.exists('funguy.conf'):
try:
# Loading existing configuration data
with open('funguy.conf', 'r') as f:
existing_config = toml.load(f)
except Exception as e:
logging.error(f"Error reading funguy.conf: {e}")
# Updating configuration data with disabled plugins
if 'plugins' not in existing_config:
existing_config['plugins'] = {}
existing_config['plugins']['disabled'] = self.disabled_plugins
# Writing updated configuration data back to file
try:
with open('funguy.conf', 'w') as f:
toml.dump(existing_config, f)
logging.info("Saved disabled plugins configuration")
except Exception as e:
logging.error(f"Error saving disabled plugins configuration: {e}")
async def handle_commands(self, room, message):
"""
Method to handle incoming commands and dispatch them to appropriate plugins.
"""
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) # Matching message against bot's prefix
# Reloading plugins command
if match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
self.reload_plugins() # Reloading 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
# Disable plugin command
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
args = match.args() # Getting command arguments
if len(args) != 2: # Checking if correct number of arguments provided
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
else:
plugin_name, room_id = args # Extracting plugin name and room ID
await self.disable_plugin(room_id, plugin_name) # Disabling plugin
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
# Enable plugin command
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
args = match.args() # Getting command arguments
if len(args) != 2: # Checking if correct number of arguments provided
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
else:
plugin_name, room_id = args # Extracting plugin name and room ID
await self.enable_plugin(room_id, plugin_name) # Enabling plugin
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
# Rehash config command
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
self.rehash_config() # Rehashing configuration
await self.bot.api.send_text_message(room.room_id, "✅ Configuration rehashed successfully")
else:
await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to rehash configuration.")
return
# List plugins command
if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"):
if self.PLUGINS:
plugin_list = "\n".join([f"{name}" for name in sorted(self.PLUGINS.keys())])
await self.bot.api.send_markdown_message(
room.room_id,
f"**📦 Loaded Plugins ({len(self.PLUGINS)})**\n\n{plugin_list}"
)
else:
await self.bot.api.send_text_message(room.room_id, "No plugins are currently loaded.")
return
# Dispatching commands to plugins (only if plugin is not disabled for this room)
for plugin_name, plugin_module in self.PLUGINS.items():
# Check if plugin is disabled for this room
if plugin_name in self.disabled_plugins.get(room.room_id, []):
continue
# Check if plugin has handle_command function
if hasattr(plugin_module, "handle_command") and callable(plugin_module.handle_command):
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)
# Don't break the loop, continue with other plugins
def rehash_config(self):
"""
Method to rehash the configuration settings.
"""
logging.info("Rehashing configuration...")
try:
# Reload environment variables
load_dotenv()
# Reload configuration
del self.config # Deleting current configuration object
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration
# Reload disabled plugins
self.load_disabled_plugins()
logging.info("Configuration rehashed successfully")
except Exception as e:
logging.error(f"Error rehashing configuration: {e}")
async def disable_plugin(self, room_id, plugin_name):
"""
Method to disable a plugin for a specific room.
"""
if plugin_name not in self.PLUGINS:
logging.warning(f"Attempted to disable non-existent plugin: {plugin_name}")
return
if room_id not in self.disabled_plugins:
self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist
if plugin_name not in self.disabled_plugins[room_id]:
self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room
self.save_disabled_plugins() # Saving disabled plugins to configuration file
logging.info(f"Plugin '{plugin_name}' disabled for room {room_id}")
async def enable_plugin(self, room_id, plugin_name):
"""
Method to enable a plugin for a specific room.
"""
if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]:
self.disabled_plugins[room_id].remove(plugin_name) # Removing plugin from list of disabled plugins for the room
# Clean up empty room entries
if not self.disabled_plugins[room_id]:
del self.disabled_plugins[room_id]
self.save_disabled_plugins() # Saving disabled plugins to configuration file
logging.info(f"Plugin '{plugin_name}' enabled for room {room_id}")
def run(self):
"""
Method to initialize and run the bot.
"""
try:
# Retrieving Matrix credentials from environment variables
MATRIX_URL = os.getenv("MATRIX_URL")
MATRIX_USER = os.getenv("MATRIX_USER")
MATRIX_PASS = os.getenv("MATRIX_PASS")
# Validate credentials
if not all([MATRIX_URL, MATRIX_USER, MATRIX_PASS]):
logging.error("Missing Matrix credentials in .env file. Please check MATRIX_URL, MATRIX_USER, MATRIX_PASS")
return
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
self.bot = botlib.Bot(creds, self.config) # Creating bot instance
logging.info(f"Bot starting with user: {MATRIX_USER}")
logging.info(f"Connected to homeserver: {MATRIX_URL}")
# Call setup() on any plugin that defines it, now that self.bot exists.
# This is what registers custom event listeners such as the join-event
# listener in the welcome plugin.
self.setup_plugins()
# Defining listener for message events
@self.bot.listener.on_message_event
async def wrapper_handle_commands(room, message):
await self.handle_commands(room, message) # Calling handle_commands method for incoming messages
logging.info("Bot is ready and listening for commands...")
self.bot.run() # Running the bot
except Exception as e:
logging.error(f"Fatal error running bot: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
try:
bot = FunguyBot() # Creating instance of FunguyBot
bot.run() # Running the bot
except KeyboardInterrupt:
logging.info("Bot stopped by user")
sys.exit(0)
except Exception as e:
logging.error(f"Unhandled exception: {e}", exc_info=True)
sys.exit(1)