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
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Funguy Bot Class
|
||||
Funguy Bot Class - A modular Matrix bot with plugin support
|
||||
"""
|
||||
|
||||
# Importing necessary libraries and modules
|
||||
@@ -17,6 +17,7 @@ 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.
|
||||
@@ -71,24 +72,40 @@ class FunguyBot:
|
||||
Method to configure logging settings.
|
||||
"""
|
||||
# Basic configuration for logging messages to console
|
||||
logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=logging.INFO)
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
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"): # Checking if file is a Python file
|
||||
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}") # Logging successful plugin loading
|
||||
logging.info(f"Loaded plugin: {plugin_name}")
|
||||
plugin_count += 1
|
||||
except Exception as e:
|
||||
logging.error(f"Error loading plugin {plugin_name}: {e}") # Logging error if plugin loading fails
|
||||
logging.error(f"Error loading plugin {plugin_name}: {e}", exc_info=True)
|
||||
|
||||
logging.info(f"Total plugins loaded: {plugin_count}")
|
||||
|
||||
def setup_plugins(self):
|
||||
"""
|
||||
@@ -98,33 +115,51 @@ class FunguyBot:
|
||||
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}")
|
||||
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.
|
||||
"""
|
||||
self.PLUGINS = {} # Clearing loaded plugins dictionary
|
||||
logging.info("Reloading plugins...")
|
||||
|
||||
# Clear loaded plugins dictionary
|
||||
self.PLUGINS.clear()
|
||||
|
||||
# Unloading modules from sys.modules
|
||||
for plugin_name in list(sys.modules.keys()):
|
||||
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
||||
del sys.modules[plugin_name] # Deleting plugin module from system modules
|
||||
self.load_plugins() # Reloading plugins
|
||||
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):
|
||||
"""
|
||||
@@ -132,11 +167,18 @@ class FunguyBot:
|
||||
"""
|
||||
# Checking if configuration file exists
|
||||
if os.path.exists('funguy.conf'):
|
||||
# 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', {})
|
||||
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):
|
||||
"""
|
||||
@@ -145,83 +187,139 @@ class FunguyBot:
|
||||
existing_config = {}
|
||||
# Checking if configuration file exists
|
||||
if os.path.exists('funguy.conf'):
|
||||
# Loading existing configuration data
|
||||
with open('funguy.conf', 'r') as f:
|
||||
existing_config = toml.load(f)
|
||||
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
|
||||
existing_config['plugins'] = {'disabled': self.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
|
||||
with open('funguy.conf', 'w') as f:
|
||||
toml.dump(existing_config, f)
|
||||
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") # Sending success message
|
||||
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.") # Sending unauthorized message
|
||||
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>") # Sending usage message
|
||||
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}'") # Sending success message
|
||||
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.") # Sending unauthorized message
|
||||
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>") # Sending usage message
|
||||
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}'") # Sending success message
|
||||
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.") # Sending unauthorized message
|
||||
|
||||
# Dispatching commands to plugins
|
||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
||||
await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config)
|
||||
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, "Config rehashed") # Sending success message
|
||||
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 reload plugins.") # Sending unauthorized message
|
||||
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.
|
||||
"""
|
||||
del self.config # Deleting current configuration object
|
||||
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration
|
||||
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):
|
||||
"""
|
||||
@@ -229,31 +327,60 @@ class FunguyBot:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
# Retrieving Matrix credentials from environment variables
|
||||
MATRIX_URL = os.getenv("MATRIX_URL")
|
||||
MATRIX_USER = os.getenv("MATRIX_USER")
|
||||
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
||||
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
|
||||
self.bot = botlib.Bot(creds, self.config) # Creating bot instance
|
||||
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")
|
||||
|
||||
# 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()
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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)
|
||||
|
||||
self.bot.run() # Running the bot
|
||||
|
||||
if __name__ == "__main__":
|
||||
bot = FunguyBot() # Creating instance of FunguyBot
|
||||
bot.run() # Running the bot
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user