diff --git a/funguy.py b/funguy.py index ea9ec2e..095b005 100644 --- a/funguy.py +++ b/funguy.py @@ -4,70 +4,52 @@ Funguy Bot Class """ -# 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 -import socket # For network diagnostics +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 -# Importing FunguyConfig class from plugins.config module 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): - """ - Constructor method for FunguyBot class. - """ print("[INIT] Starting FunguyBot initialization...") - # 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.PLUGINS_DIR = "plugins" + self.PLUGINS = {} + self.config = None + self.bot = None + self.disabled_plugins = {} - print("[INIT] Loading environment variables...") - self.load_dotenv() # Loading environment variables from .env file + # Rate limiter state: {sender: [(timestamp, room_id), ...]} + self._rate_limit_buckets = defaultdict(list) - print("[INIT] Setting up logging...") - self.setup_logging() # Setting up logging configurations - - print("[INIT] Loading plugins...") - self.load_plugins() # Loading plugins - - print("[INIT] Loading config...") - self.load_config() # Loading bot configuration - - print("[INIT] Loading disabled plugins...") - self.load_disabled_plugins() # Loading disabled plugins from configuration file + load_dotenv() # load once here + self.setup_logging() + self.load_plugins() + self.load_config() + self.load_disabled_plugins() print("[INIT] FunguyBot initialization complete!") - def load_dotenv(self): - """ - Method to load environment variables from a .env file. - """ - load_dotenv() - print("[ENV] Environment variables loaded") - def setup_logging(self): - """ - Method to configure logging settings. - """ - # Get log level from environment, default to INFO log_level = os.getenv("LOG_LEVEL", "INFO").upper() - - # Convert string to logging constant level_map = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, @@ -82,37 +64,23 @@ class FunguyBot: level=level ) logging.getLogger().setLevel(level) - - # Optionally silence noisy libraries 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): - """ - Method to load plugins from the specified directory. - """ - # Iterating through files in the plugins directory for plugin_file in os.listdir(self.PLUGINS_DIR): - if plugin_file.endswith(".py"): # Checking if file is a Python file - plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name + if plugin_file.endswith(".py") and plugin_file != "__init__.py": + plugin_name = os.path.splitext(plugin_file)[0] 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 + 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}") # Logging error if plugin loading fails + logging.error(f"Error loading plugin {plugin_name}: {e}") 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. - """ + """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: @@ -122,107 +90,101 @@ class FunguyBot: logging.error(f"Error during setup of plugin {plugin_name}: {e}") def reload_plugins(self): - """ - Method to reload all plugins. - """ - self.PLUGINS = {} # Clearing loaded plugins dictionary - # Unloading modules from sys.modules + self.PLUGINS.clear() 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 - # Re-run setup for any plugin that needs it (bot already exists at this point) + del sys.modules[plugin_name] + self.load_plugins() if self.bot is not None: self.setup_plugins() def load_config(self): - """ - Method to load configuration settings. - """ - self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration + self.config = FunguyConfig() 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'): - # 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', {}) 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'): - # Loading existing configuration data with open('funguy.conf', 'r') as f: existing_config = toml.load(f) - # Updating configuration data with disabled 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) + 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): - """ - 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 + match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) - # Reloading plugins command + # 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 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 + 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.") # 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 ") # Sending usage message + 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 ") 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 + 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.") # 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 ") # Sending usage message + 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 ") 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 + 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.") # Sending unauthorized message + 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 + 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.") # Sending unauthorized message + await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") return - # Dispatching commands to plugins + # 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: @@ -231,46 +193,30 @@ class FunguyBot: logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True) 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 + del self.config + self.config = FunguyConfig() async def disable_plugin(self, room_id, plugin_name): - """ - Method to disable a plugin for a specific room. - """ if room_id not in self.disabled_plugins: - self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist + self.disabled_plugins[room_id] = [] 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 + self.disabled_plugins[room_id].append(plugin_name) + self.save_disabled_plugins() 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 - self.save_disabled_plugins() # Saving disabled plugins to configuration file + self.disabled_plugins[room_id].remove(plugin_name) + self.save_disabled_plugins() def test_connectivity(self, hostname, port=443): - """ - Test network connectivity to Matrix server. - """ logging.info(f"Testing connectivity to {hostname}:{port}...") try: - # Test DNS resolution ip_address = socket.gethostbyname(hostname) logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}") - - # Test socket connection 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 @@ -285,46 +231,29 @@ class FunguyBot: return False def run(self): - """ - Method to initialize and run the bot. - """ print("\n" + "="*60) print("FUNGUY BOT - STARTING") print("="*60 + "\n") - # 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 MATRIX_URL: - logging.error("MATRIX_URL not set in .env file") - return - if not MATRIX_USER: - logging.error("MATRIX_USER not set in .env file") - return - if not MATRIX_PASS: - logging.error("MATRIX_PASS not set in .env file") + 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}") - # Extract hostname from URL for connectivity test hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0] - # Test connectivity before attempting to connect logging.info("="*40) logging.info("RUNNING NETWORK DIAGNOSTICS") logging.info("="*40) if not self.test_connectivity(hostname, 443): - logging.error("Connectivity test failed. Please check:") - logging.error(" 1. Your internet connection") - logging.error(" 2. Firewall settings (outbound port 443)") - logging.error(" 3. DNS resolution") - logging.error(f" 4. If {hostname} is accessible") + logging.error("Connectivity test failed. See above messages.") return logging.info("="*40) @@ -332,69 +261,46 @@ class FunguyBot: logging.info("="*40) try: - logging.info(f"Creating credentials object for {MATRIX_USER}...") - creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object - logging.info("✓ Credentials object created") + creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) + self.bot = botlib.Bot(creds, self.config) - logging.info("Creating bot instance...") - self.bot = botlib.Bot(creds, self.config) # Creating bot instance - logging.info("✓ Bot instance created") - - # Check if async_client is available - if hasattr(self.bot, 'async_client'): - logging.info("✓ Async client available") - else: - logging.warning("⚠ Async client not yet available (will be created on login)") - - logging.info("Calling setup_plugins()...") - # Call setup() on any plugin that defines it, now that self.bot exists. self.setup_plugins() - logging.info("✓ Plugin setup complete") - - # ----- NEW: Expose plugins dictionary on bot object ----- self.bot.plugins = self.PLUGINS - logging.info("✓ Plugin dictionary exposed on bot.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("="*40) - logging.info("BOT IS READY - ATTEMPTING TO CONNECT TO MATRIX") - logging.info("="*40) - logging.info(f"Connecting to {MATRIX_URL} as {MATRIX_USER}...") - logging.info("(This may take up to 30 seconds...)") - - self.bot.run() # Running the bot + 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) - logging.error("="*40) - logging.error("TROUBLESHOOTING SUGGESTIONS:") - logging.error("1. Check your internet connection") - logging.error("2. Verify MATRIX_URL is correct (should be https://matrix.org)") - logging.error("3. Verify MATRIX_USER and MATRIX_PASS are correct") - logging.error("4. Check if matrix.org is accessible from your network") - logging.error("5. Try increasing timeout in config") - logging.error("="*40) 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: - print("Creating bot instance...") - bot = FunguyBot() # Creating instance of FunguyBot - print("Bot instance created. Running bot...") - bot.run() # Running the bot + 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) diff --git a/plugins/bitcoin.py b/plugins/bitcoin.py index 48418e3..1445d56 100644 --- a/plugins/bitcoin.py +++ b/plugins/bitcoin.py @@ -1,119 +1,58 @@ """ This plugin provides a command to fetch the current Bitcoin price. """ - import logging -import requests +import aiohttp import simplematrixbotlib as botlib +from plugins.common import html_escape BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/" - async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !btc command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("btc"): logging.info("Received !btc command") - try: - # Fetch Bitcoin price data headers = { 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'FunguyBot/1.0' } - - logging.info(f"Fetching Bitcoin price from {BITCOIN_API_URL}") - response = requests.get(BITCOIN_API_URL, headers=headers, timeout=10) - response.raise_for_status() - - data = response.json() + async with aiohttp.ClientSession() as session: + async with session.get(BITCOIN_API_URL, headers=headers, timeout=10) as response: + response.raise_for_status() + data = await response.json() if not data or len(data) == 0: - await bot.api.send_text_message( - room.room_id, - "No Bitcoin price data available." - ) - logging.warning("No Bitcoin price data returned from API") + await bot.api.send_text_message(room.room_id, "No Bitcoin price data available.") return - # Get the most recent trade (last item in the array) latest_trade = data[-1] price = latest_trade.get('price') - if price is None: - await bot.api.send_text_message( - room.room_id, - "Could not extract Bitcoin price from API response." - ) - logging.error("Price field not found in API response") + await bot.api.send_text_message(room.room_id, "Could not extract Bitcoin price from API response.") return - # Convert to float and format with commas try: price_float = float(price) price_formatted = f"${price_float:,.2f}" except (ValueError, TypeError): price_formatted = f"${price}" - # Optional: Get additional info if available - timestamp = latest_trade.get('timestamp', '') - volume = latest_trade.get('volume', '') - - # Build the message message_text = f"₿ BTC/USD" message_text += f" Current Price: {price_formatted}" - message_text += ", bitcointicker.co" await bot.api.send_markdown_message(room.room_id, message_text) logging.info(f"Sent Bitcoin price: {price_formatted}") - except requests.exceptions.Timeout: - await bot.api.send_text_message( - room.room_id, - "Request timed out. Bitcoin price API may be slow or unavailable." - ) - logging.error("Bitcoin API timeout") - - except requests.exceptions.RequestException as e: - await bot.api.send_text_message( - room.room_id, - f"Error fetching Bitcoin price: {e}" - ) + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error fetching Bitcoin price: {e}") logging.error(f"Error fetching Bitcoin price: {e}") - - except (KeyError, IndexError, ValueError) as e: - await bot.api.send_text_message( - room.room_id, - "Error parsing Bitcoin price data." - ) - logging.error(f"Error parsing Bitcoin API response: {e}", exc_info=True) - except Exception as e: - await bot.api.send_text_message( - room.room_id, - "An unexpected error occurred while fetching Bitcoin price." - ) + await bot.api.send_text_message(room.room_id, "An unexpected error occurred.") logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True) - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "Current Bitcoin price" __help__ = """ diff --git a/plugins/common.py b/plugins/common.py index 797ccc2..0ff80ac 100644 --- a/plugins/common.py +++ b/plugins/common.py @@ -55,3 +55,28 @@ def is_public_destination(target: str) -> bool: except Exception as e: logger.warning(f"Cannot resolve {target}: {e}") return False + +async def handle_command(room, message, bot, prefix, config): + """No-op handler so the bot doesn't crash when loading this module as a plugin.""" + pass + +async def send_html_message(bot, room_id, html_body, markdown_fallback): + """Send an HTML-formatted message with a Markdown fallback. + + Args: + bot: simplematrixbotlib.Bot instance + room_id: Matrix room ID + html_body: HTML string (table, etc.) + markdown_fallback: Markdown/plain text for clients that don't render HTML + """ + content = { + "msgtype": "m.text", + "body": markdown_fallback, + "format": "org.matrix.custom.html", + "formatted_body": html_body + } + await bot.async_client.room_send( + room_id=room_id, + message_type="m.room.message", + content=content + ) diff --git a/plugins/ddg.py b/plugins/ddg.py index 09f8d5a..3e14bd1 100644 --- a/plugins/ddg.py +++ b/plugins/ddg.py @@ -9,19 +9,15 @@ from html import escape import simplematrixbotlib as botlib from ddgs import DDGS +from plugins.common import html_escape, collapsible_summary logger = logging.getLogger("ddg") -# --------------------------------------------------------------------------- -# Async search wrapper -# --------------------------------------------------------------------------- +# Async search wrapper (ddgs is sync, run in executor) async def _async_search(func, *args, **kwargs): loop = asyncio.get_running_loop() return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) -# --------------------------------------------------------------------------- -# Command handler -# --------------------------------------------------------------------------- async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")): @@ -34,93 +30,67 @@ async def handle_command(room, message, bot, prefix, config): subcommand = args[0].lower() - # ---- Instant answer (default) ---- if subcommand in ("instant", "i"): query = " ".join(args[1:]) if len(args) > 1 else "" if not query: await bot.api.send_text_message(room.room_id, "Usage: !ddg instant ") return await instant_answer(room, bot, query) - - # ---- Web search ---- elif subcommand == "search": query = " ".join(args[1:]) if len(args) > 1 else "" if not query: await bot.api.send_text_message(room.room_id, "Usage: !ddg search ") return await web_search(room, bot, query) - - # ---- Image search ---- elif subcommand == "image": query = " ".join(args[1:]) if len(args) > 1 else "" if not query: await bot.api.send_text_message(room.room_id, "Usage: !ddg image ") return await image_search(room, bot, query) - - # ---- News search ---- elif subcommand == "news": query = " ".join(args[1:]) if len(args) > 1 else "" if not query: await bot.api.send_text_message(room.room_id, "Usage: !ddg news ") return await news_search(room, bot, query) - - # ---- Video search ---- elif subcommand == "video": query = " ".join(args[1:]) if len(args) > 1 else "" if not query: await bot.api.send_text_message(room.room_id, "Usage: !ddg video ") return await video_search(room, bot, query) - - # ---- Bang search ---- elif subcommand == "bang": bang_query = " ".join(args[1:]) if len(args) > 1 else "" if not bang_query: await bang_help(room, bot) return await bang_search(room, bot, bang_query) - - # ---- Definitions ---- elif subcommand == "define": word = " ".join(args[1:]) if len(args) > 1 else "" if not word: await bot.api.send_text_message(room.room_id, "Usage: !ddg define ") return await definition(room, bot, word) - - # ---- Calculator ---- elif subcommand == "calc": expr = " ".join(args[1:]) if len(args) > 1 else "" if not expr: await bot.api.send_text_message(room.room_id, "Usage: !ddg calc ") return await calculator(room, bot, expr) - - # ---- Weather ---- elif subcommand == "weather": location = " ".join(args[1:]) if len(args) > 1 else "" if not location: location = "current location" await weather(room, bot, location) - - # ---- Help ---- elif subcommand == "help": await send_help(room, bot) - - # ---- Default: treat as instant answer ---- else: query = " ".join(args) await instant_answer(room, bot, query) - -# ============================== -# Result functions (all wrapped in
) -# ============================== - async def instant_answer(room, bot, query): - """Top web result wrapped in a collapsible box.""" + safe_query = html_escape(query) try: with DDGS() as ddgs: results = await _async_search(ddgs.text, query, max_results=1) @@ -128,28 +98,25 @@ async def instant_answer(room, bot, query): logger.error(f"DDG instant answer error: {e}") await bot.api.send_markdown_message( room.room_id, - f"🦆 DuckDuckGo: {escape(query)}

Error fetching results. Try again later." + f"🦆 DuckDuckGo: {safe_query}

Error fetching results." ) return content = "" if results: r = results[0] - title = escape(r.get("title", "Result")) - body = escape(r.get("body", "")) + title = html_escape(r.get("title", "Result")) + body = html_escape(r.get("body", "")) content = f"💡 {title}
{body[:300]}…
Read more" else: - search_url = f"https://duckduckgo.com/?q={escape(query)}" + search_url = f"https://duckduckgo.com/?q={html_escape(query)}" content = f"No results found.
🔍 Search on DuckDuckGo" - msg = f"""
-🦆 DuckDuckGo: {escape(query)} -{content} -
""" + msg = collapsible_summary(f"🦆 DuckDuckGo: {safe_query}", content) await bot.api.send_markdown_message(room.room_id, msg) - async def web_search(room, bot, query): + safe_query = html_escape(query) try: with DDGS() as ddgs: results = await _async_search(ddgs.text, query, max_results=5) @@ -159,23 +126,20 @@ async def web_search(room, bot, query): return if not results: - await bot.api.send_text_message(room.room_id, f"No results for '{query}'.") + await bot.api.send_text_message(room.room_id, f"No results for '{safe_query}'.") return items = "" for r in results: - title = escape(r.get("title", "Result")) - body = escape(r.get("body", "")) + title = html_escape(r.get("title", "Result")) + body = html_escape(r.get("body", "")) items += f"• {title}
{body[:200]}…

" - msg = f"""
-🔍 Search: {escape(query)} -{items} -
""" + msg = collapsible_summary(f"🔍 Search: {safe_query}", items) await bot.api.send_markdown_message(room.room_id, msg) - async def image_search(room, bot, query): + safe_query = html_escape(query) try: with DDGS() as ddgs: results = await _async_search(ddgs.images, query, max_results=3) @@ -185,28 +149,25 @@ async def image_search(room, bot, query): return if not results: - await bot.api.send_text_message(room.room_id, f"No images for '{query}'.") + await bot.api.send_text_message(room.room_id, f"No images for '{safe_query}'.") return items = "" for img in results: - title = escape(img.get("title", "Image")) + title = html_escape(img.get("title", "Image")) items += f"• {title}" if img.get("width") and img.get("height"): items += f" ({img['width']}×{img['height']})" items += "
" - search_url = f"https://duckduckgo.com/?q={escape(query)}&iax=images&ia=images" + search_url = f"https://duckduckgo.com/?q={html_escape(query)}&iax=images&ia=images" items += f"
🔍 View all images" - msg = f"""
-🖼️ Images: {escape(query)} -{items} -
""" + msg = collapsible_summary(f"🖼️ Images: {safe_query}", items) await bot.api.send_markdown_message(room.room_id, msg) - async def news_search(room, bot, query): + safe_query = html_escape(query) try: with DDGS() as ddgs: results = await _async_search(ddgs.news, query, max_results=3) @@ -216,23 +177,20 @@ async def news_search(room, bot, query): return if not results: - await bot.api.send_text_message(room.room_id, f"No news for '{query}'.") + await bot.api.send_text_message(room.room_id, f"No news for '{safe_query}'.") return items = "" for n in results: - title = escape(n.get("title", "Article")) - body = escape(n.get("body", "")) + title = html_escape(n.get("title", "Article")) + body = html_escape(n.get("body", "")) items += f"• {title}
{body[:200]}…

" - msg = f"""
-📰 News: {escape(query)} -{items} -
""" + msg = collapsible_summary(f"📰 News: {safe_query}", items) await bot.api.send_markdown_message(room.room_id, msg) - async def video_search(room, bot, query): + safe_query = html_escape(query) try: with DDGS() as ddgs: results = await _async_search(ddgs.videos, query, max_results=3) @@ -242,49 +200,36 @@ async def video_search(room, bot, query): return if not results: - await bot.api.send_text_message(room.room_id, f"No videos for '{query}'.") + await bot.api.send_text_message(room.room_id, f"No videos for '{safe_query}'.") return items = "" for v in results: - title = escape(v.get("title", "Video")) + title = html_escape(v.get("title", "Video")) items += f"• {title}
" - search_url = f"https://duckduckgo.com/?q={escape(query)}&iar=videos" + search_url = f"https://duckduckgo.com/?q={html_escape(query)}&iar=videos" items += f"
🔍 View all videos" - msg = f"""
-🎬 Videos: {escape(query)} -{items} -
""" + msg = collapsible_summary(f"🎬 Videos: {safe_query}", items) await bot.api.send_markdown_message(room.room_id, msg) - async def bang_search(room, bot, bang_query): - search_url = f"https://duckduckgo.com/?q={escape(bang_query)}" - content = f"🔗 Search with {escape(bang_query)} on DuckDuckGo" - msg = f"""
-🎯 Bang: {escape(bang_query)} -{content} -
""" + safe_query = html_escape(bang_query) + search_url = f"https://duckduckgo.com/?q={html_escape(bang_query)}" + content = f"🔗 Search with {safe_query} on DuckDuckGo" + msg = collapsible_summary(f"🎯 Bang: {safe_query}", content) await bot.api.send_markdown_message(room.room_id, msg) - async def definition(room, bot, word): await instant_answer(room, bot, f"define {word}") - async def calculator(room, bot, expr): await instant_answer(room, bot, expr) - async def weather(room, bot, location): await instant_answer(room, bot, f"weather {location}") - -# --------------------------------------------------------------------------- -# Help messages (no details wrapper – kept readable) -# --------------------------------------------------------------------------- async def bang_help(room, bot): msg = """ 🎯 DuckDuckGo Bangs
@@ -302,7 +247,6 @@ Usage: !ddg bang !bang query

""" await bot.api.send_markdown_message(room.room_id, msg) - async def send_help(room, bot): help_msg = """ 🦆 DuckDuckGo Commands
@@ -319,11 +263,7 @@ async def send_help(room, bot): """ await bot.api.send_markdown_message(room.room_id, help_msg) - -# --------------------------------------------------------------------------- -# Plugin metadata -# --------------------------------------------------------------------------- -__version__ = "2.1.0" +__version__ = "2.1.1" __author__ = "Funguy Bot" __description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)" __help__ = """ diff --git a/plugins/dnsdumpster.py b/plugins/dnsdumpster.py index 97cc951..071342e 100644 --- a/plugins/dnsdumpster.py +++ b/plugins/dnsdumpster.py @@ -1,289 +1,139 @@ """ This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping. """ - import logging import os -import requests +import aiohttp import simplematrixbotlib as botlib -from dotenv import load_dotenv - -# Load environment variables from .env file -plugin_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(plugin_dir) -dotenv_path = os.path.join(parent_dir, '.env') -load_dotenv(dotenv_path) +from plugins.common import html_escape, collapsible_summary DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "") DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com" async def handle_command(room, message, bot, prefix, config): - """ - Function to handle DNSDumpster commands. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"): logging.info("Received !dnsdumpster command") - # Check if API key is configured if not DNSDUMPSTER_API_KEY: await bot.api.send_text_message( room.room_id, - "DNSDumpster API key not configured. Please set DNSDUMPSTER_KEY environment variable." + "DNSDumpster API key not configured. Set DNSDUMPSTER_KEY in .env." ) - logging.error("DNSDumpster API key not configured") return args = match.args() - if len(args) < 1: await show_usage(room, bot) return - # Check if it's a test command or domain lookup if args[0].lower() == "test": await test_dnsdumpster_connection(room, bot) else: - # Treat the first argument as the domain domain = args[0].lower().strip() await dnsdumpster_domain_lookup(room, bot, domain) async def show_usage(room, bot): - """Display DNSDumpster command usage.""" - usage = """ -🔍 DNSDumpster Commands: - + usage = """🔍 DNSDumpster Commands: !dnsdumpster <domain_name> - Get comprehensive DNS reconnaissance for a domain !dnsdumpster test - Test API connection - Examples:!dnsdumpster google.com!dnsdumpster github.com -• !dnsdumpster example.com - -Rate Limit: 1 request per 2 seconds """ await bot.api.send_markdown_message(room.room_id, usage) async def test_dnsdumpster_connection(room, bot): - """Test DNSDumpster API connection.""" + test_domain = "google.com" try: - test_domain = "google.com" # Changed from example.com to google.com url = f"{DNSDUMPSTER_API_BASE}/domain/{test_domain}" - headers = { - "X-API-Key": DNSDUMPSTER_API_KEY - } - - logging.info(f"Testing DNSDumpster API with domain: {test_domain}") - response = requests.get(url, headers=headers, timeout=15) - - debug_info = f"🔧 DNSDumpster API Test
" - debug_info += f"Status Code: {response.status_code}
" - debug_info += f"Test Domain: {test_domain}
" - debug_info += f"Headers Used: X-API-Key
" - - if response.status_code == 200: - data = response.json() - debug_info += "✅ SUCCESS - API is working!
" - debug_info += f"Response Keys: {list(data.keys())}
" - - # Show some sample data - if data.get('a'): - debug_info += f"A Records Found: {len(data['a'])}
" - if data.get('ns'): - debug_info += f"NS Records Found: {len(data['ns'])}
" - if data.get('total_a_recs'): - debug_info += f"Total A Records: {data['total_a_recs']}
" - - elif response.status_code == 400: - debug_info += "❌ Bad Request - Check domain format
" - debug_info += f"Response: {response.text[:200]}
" - elif response.status_code == 401: - debug_info += "❌ Unauthorized - Invalid API key
" - elif response.status_code == 429: - debug_info += "⚠️ Rate Limit Exceeded - Wait 2 seconds
" - else: - debug_info += f"❌ Error: {response.status_code} - {response.text[:200]}
" - - await bot.api.send_markdown_message(room.room_id, debug_info) + headers = {"X-API-Key": DNSDUMPSTER_API_KEY} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=15) as response: + status = response.status + debug_info = f"🔧 DNSDumpster API Test
Status Code: {status}
Test Domain: {test_domain}
" + if status == 200: + data = await response.json() + debug_info += "✅ SUCCESS
" + if data.get('a'): + debug_info += f"A Records Found: {len(data['a'])}
" + elif status == 401: + debug_info += "❌ Unauthorized - Invalid API key
" + elif status == 429: + debug_info += "⚠️ Rate Limit Exceeded
" + else: + debug_info += f"❌ Error: {status}
" + await bot.api.send_markdown_message(room.room_id, debug_info) except Exception as e: await bot.api.send_text_message(room.room_id, f"Test failed: {str(e)}") async def dnsdumpster_domain_lookup(room, bot, domain): - """Get comprehensive DNS reconnaissance for a domain.""" + safe_domain = html_escape(domain) try: + await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {safe_domain}...") url = f"{DNSDUMPSTER_API_BASE}/domain/{domain}" - headers = { - "X-API-Key": DNSDUMPSTER_API_KEY - } + headers = {"X-API-Key": DNSDUMPSTER_API_KEY} + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=30) as response: + if response.status != 200: + await bot.api.send_text_message(room.room_id, f"API error: {response.status}") + return + data = await response.json() - logging.info(f"Fetching DNSDumpster data for domain: {domain}") - - # Send initial processing message - await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {domain}...") - - response = requests.get(url, headers=headers, timeout=30) - - if response.status_code == 400: - await bot.api.send_text_message(room.room_id, f"Bad request - check domain format: {domain}") - return - elif response.status_code == 401: - await bot.api.send_text_message(room.room_id, "Invalid DNSDumpster API key") - return - elif response.status_code == 403: - await bot.api.send_text_message(room.room_id, "Access denied - check API key permissions") - return - elif response.status_code == 429: - await bot.api.send_text_message(room.room_id, "Rate limit exceeded - wait 2 seconds between requests") - return - elif response.status_code != 200: - await bot.api.send_text_message(room.room_id, f"DNSDumpster API error: {response.status_code} - {response.text[:100]}") - return - - data = response.json() - logging.info(f"DNSDumpster response keys: {list(data.keys())}") - - # Format the comprehensive DNS report output = await format_dnsdumpster_report(domain, data) - await bot.api.send_markdown_message(room.room_id, output) logging.info(f"Sent DNSDumpster data for {domain}") - - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "DNSDumpster API request timed out") - logging.error("DNSDumpster API timeout") + except asyncio.TimeoutError: + await bot.api.send_text_message(room.room_id, "Request timed out.") except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error fetching DNSDumpster data: {str(e)}") - logging.error(f"Error in dnsdumpster_domain_lookup: {e}") + await bot.api.send_text_message(room.room_id, f"Error: {e}") async def format_dnsdumpster_report(domain, data): - """Format DNSDumpster JSON response into a readable report.""" - output = f"🔍 DNSDumpster Report: {domain}

" - - # Summary statistics + safe_domain = html_escape(domain) + output = f"🔍 DNSDumpster Report: {safe_domain}

" if data.get('total_a_recs'): - output += f"📊 Summary
" - output += f" • Total A Records: {data['total_a_recs']}
" + output += f"📊 Summary
Total A Records: {data['total_a_recs']}
" - # A Records - Show ALL records - if data.get('a') and data['a']: - output += f"
📍 A Records (IPv4) - {len(data['a'])} found
" - for record in data['a']: # Show ALL A records - host = record.get('host', 'N/A') - ips = record.get('ips', []) - - output += f" • {host}
" - for ip_info in ips: # Show ALL IPs per host - ip = ip_info.get('ip', 'N/A') - country = ip_info.get('country', 'Unknown') - asn_name = ip_info.get('asn_name', 'Unknown') - - output += f" └─ {ip} ({country})
" - output += f" └─ {asn_name}
" - - # Show banner information if available - banners = ip_info.get('banners', {}) - if banners.get('http') or banners.get('https'): - output += f" └─ Web Services: " - services = [] - if banners.get('http'): - services.append("HTTP") - if banners.get('https'): - services.append("HTTPS") - output += f"{', '.join(services)}
" - - # NS Records - Show ALL records - if data.get('ns') and data['ns']: - output += f"
🔗 NS Records (Name Servers) - {len(data['ns'])} found
" - for record in data['ns']: # Show ALL NS records - host = record.get('host', 'N/A') - ips = record.get('ips', []) - - output += f" • {host}
" - for ip_info in ips: # Show ALL IPs - ip = ip_info.get('ip', 'N/A') - country = ip_info.get('country', 'Unknown') - output += f" └─ {ip} ({country})
" - - # MX Records - Show ALL records - if data.get('mx') and data['mx']: - output += f"
📧 MX Records (Mail Servers) - {len(data['mx'])} found
" - for record in data['mx']: # Show ALL MX records - host = record.get('host', 'N/A') - ips = record.get('ips', []) - - output += f" • {host}
" - for ip_info in ips: # Show ALL IPs - ip = ip_info.get('ip', 'N/A') - country = ip_info.get('country', 'Unknown') - output += f" └─ {ip} ({country})
" - - # CNAME Records - Show ALL records - if data.get('cname') and data['cname']: - output += f"
🔀 CNAME Records - {len(data['cname'])} found
" - for record in data['cname']: # Show ALL CNAME records - host = record.get('host', 'N/A') - target = record.get('target', 'N/A') - output += f" • {host} → {target}
" - - # TXT Records - Show ALL records - if data.get('txt') and data['txt']: - output += f"
📄 TXT Records - {len(data['txt'])} found
" - for txt in data['txt']: # Show ALL TXT records - # Truncate very long TXT records but show more content - if len(txt) > 200: - txt = txt[:200] + "..." - output += f" • {txt}
" - - # Additional record types that might be present - Show ALL records - other_records = ['aaaa', 'srv', 'soa', 'ptr'] - for record_type in other_records: + for record_type, label in [('a','A Records'),('ns','NS Records'),('mx','MX Records'),('cname','CNAME'),('txt','TXT')]: if data.get(record_type) and data[record_type]: - output += f"
🔧 {record_type.upper()} Records - {len(data[record_type])} found
" - for record in data[record_type]: # Show ALL records - if isinstance(record, dict): - # Format dictionary records nicely - record_str = ", ".join([f"{k}: {v}" for k, v in record.items()]) - if len(record_str) > 150: - record_str = record_str[:150] + "..." - output += f" • {record_str}
" + output += f"
{label} ({len(data[record_type])} found)
" + for rec in data[record_type]: + if record_type == 'txt': + txt = html_escape(str(rec)) + if len(txt) > 200: + txt = txt[:200] + "..." + output += f" • {txt}
" + elif record_type == 'a': + host = html_escape(rec.get('host','N/A')) + ips = rec.get('ips',[]) + output += f" • {host}
" + for ip_info in ips: + ip = html_escape(ip_info.get('ip','N/A')) + country = html_escape(ip_info.get('country','Unknown')) + output += f" └─ {ip} ({country})
" else: - output += f" • {record}
" + host = html_escape(rec.get('host','N/A')) + ips = rec.get('ips',[]) + output += f" • {host}
" + for ip_info in ips: + ip = html_escape(ip_info.get('ip','N/A')) + country = html_escape(ip_info.get('country','Unknown')) + output += f" └─ {ip} ({country})
" - # Add rate limit reminder output += "
💡 Rate Limit: 1 request per 2 seconds" + return collapsible_summary(f"🔍 DNSDumpster Report: {safe_domain} (Click to expand)", output) - # Always wrap in collapsible details since we're showing all results - output = f"
🔍 DNSDumpster Report: {domain} (Click to expand){output}
" - - return output - - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "DNSDumpster domain reconnaissance" __help__ = """
!dnsdumpster – Comprehensive DNS mapping via DNSDumpster
    -
  • !dnsdumpster <domain> – Full recon (A, NS, MX, CNAME, TXT, etc.)
  • +
  • !dnsdumpster <domain> – Full recon
  • !dnsdumpster test – Test API connection
-

Requires DNSDUMPSTER_KEY env var. Rate limit: 1 req/2 sec.

+

Requires DNSDUMPSTER_KEY env var.

""" diff --git a/plugins/exploitdb.py b/plugins/exploitdb.py index 0b632f2..bb77d0f 100644 --- a/plugins/exploitdb.py +++ b/plugins/exploitdb.py @@ -1,254 +1,112 @@ """ -This plugin provides a command to search Exploit-DB for security exploits and vulnerabilities. -Uses the searchsploit-style approach with the files.csv database. +This plugin provides a command to search Exploit-DB for security exploits. """ - import logging -import requests +import aiohttp import csv import io import simplematrixbotlib as botlib -from datetime import datetime +from plugins.common import html_escape, collapsible_summary -# Exploit-DB CSV database URL EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv" - def format_exploit(exploit, index, total): - """ - Format an exploit entry for display. - - Args: - exploit (dict): The exploit data. - index (int): Current result index. - total (int): Total number of results. - - Returns: - str: Formatted HTML string. - """ - edb_id = exploit.get('id', 'N/A') - title = exploit.get('description', 'No title') - date = exploit.get('date', 'Unknown') - author = exploit.get('author', 'Unknown') - exploit_type = exploit.get('type', 'Unknown') - platform = exploit.get('platform', 'Unknown') - - # Build the URL + edb_id = html_escape(str(exploit.get('id', 'N/A'))) + title = html_escape(exploit.get('description', 'No title')) + date = html_escape(exploit.get('date', 'Unknown')) + author = html_escape(exploit.get('author', 'Unknown')) + exploit_type = html_escape(exploit.get('type', 'Unknown')) + platform = html_escape(exploit.get('platform', 'Unknown')) url = f"https://www.exploit-db.com/exploits/{edb_id}" - output = f"""💣 Exploit {index}/{total}
+ return f"""💣 Exploit {index}/{total}
Title: {title}
EDB-ID: {edb_id}
Type: {exploit_type} | Platform: {platform}
Author: {author} | Date: {date}
URL: {url}""" - return output - - async def search_exploitdb_csv(query, max_results=5): - """ - Search Exploit-DB CSV database for exploits matching the query. - - Args: - query (str): Search term. - max_results (int): Maximum number of results to return. - - Returns: - list: List of exploit dictionaries, or None on error. - """ + headers = {'User-Agent': 'FunguyBot/1.0'} try: - logging.info(f"Downloading Exploit-DB CSV database...") + async with aiohttp.ClientSession() as session: + async with session.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30) as response: + response.raise_for_status() + csv_data = await response.text() + except Exception as e: + logging.error(f"Error downloading CSV: {e}") + return None - headers = { - 'User-Agent': 'FunguyBot/1.0', - } - - # Download the CSV file - response = requests.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30) - response.raise_for_status() - - # Parse CSV - csv_data = response.text + results = [] + try: csv_file = io.StringIO(csv_data) reader = csv.DictReader(csv_file) - - # Search through CSV - results = [] query_lower = query.lower() - - logging.info(f"Searching CSV for: {query}") - for row in reader: - # Search in description (title) and other fields description = row.get('description', '').lower() file_path = row.get('file', '').lower() - if query_lower in description or query_lower in file_path: - exploit = { + results.append({ 'id': row.get('id', 'N/A'), 'description': row.get('description', 'No title'), 'date': row.get('date_published', row.get('date', 'Unknown')), 'author': row.get('author', 'Unknown'), 'type': row.get('type', 'Unknown'), 'platform': row.get('platform', 'Unknown') - } - results.append(exploit) - + }) if len(results) >= max_results: break - return results - - except requests.exceptions.Timeout: - logging.error("Timeout downloading Exploit-DB database") - return None - except requests.exceptions.RequestException as e: - logging.error(f"Error downloading Exploit-DB database: {e}") - return None except Exception as e: - logging.error(f"Unexpected error searching Exploit-DB: {e}", exc_info=True) + logging.error(f"CSV parse error: {e}") return None - -async def search_exploitdb_google(query, max_results=5): - """ - Alternative: Search Exploit-DB using site-specific search. - Returns formatted search URLs instead of parsing. - - Args: - query (str): Search term. - max_results (int): Maximum number of results to return. - - Returns: - str: Formatted search information. - """ - # Create search URLs - exploitdb_search_url = f"https://www.exploit-db.com/search?q={query}" - google_search_url = f"https://www.google.com/search?q=site:exploit-db.com+{query}" - - output = f"""💣 Exploit-DB Search for: {query}

-Direct Search:
-{exploitdb_search_url}

-Google Site Search:
-{google_search_url}

-💡 Tip: You can also use searchsploit command-line tool for offline searches.
-⚠️ Use responsibly and only on systems you have permission to test.""" - - return output - - async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !exploitdb command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("exploitdb"): - logging.info("Received !exploitdb command") - args = match.args() - - if len(args) < 1: - await bot.api.send_text_message( - room.room_id, - "Usage: !exploitdb [max_results]\n" - "Examples:\n" - " !exploitdb wordpress\n" - " !exploitdb apache 3\n" - " !exploitdb windows privilege escalation\n" - "Searches Exploit-DB for security vulnerabilities and exploits." - ) - logging.info("Sent usage message for !exploitdb") + if not args: + await bot.api.send_text_message(room.room_id, "Usage: !exploitdb [max_results]") return - # Check if last argument is a number (max results) max_results = 5 search_terms = args - if args[-1].isdigit(): max_results = int(args[-1]) - if max_results < 1: - max_results = 1 - elif max_results > 10: - max_results = 10 + if max_results < 1: max_results = 1 + elif max_results > 10: max_results = 10 search_terms = args[:-1] query = ' '.join(search_terms) + safe_query = html_escape(query) - try: - # Send "searching" message - await bot.api.send_text_message( - room.room_id, - f"🔍 Searching Exploit-DB for: {query}... (this may take a moment)" - ) + await bot.api.send_text_message(room.room_id, f"🔍 Searching Exploit-DB for: {safe_query}...") + exploits = await search_exploitdb_csv(query, max_results) - # Try CSV search first - exploits = await search_exploitdb_csv(query, max_results) + if exploits is None: + await bot.api.send_text_message(room.room_id, "❌ Failed to search Exploit-DB (network error).") + return - if exploits is None: - # Fallback to providing search links - logging.warning("CSV search failed, providing search links instead") - output = await search_exploitdb_google(query, max_results) - await bot.api.send_markdown_message(room.room_id, output) - return + if not exploits: + exploitdb_url = f"https://www.exploit-db.com/search?q={query}" + google_url = f"https://www.google.com/search?q=site:exploit-db.com+{query}" + msg = f"No exploits found for {safe_query}.
Direct: Exploit-DB | Google" + await bot.api.send_markdown_message(room.room_id, msg) + return - if not exploits: - # Also provide search links when no results - output = f"No exploits found in local search for: {query}

" - output += await search_exploitdb_google(query, max_results) - await bot.api.send_markdown_message(room.room_id, output) - logging.info(f"No exploits found for: {query}") - return + total = len(exploits) + output = f"💣 Exploit-DB Search Results for: {safe_query}

" + for idx, exp in enumerate(exploits, 1): + output += format_exploit(exp, idx, total) + "

" + output += "⚠️ Use responsibly" - total = len(exploits) - logging.info(f"Found {total} exploit(s) for: {query}") + if total > 2: + output = collapsible_summary(f"💣 Exploit-DB: {safe_query} ({total} results)", output) - # Format all results - output = f"💣 Exploit-DB Search Results for: {query}

" + await bot.api.send_markdown_message(room.room_id, output) - for idx, exploit in enumerate(exploits, 1): - output += format_exploit(exploit, idx, total) - output += "

" - - output += f"⚠️ Use responsibly and only on systems you have permission to test." - - # Wrap in collapsible details if more than 2 results - if total > 2: - summary = f"💣 Exploit-DB: {query} ({total} results)" - output = f"
{summary}{output}
" - - await bot.api.send_markdown_message(room.room_id, output) - logging.info(f"Sent {total} exploit(s) for: {query}") - - except Exception as e: - await bot.api.send_text_message( - room.room_id, - f"An error occurred while searching Exploit-DB: {str(e)}" - ) - logging.error(f"Error in exploitdb plugin: {e}", exc_info=True) - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "Exploit-DB search" -__help__ = """ -
-!exploitdb – Search Exploit Database -

!exploitdb <search term> [max_results] – Search for exploits (title, EDB-ID, type, platform, author, link).
-Example: !exploitdb wordpress 5

-

Fetches from the official Exploit-DB CSV. Falls back to search links if unavailable.

-
-""" +__help__ = """
!exploitdb – Search Exploit Database +

!exploitdb <search term> [max_results]

""" diff --git a/plugins/fortune.py b/plugins/fortune.py index ea0c360..a3a4bc8 100644 --- a/plugins/fortune.py +++ b/plugins/fortune.py @@ -1,41 +1,32 @@ """ This plugin provides a command to get a random fortune message. """ -# plugins/fortune.py - -import subprocess +import asyncio import logging import simplematrixbotlib as botlib async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !fortune command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("fortune"): logging.info("Received !fortune command") - fortune_output = "🃏 " + subprocess.run(['/usr/games/fortune'], capture_output=True).stdout.decode('UTF-8') - await bot.api.send_markdown_message(room.room_id, fortune_output) - logging.info("Sent fortune to the room") + try: + proc = await asyncio.create_subprocess_exec( + '/usr/games/fortune', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0 and stdout: + fortune_text = "🃏 " + stdout.decode('UTF-8') + else: + fortune_text = "🃏 Fortune command failed." + await bot.api.send_markdown_message(room.room_id, fortune_text) + except Exception as e: + logging.error(f"Fortune error: {e}") + await bot.api.send_text_message(room.room_id, "Fortune unavailable.") - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "Random fortune message" -__help__ = """ -
-!fortune – Random fortune -

Runs the /usr/games/fortune utility and posts a random quote.

-
-""" +__help__ = """
!fortune – Random fortune +

Runs the /usr/games/fortune utility.

""" diff --git a/plugins/geo.py b/plugins/geo.py index afae3c5..f88d714 100644 --- a/plugins/geo.py +++ b/plugins/geo.py @@ -1,18 +1,14 @@ """ This plugin provides IP geolocation functionality using free APIs. -It uses ip-api.com as the primary API with a fallback to ipapi.co. """ - import logging import aiohttp import simplematrixbotlib as botlib import socket import re - -from plugins.utils import is_public_destination +from plugins.common import is_public_destination, html_escape, collapsible_summary async def is_valid_ip(ip): - """Check if the provided string is a valid IP address.""" try: socket.inet_pton(socket.AF_INET, ip) return True @@ -24,165 +20,103 @@ async def is_valid_ip(ip): return False def is_domain(domain): - """Check if the provided string is a domain name.""" domain_pattern = re.compile( r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$' ) return bool(domain_pattern.match(domain)) async def resolve_domain(domain): - """Resolve a domain name to an IP address.""" try: return socket.gethostbyname(domain) except socket.gaierror: return None async def query_ip_api_com(ip): - """Query ip-api.com for geolocation information.""" url = f"http://ip-api.com/json/{ip}" try: async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status == 200: - data = await response.json() - return data - else: - logging.error(f"ip-api.com returned status {response.status}") - return None + return await response.json() except Exception as e: - logging.error(f"Error querying ip-api.com: {e}") - return None + logging.error(f"ip-api.com error: {e}") + return None async def query_ipapi_co(ip): - """Query ipapi.co for geolocation information (fallback).""" url = f"https://ipapi.co/{ip}/json/" try: async with aiohttp.ClientSession() as session: async with session.get(url) as response: if response.status == 200: - data = await response.json() - return data - else: - logging.error(f"ipapi.co returned status {response.status}") - return None + return await response.json() except Exception as e: - logging.error(f"Error querying ipapi.co: {e}") - return None + logging.error(f"ipapi.co error: {e}") + return None async def query_geolocation(ip): - """Query geolocation information using primary and fallback APIs.""" data = await query_ip_api_com(ip) if not data or data.get('status') == 'fail': - logging.info("Primary API failed, trying fallback API") data = await query_ipapi_co(ip) return data async def format_geolocation_results(ip, data): - """Format geolocation results into a readable message.""" - if not data: + if not data or ('status' in data and data.get('status') == 'fail'): return f"🔍 No geolocation data found for {ip}." - if 'status' in data and data.get('status') == 'fail': - return f"🔍 No geolocation data found for {ip}." - if 'country' in data: - country = data.get('country', 'N/A') - country_code = data.get('countryCode', 'N/A') - region = data.get('regionName', data.get('region', 'N/A')) - city = data.get('city', 'N/A') - postal = data.get('zip', 'N/A') - latitude = data.get('lat', 'N/A') - longitude = data.get('lon', 'N/A') - timezone = data.get('timezone', 'N/A') - isp = data.get('isp', 'N/A') - org = data.get('org', 'N/A') - asn = data.get('as', 'N/A') - else: - country = data.get('country_name', data.get('country', 'N/A')) - country_code = data.get('country_code', data.get('countryCode', 'N/A')) - region = data.get('region', 'N/A') - city = data.get('city', 'N/A') - postal = data.get('postal', 'N/A') - latitude = data.get('latitude', 'N/A') - longitude = data.get('longitude', 'N/A') - timezone = data.get('timezone', 'N/A') - isp = data.get('org', 'N/A') - org = data.get('org', 'N/A') - asn = data.get('asn', 'N/A') - content = f"🔍 IP Geolocation Results for {ip}

" - content += f"Country: {country} ({country_code})
" - content += f"Region: {region}
" - content += f"City: {city}
" - content += f"Postal Code: {postal}
" - content += f"Coordinates: {latitude}, {longitude}
" - content += f"Timezone: {timezone}
" - content += f"ISP/Organization: {isp}
" - content += f"ASN: {asn}
" - message = f"
🔍 Geolocation: {ip}{content}
" - return message + country = data.get('country', 'N/A') + country_code = data.get('countryCode', 'N/A') + region = data.get('regionName', data.get('region', 'N/A')) + city = data.get('city', 'N/A') + postal = data.get('zip', 'N/A') + latitude = data.get('lat', 'N/A') + longitude = data.get('lon', 'N/A') + timezone = data.get('timezone', 'N/A') + isp = data.get('isp', 'N/A') + org = data.get('org', 'N/A') + asn = data.get('as', 'N/A') + + content = (f"Country: {country} ({country_code})
" + f"Region: {region}
" + f"City: {city}
" + f"Postal Code: {postal}
" + f"Coordinates: {latitude}, {longitude}
" + f"Timezone: {timezone}
" + f"ISP/Organization: {isp}
" + f"ASN: {asn}
") + return collapsible_summary(f"🔍 Geolocation: {ip}", content) async def handle_command(room, message, bot, prefix, config): - """Handle the !geo command.""" match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("geo"): args = match.args() if len(args) < 1: - await bot.api.send_text_message( - room.room_id, - "Usage: !geo \nExample: !geo 8.8.8.8\nExample: !geo example.com" - ) + await bot.api.send_text_message(room.room_id, "Usage: !geo ") return query = args[0].strip() - logging.info(f"Received !geo command for: {query}") - try: - ip = query - if is_domain(query): - await bot.api.send_text_message( - room.room_id, - f"🔍 Resolving domain {query} to IP address..." - ) - ip = await resolve_domain(query) - if not ip: - await bot.api.send_text_message(room.room_id, - f"Failed to resolve domain {query} to IP address.") - return - if not is_public_destination(ip): - await bot.api.send_text_message(room.room_id, - "❌ That domain resolves to a private/internal IP, geo not allowed.") - return - await bot.api.send_text_message(room.room_id, - f"Domain {query} resolved to IP {ip}") - elif not await is_valid_ip(query): - await bot.api.send_text_message(room.room_id, - f"Invalid IP address or domain format: {query}") + ip = query + if is_domain(query): + await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...") + ip = await resolve_domain(query) + if not ip: + await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.") + return + if not is_public_destination(ip): + await bot.api.send_text_message(room.room_id, "❌ Domain resolves to private IP.") + return + await bot.api.send_text_message(room.room_id, f"Resolved to {ip}") + elif not await is_valid_ip(query): + await bot.api.send_text_message(room.room_id, f"Invalid IP/domain: {html_escape(query)}") + return + else: + if not is_public_destination(ip): + await bot.api.send_text_message(room.room_id, "❌ Private IP not allowed.") return - else: - if not is_public_destination(ip): - await bot.api.send_text_message(room.room_id, - "❌ Geolocation of private IP addresses is not allowed.") - return - await bot.api.send_text_message(room.room_id, - f"🔍 Looking up geolocation for {ip}...") - geo_data = await query_geolocation(ip) - result_message = await format_geolocation_results(ip, geo_data) - await bot.api.send_markdown_message(room.room_id, result_message) - logging.info(f"Successfully sent geolocation results for {ip}") - except Exception as e: - await bot.api.send_text_message(room.room_id, - f"An error occurred during geolocation lookup for {query}. Please try again later.") - logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True) -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- -__version__ = "1.0.1" + geo_data = await query_geolocation(ip) + result = await format_geolocation_results(ip, geo_data) + await bot.api.send_markdown_message(room.room_id, result) + +__version__ = "1.0.2" __author__ = "Funguy Bot" __description__ = "IP geolocation lookup" -__help__ = """ -
-!geo – IP / domain geolocation -
    -
  • !geo <ip> – Locate an IP address
  • -
  • !geo <domain> – Resolves domain then locates
  • -
-

Shows country, region, city, coordinates, ISP, ASN. Uses ip-api.com / ipapi.co.

-
-""" +__help__ = """
!geo – IP / domain geolocation +
  • !geo <ip> or !geo <domain>
""" diff --git a/plugins/headers.py b/plugins/headers.py index 2307ef0..0637ced 100644 --- a/plugins/headers.py +++ b/plugins/headers.py @@ -3,27 +3,18 @@ This plugin provides comprehensive HTTP security header analysis. """ import logging -import requests +import aiohttp +import asyncio import simplematrixbotlib as botlib from urllib.parse import urlparse import ssl import socket - -from plugins.utils import is_public_destination +import datetime +from plugins.common import is_public_destination, collapsible_summary, html_escape async def handle_command(room, message, bot, prefix, config): """ Function to handle !headers command for HTTP security header analysis. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("headers"): @@ -74,7 +65,7 @@ async def show_usage(room, bot): async def analyze_headers(room, bot, url): """Perform comprehensive HTTP security header analysis.""" try: - await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {url}") + await bot.api.send_text_message(room.room_id, f"🔍 Analyzing security headers for: {html_escape(url)}") results = { 'url': url, @@ -121,65 +112,60 @@ async def analyze_headers(room, bot, url): async def analyze_http_response(results, url): """Analyze HTTP response and redirect chain.""" try: - session = requests.Session() - session.max_redirects = 5 - - response = session.get(url, timeout=10, allow_redirects=True) - results['final_url'] = response.url - results['status_code'] = response.status_code - results['http_headers'] = dict(response.headers) - - # Check if redirects to HTTPS - results['redirects_to_https'] = response.url.startswith('https://') - - # Store redirect history - results['redirect_chain'] = [{ - 'url': resp.url, - 'status_code': resp.status_code, - 'headers': dict(resp.headers) - } for resp in response.history] - - except requests.exceptions.SSLError: - results['ssl_error'] = True - except requests.exceptions.RequestException as e: + async with aiohttp.ClientSession() as session: + async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response: + results['final_url'] = str(response.url) + results['status_code'] = response.status + results['http_headers'] = dict(response.headers) + results['redirects_to_https'] = response.url.scheme == 'https' + # aiohttp doesn't give access to redirect history easily, so we'll mark if final URL differs + if str(response.url) != url: + results['redirect_chain'] = [{'url': url, 'status_code': 301}] # simplified + except aiohttp.ClientError as e: results['http_error'] = str(e) async def analyze_https_response(results, url): """Analyze HTTPS response headers.""" try: - response = requests.get(url, timeout=10, allow_redirects=False) - results['https_headers'] = dict(response.headers) - results['https_status'] = response.status_code - except requests.exceptions.RequestException as e: + async with aiohttp.ClientSession() as session: + async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response: + results['https_headers'] = dict(response.headers) + results['https_status'] = response.status + except aiohttp.ClientError as e: results['https_error'] = str(e) async def analyze_ssl_certificate(results, domain): - """Analyze SSL certificate information.""" - try: - context = ssl.create_default_context() - with socket.create_connection((domain, 443), timeout=10) as sock: - with context.wrap_socket(sock, server_hostname=domain) as ssock: - cert = ssock.getpeercert() + """Analyze SSL certificate information (run in thread to avoid event loop blocking).""" + def _get_cert(): + try: + context = ssl.create_default_context() + with socket.create_connection((domain, 443), timeout=10) as sock: + with context.wrap_socket(sock, server_hostname=domain) as ssock: + cert = ssock.getpeercert() + return { + 'subject': dict(x[0] for x in cert['subject']), + 'issuer': dict(x[0] for x in cert['issuer']), + 'not_before': cert['notBefore'], + 'not_after': cert['notAfter'], + 'san': cert.get('subjectAltName', []), + 'version': cert.get('version'), + 'serial_number': cert.get('serialNumber') + } + except Exception as e: + return f"Error: {e}" - results['ssl_info'] = { - 'subject': dict(x[0] for x in cert['subject']), - 'issuer': dict(x[0] for x in cert['issuer']), - 'not_before': cert['notBefore'], - 'not_after': cert['notAfter'], - 'san': cert.get('subjectAltName', []), - 'version': cert.get('version'), - 'serial_number': cert.get('serialNumber') - } - - except Exception as e: - results['ssl_error'] = str(e) + loop = asyncio.get_running_loop() + ssl_data = await loop.run_in_executor(None, _get_cert) + if isinstance(ssl_data, str): + results['ssl_error'] = ssl_data + else: + results['ssl_info'] = ssl_data async def calculate_security_score(results): """Calculate overall security score based on headers and configuration.""" score = 100 missing_headers = [] - # Critical security headers critical_headers = [ 'Strict-Transport-Security', 'Content-Security-Policy', @@ -239,7 +225,6 @@ async def generate_recommendations(results): recommendations = [] headers = results.get('https_headers') or results.get('http_headers', {}) - # HSTS recommendations if 'Strict-Transport-Security' not in headers: recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload") else: @@ -251,35 +236,21 @@ async def generate_recommendations(results): if 'preload' not in hsts: recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading") - # CSP recommendations if 'Content-Security-Policy' not in headers: recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks") - else: - csp = headers['Content-Security-Policy'] - if "default-src 'self'" not in csp and "default-src 'none'" not in csp: - recommendations.append("🛡️ Restrict CSP default-src to 'self' or specific origins") - # Frame options if 'X-Frame-Options' not in headers: recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)") - # Content type options if 'X-Content-Type-Options' not in headers: recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing") - # Referrer policy if 'Referrer-Policy' not in headers: recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage") - # Feature policy - if 'Feature-Policy' not in headers and 'Permissions-Policy' not in headers: - recommendations.append("⚙️ Implement Feature-Policy/Permissions-Policy to restrict browser features") - - # Remove server information if 'Server' in headers or 'X-Powered-By' in headers: recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure") - # HTTPS enforcement if not results.get('redirects_to_https') and not results['url'].startswith('https://'): recommendations.append("🔐 Implement HTTP to HTTPS redirects") @@ -287,7 +258,8 @@ async def generate_recommendations(results): async def format_header_analysis(results): """Format the header analysis results for display.""" - output = f"🔒 Security Headers Analysis: {results['url']}

" + safe_url = html_escape(results['url']) + output = f"🔒 Security Headers Analysis: {safe_url}

" # Security Score score = results['security_score'] @@ -296,13 +268,12 @@ async def format_header_analysis(results): # Basic Information output += "📊 Basic Information
" - output += f" • Final URL: {results.get('final_url', 'N/A')}
" + output += f" • Final URL: {html_escape(results.get('final_url', 'N/A'))}
" output += f" • Status Code: {results.get('status_code', 'N/A')}
" if results.get('redirects_to_https'): output += f" • HTTPS Redirect: ✅ Enforced
" else: output += f" • HTTPS Redirect: ❌ Not enforced
" - output += f" • Redirect Chain: {len(results.get('redirect_chain', []))} hops
" output += "
" # Security Headers Analysis @@ -310,10 +281,10 @@ async def format_header_analysis(results): output += "🛡️ Security Headers Analysis
" security_headers = { - 'Strict-Transport-Security': ('🔒', 'HSTS - HTTP Strict Transport Security'), - 'Content-Security-Policy': ('🛡️', 'CSP - Content Security Policy'), + 'Strict-Transport-Security': ('🔒', 'HSTS'), + 'Content-Security-Policy': ('🛡️', 'CSP'), 'X-Frame-Options': ('🚫', 'Clickjacking Protection'), - 'X-Content-Type-Options': ('📄', 'MIME Type Sniffing Protection'), + 'X-Content-Type-Options': ('📄', 'MIME Sniffing'), 'X-XSS-Protection': ('❌', 'XSS Protection (Deprecated)'), 'Referrer-Policy': ('🔗', 'Referrer Policy'), 'Feature-Policy': ('⚙️', 'Feature Policy'), @@ -322,88 +293,55 @@ async def format_header_analysis(results): for header, (emoji, description) in security_headers.items(): if header in headers: - value = headers[header] - if len(value) > 100: - value = value[:100] + "..." + value = html_escape(str(headers[header]))[:100] output += f" • {emoji} {header}: ✅ {value}
" else: output += f" • {emoji} {header}: ❌ Missing
" - output += "
" # Other Headers (Information Disclosure) output += "📋 Other Headers
" - info_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version'] - for header in info_headers: + for header in ['Server', 'X-Powered-By']: if header in headers: - output += f" • 🔍 {header}: {headers[header]}
" - + output += f" • 🔍 {header}: {html_escape(str(headers[header]))}
" output += "
" # SSL Certificate Information (if available) - if results.get('ssl_info'): + if results.get('ssl_info') and 'subject' in results['ssl_info']: output += "🔐 SSL Certificate
" ssl_info = results['ssl_info'] if ssl_info.get('subject'): - output += f" • Subject: {ssl_info['subject'].get('commonName', 'N/A')}
" + output += f" • Subject: {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}
" if ssl_info.get('issuer'): - output += f" • Issuer: {ssl_info['issuer'].get('organizationName', 'N/A')}
" + output += f" • Issuer: {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}
" if ssl_info.get('not_after'): - output += f" • Expires: {ssl_info['not_after']}
" - if ssl_info.get('san'): - san_count = len([san for san in ssl_info['san'] if san[0] == 'DNS']) - output += f" • SAN Entries: {san_count}
" + output += f" • Expires: {html_escape(ssl_info['not_after'])}
" output += "
" # Recommendations if results.get('recommendations'): output += "💡 Security Recommendations
" - for rec in results['recommendations'][:8]: # Show first 8 recommendations + for rec in results['recommendations'][:8]: output += f" • {rec}
" - - if len(results['recommendations']) > 8: - output += f" • ... and {len(results['recommendations']) - 8} more recommendations
" output += "
" - # Missing Headers Summary - if results.get('missing_headers'): - output += "⚠️ Critical Headers Missing
" - for header in results['missing_headers']: - output += f" • ❌ {header}
" - output += "
" - - # Security Rating - score = results['security_score'] + # Final rating if score >= 80: rating = "🟢 Excellent" - description = "Strong security headers configuration" elif score >= 60: rating = "🟡 Good" - description = "Moderate security, room for improvement" elif score >= 40: rating = "🟠 Fair" - description = "Basic security, significant improvements needed" else: rating = "🔴 Poor" - description = "Weak security headers configuration" - output += f"📈 Security Rating: {rating}
" - output += f"📝 Assessment: {description}
" - # Wrap in collapsible if content is large - if len(output) > 1000: - output = f"
🔒 Security Headers Analysis: {results['url']}{output}
" + # Wrap in collapsible details + return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output) - return output - - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.1" +__version__ = "1.0.2" __author__ = "Funguy Bot" -__description__ = "HTTP security header analysis" +__description__ = "HTTP security header analysis (SSRF‑safe, async)" __help__ = """
!headers – HTTP security header scanner diff --git a/plugins/infermatic-text.py b/plugins/infermatic-text.py index 7da65e4..f363b6b 100644 --- a/plugins/infermatic-text.py +++ b/plugins/infermatic-text.py @@ -1,40 +1,18 @@ """ Plugin for generating text using Infermatic AI API and sending it to a Matrix chat room. """ - import os -import requests -import argparse +import aiohttp import json import simplematrixbotlib as botlib -from asyncio import Queue -from dotenv import load_dotenv import re +from plugins.common import html_escape -# Load environment variables from .env file in the parent directory -plugin_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(plugin_dir) -dotenv_path = os.path.join(parent_dir, '.env') -load_dotenv(dotenv_path) - -# Infermatic AI API configuration +# No load_dotenv – handled centrally by funguy.py INFERMATIC_API_KEY = os.getenv("INFERMATIC_API", "") DEFAULT_MODEL = os.getenv("INFERMATIC_MODEL", "Sao10K-L3.1-70B-Hanami-x1") INFERMATIC_API_BASE = "https://api.totalgpt.ai/v1" -# Queue to store pending commands -command_queue = Queue() - -async def process_command(room, message, bot, prefix, config): - """Queue and process !text commands sequentially.""" - match = botlib.MessageMatch(room, message, bot, prefix) - if match.prefix() and match.command("text"): - if command_queue.empty(): - await handle_command(room, message, bot, prefix, config) - else: - await command_queue.put((room, message, bot, prefix, config)) - await bot.api.send_text_message(room.room_id, "Command queued. Please wait for the current request to finish.") - async def handle_command(room, message, bot, prefix, config): """Handle !text command: generate text using Infermatic AI API.""" match = botlib.MessageMatch(room, message, bot, prefix) @@ -42,29 +20,20 @@ async def handle_command(room, message, bot, prefix, config): if not (match.prefix() and match.command("text")): return - # Check if API key is configured if not INFERMATIC_API_KEY: - await bot.api.send_text_message( - room.room_id, - "Infermatic API key not configured. Please set INFERMATIC_API environment variable." - ) + await bot.api.send_text_message(room.room_id, "Infermatic API key not configured. Set INFERMATIC_API in .env.") return - # Parse command arguments args = match.args() - if len(args) < 1: await show_usage(room, bot) return - # Check if it's a --list-models command if args[0] == "--list-models": await list_models(room, bot) return - # Parse other arguments try: - # Extract options manually since argparse doesn't handle mixed positional/optional well temperature = 0.9 max_tokens = 512 custom_model = None @@ -86,13 +55,11 @@ async def handle_command(room, message, bot, prefix, config): i += 1 prompt = ' '.join(prompt_parts).strip() - if not prompt: await show_usage(room, bot) return model = custom_model or DEFAULT_MODEL - await generate_text(room, bot, prompt, model, temperature, max_tokens) except ValueError as e: @@ -101,7 +68,6 @@ async def handle_command(room, message, bot, prefix, config): await bot.api.send_text_message(room.room_id, f"Error processing command: {str(e)}") async def show_usage(room, bot): - """Display command usage information.""" usage = """ 📄 Infermatic Text Generation Usage: @@ -119,75 +85,57 @@ async def show_usage(room, bot): Examples:!text write a python function to calculate fibonacci!text --list-models -• !text --use-model llama-v3-8b-instruct explain quantum computing -• !text --temperature 0.7 write a haiku about AI """ await bot.api.send_markdown_message(room.room_id, usage) async def list_models(room, bot): - """List all available models from Infermatic AI.""" try: await bot.api.send_text_message(room.room_id, "🔍 Fetching available models...") - url = f"{INFERMATIC_API_BASE}/models" headers = { "Authorization": f"Bearer {INFERMATIC_API_KEY}", "Content-Type": "application/json" } + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers, timeout=30) as response: + response.raise_for_status() + data = await response.json() - response = requests.get(url, headers=headers, timeout=30) - response.raise_for_status() - - data = response.json() models = data.get('data', []) - if not models: - await bot.api.send_text_message(room.room_id, "No models found or error in response.") + await bot.api.send_text_message(room.room_id, "No models found.") return - # Format the model list output = "🔧 Available Models:

" - for model in models: - model_id = model.get('id', 'Unknown') - model_name = model.get('name', model_id) + model_id = html_escape(model.get('id', 'Unknown')) + model_name = html_escape(model.get('name', model_id)) context_length = model.get('context_length', 'Unknown') - pricing = model.get('pricing', {}) - output += f"• {model_name}
" output += f" └─ ID: {model_id}
" output += f" └─ Context: {context_length}
" - - if pricing: - prompt_price = pricing.get('prompt', '0') - completion_price = pricing.get('completion', '0') - output += f" └─ Price: ${prompt_price}/${completion_price} per 1M tokens
" - output += f" └─ Usage: !text --use-model {model_id} <prompt>

" - # Wrap in collapsible details since list can be long - output = f"
🔧 Available Models (Click to expand){output}
" + # Wrap in collapsible (from common) + from plugins.common import collapsible_summary + msg = collapsible_summary("🔧 Available Models (Click to expand)", output) + await bot.api.send_markdown_message(room.room_id, msg) - await bot.api.send_markdown_message(room.room_id, output) - - except requests.exceptions.RequestException as e: - await bot.api.send_text_message(room.room_id, f"❌ Error fetching models: {str(e)}") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"❌ API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {str(e)}") - -import re # add at the top of the file + await bot.api.send_text_message(room.room_id, f"❌ Error: {e}") async def generate_text(room, bot, prompt, model, temperature, max_tokens): - """Generate text using the Infermatic AI API.""" + safe_prompt = html_escape(prompt) + safe_model = html_escape(model) try: - await bot.api.send_text_message(room.room_id, f"📝 Generating text...") - + await bot.api.send_text_message(room.room_id, "📝 Generating text...") url = f"{INFERMATIC_API_BASE}/chat/completions" headers = { "Authorization": f"Bearer {INFERMATIC_API_KEY}", "Content-Type": "application/json" } - payload = { "model": model, "messages": [ @@ -197,49 +145,34 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens): "max_tokens": max_tokens } - response = requests.post(url, headers=headers, json=payload, timeout=120) - response.raise_for_status() + async with aiohttp.ClientSession() as session: + async with session.post(url, headers=headers, json=payload, timeout=120) as response: + response.raise_for_status() + data = await response.json() - data = response.json() generated_text = data.get('choices', [{}])[0].get('message', {}).get('content', '').strip() - if not generated_text: await bot.api.send_text_message(room.room_id, "No response generated.") return - # ---- Clean up blank lines that break list rendering ---- - # Remove blank lines directly before a list item (number‐dot or hyphen). + # Clean up blank lines that break list rendering generated_text = re.sub(r'\n\n(\d+\.)', r'\n\1', generated_text) generated_text = re.sub(r'\n\n(- )', r'\n\1', generated_text) - # Build a pure Markdown message (no HTML) - output = f"**Model:** `{model}`\n\n**Prompt:** {prompt}\n\n**Response:**\n\n{generated_text}" + # Escape any stray HTML inside the generated text before embedding + generated_text = html_escape(generated_text) + + output = f"Model: {safe_model}
Prompt: {safe_prompt}

Response:

{generated_text}" await bot.api.send_markdown_message(room.room_id, output) - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "❌ Request timed out. The model is taking too long to respond.") - except requests.exceptions.HTTPError as e: - if e.response.status_code == 401: - await bot.api.send_text_message(room.room_id, "❌ Authentication failed. Please check your INFERMATIC_API key.") - elif e.response.status_code == 429: - await bot.api.send_text_message(room.room_id, "❌ Rate limit exceeded. Please try again later.") - else: - await bot.api.send_text_message(room.room_id, f"❌ API error: HTTP {e.response.status_code}") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"❌ API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"❌ Error generating text: {str(e)}") - finally: - if not command_queue.empty(): - next_command = await command_queue.get() - await handle_command(*next_command) + await bot.api.send_text_message(room.room_id, f"❌ Error: {e}") - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.2" +__version__ = "1.0.3" __author__ = "Funguy Bot" -__description__ = "AI text generation via Infermatic API (pure Markdown output)" +__description__ = "AI text generation via Infermatic API (async, safe)" __help__ = """
!text – AI text generation (Infermatic) diff --git a/plugins/joke.py b/plugins/joke.py index a1bf4a7..f61bbda 100644 --- a/plugins/joke.py +++ b/plugins/joke.py @@ -1,93 +1,57 @@ """ Plugin for fetching jokes from the Official Joke API. """ -# plugins/joke.py - import logging -import simplematrixbotlib as botlib import aiohttp +import simplematrixbotlib as botlib async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !joke command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) - - # Handle !joke command if match.is_not_from_this_bot() and match.prefix() and match.command("joke"): args = match.args() - - # Check if user wants a specific category category = "general" if args: category = args[0].lower() - if category not in ["general", "programming"]: - # If invalid category, use general + if category not in ("general", "programming"): category = "general" logging.info(f"Fetching {category} joke") - try: - # Fetch joke from API if category == "programming": url = "https://official-joke-api.appspot.com/jokes/programming/random" else: url = "https://official-joke-api.appspot.com/random_joke" async with aiohttp.ClientSession() as session: - async with session.get(url) as response: + async with session.get(url, timeout=10) as response: if response.status == 200: data = await response.json() - - # Handle different response formats - if isinstance(data, list) and len(data) > 0: + if isinstance(data, list) and data: joke = data[0] elif isinstance(data, dict): joke = data else: - await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke right now.") + await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke.") return - # Extract joke parts - setup = joke.get("setup", "No setup available") - punchline = joke.get("punchline", "No punchline available") - - # Send the joke with a delay for better effect + setup = joke.get("setup", "No setup") + punchline = joke.get("punchline", "No punchline") await bot.api.send_text_message(room.room_id, setup) - # Add a small delay before the punchline for comedic timing import asyncio await asyncio.sleep(2) await bot.api.send_text_message(room.room_id, f"... {punchline}") else: - await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke right now. Try again later.") - + await bot.api.send_text_message(room.room_id, "Sorry, couldn't fetch a joke.") except Exception as e: logging.error(f"Error fetching joke: {e}") await bot.api.send_text_message(room.room_id, f"Error fetching joke: {str(e)}") - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "Get random jokes from the Official Joke API" __help__ = """
!joke – Random jokes -

Get random jokes from the Official Joke API.
-Usage: !joke for a general joke
-Usage: !joke programming for a programming joke

+

!joke for general, !joke programming for programming jokes.

-""" \ No newline at end of file +""" diff --git a/plugins/news.py b/plugins/news.py index 36c7784..5a3cdfd 100644 --- a/plugins/news.py +++ b/plugins/news.py @@ -3,40 +3,19 @@ News Aggregator Plugin for Funguy Bot Fetches latest headlines from various news categories using GNews API. Free tier: 100 requests/day - -Commands: - !news - Get top headlines (default) - !news top - Top headlines - !news world - World news - !news tech - Technology news - !news business - Business news - !news science - Science news - !news health - Health news - !news crypto - Cryptocurrency news - !news search - Search for specific news """ import logging import aiohttp import os -from typing import Optional, Dict, Any, List -from dotenv import load_dotenv +import simplematrixbotlib as botlib +from plugins.common import html_escape, collapsible_summary -# Load environment variables -load_dotenv() - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -# Get API key from environment variable +# API key loaded centrally GNEWS_API_KEY = os.getenv("GNEWS_API_KEY") -# Number of articles to return per command DEFAULT_ARTICLES = 5 MAX_ARTICLES = 10 - -# Category mapping CATEGORIES = { "top": "general", "world": "world", @@ -49,30 +28,15 @@ CATEGORIES = { "crypto": "cryptocurrency" } - -# --------------------------------------------------------------------------- -# Helper Functions -# --------------------------------------------------------------------------- - -def _format_collapsible(title: str, content: str, expanded: bool = False) -> str: - """Format content in a collapsible details/summary block.""" - open_attr = ' open' if expanded else '' - return f"\n📰 {title}\n\n{content}\n\n
" - - -def _format_news_article(article: Dict, index: int) -> str: +def _format_news_article(article, index): """Format a single news article as an HTML list item.""" - title = article.get("title", "No title") - source = article.get("source", {}).get("name", "Unknown source") + title = html_escape(article.get("title", "No title")) + source = html_escape((article.get("source") or {}).get("name", "Unknown")) url = article.get("url", "#") - description = article.get("description", "No description available") - published = article.get("publishedAt", "") - - # Truncate description if too long + description = html_escape(article.get("description", "No description available")) if len(description) > 300: description = description[:297] + "..." - - # Format date if available + published = article.get("publishedAt", "") date_str = "" if published: try: @@ -81,7 +45,6 @@ def _format_news_article(article: Dict, index: int) -> str: date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}" except: pass - return ( f"
  • \n" f"{index}. {title}
    \n" @@ -91,17 +54,13 @@ def _format_news_article(article: Dict, index: int) -> str: f"
  • " ) - -async def _fetch_news(category: str = "general", query: str = None, limit: int = DEFAULT_ARTICLES) -> Optional[List[Dict]]: - """Fetch news articles from GNews API.""" +async def _fetch_news(category="general", query=None, limit=DEFAULT_ARTICLES): if not GNEWS_API_KEY: logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file") return None base_url = "https://gnews.io/api/v4" - if query: - # Search endpoint url = f"{base_url}/search" params = { "q": query, @@ -111,7 +70,6 @@ async def _fetch_news(category: str = "general", query: str = None, limit: int = "country": "us" } else: - # Top headlines endpoint url = f"{base_url}/top-headlines" params = { "apikey": GNEWS_API_KEY, @@ -135,26 +93,15 @@ async def _fetch_news(category: str = "general", query: str = None, limit: int = logging.error(f"Error fetching news: {e}") return None - -# --------------------------------------------------------------------------- -# Plugin Setup -# --------------------------------------------------------------------------- - def setup(bot): """Initialize plugin with bot instance.""" global GNEWS_API_KEY GNEWS_API_KEY = os.getenv("GNEWS_API_KEY") - if GNEWS_API_KEY: logging.info("News plugin loaded with API key") else: logging.warning("News plugin loaded but GNEWS_API_KEY not set in .env file") - -# --------------------------------------------------------------------------- -# Command Handler -# --------------------------------------------------------------------------- - async def handle_command(room, message, bot, prefix, config): """Handle !news commands.""" import simplematrixbotlib as botlib @@ -201,9 +148,10 @@ async def handle_command(room, message, bot, prefix, config): # Fetch news if query: - await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{query}*...") + safe_title = html_escape(query) + await bot.api.send_text_message(room.room_id, f"🔍 Searching for: *{safe_title}*...") articles = await _fetch_news(query=query, limit=limit) - title = f"Search Results: '{query}'" + title = f"Search Results: '{safe_title}'" else: articles = await _fetch_news(category=category, limit=limit) category_name = next((k for k, v in CATEGORIES.items() if v == category), category) @@ -220,11 +168,10 @@ async def handle_command(room, message, bot, prefix, config): content += f"\n\nFetched {len(articles[:limit])} articles" # Format as collapsible and send - response = _format_collapsible(title, content, expanded=False) + response = collapsible_summary(title, content) await bot.api.send_markdown_message(room.room_id, response) logging.info(f"Sent news to {room.room_id}: category={category}, query={query}") - # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- diff --git a/plugins/proxy.py b/plugins/proxy.py index bd2623b..19ec94d 100644 --- a/plugins/proxy.py +++ b/plugins/proxy.py @@ -1,46 +1,41 @@ """ This plugin provides a command to get random SOCKS5 proxies. """ - import os import logging import random -import requests +import aiohttp import socket import time from datetime import datetime, timedelta import concurrent.futures +import asyncio import simplematrixbotlib as botlib import sqlite3 -import ipaddress - -from plugins.utils import is_public_destination +from plugins.common import is_public_destination, html_escape SOCKS5_LIST_URL = 'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/socks5.txt' MAX_TRIES = 64 PROXY_LIST_FILENAME = 'socks5.txt' PROXY_LIST_EXPIRATION = timedelta(hours=8) -MAX_THREADS = 128 +MAX_THREADS = 64 # lowered to avoid resource exhaustion PROXIES_DB_FILE = 'proxies.db' MAX_PROXIES_IN_DB = 10 -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - def test_proxy(proxy): - """Test a SOCKS5 proxy and return the outcome.""" + """Test a SOCKS5 proxy and return (success, proxy, latency).""" try: ip, port = proxy.split(':') - logging.info(f"Testing SOCKS5 proxy: {ip}:{port}") start_time = time.time() with socket.create_connection((ip, int(port)), timeout=12) as client: client.sendall(b'\x05\x01\x00') response = client.recv(2) if response == b'\x05\x00': - latency = int(round((time.time() - start_time) * 1000, 0)) + latency = int(round((time.time() - start_time) * 1000)) return True, proxy, latency else: return False, proxy, None - except Exception as e: + except Exception: return False, proxy, None async def download_proxy_list(): @@ -48,13 +43,15 @@ async def download_proxy_list(): if not os.path.exists(PROXY_LIST_FILENAME) or \ datetime.now() - datetime.fromtimestamp(os.path.getctime(PROXY_LIST_FILENAME)) > PROXY_LIST_EXPIRATION: logging.info("Downloading SOCKS5 proxy list") - response = requests.get(SOCKS5_LIST_URL, timeout=5) - with open(PROXY_LIST_FILENAME, 'w') as f: - f.write(response.text) - logging.info("Proxy list downloaded successfully") + async with aiohttp.ClientSession() as session: + async with session.get(SOCKS5_LIST_URL, timeout=20) as response: + response.raise_for_status() + text = await response.text() + with open(PROXY_LIST_FILENAME, 'w') as f: + f.write(text) + logging.info("Proxy list downloaded") return True else: - logging.info("Proxy list already exists and is up-to-date") return True except Exception as e: logging.error(f"Error downloading proxy list: {e}") @@ -64,48 +61,39 @@ def check_db_for_proxy(): try: with sqlite3.connect(PROXIES_DB_FILE) as conn: cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS proxies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - proxy TEXT, - latency INTEGER, - status TEXT - ) - """) - cursor.execute("SELECT proxy, latency FROM proxies WHERE status = 'working' AND latency < 3000 ORDER BY RANDOM() LIMIT 1") - result = cursor.fetchone() - if result: - proxy, latency = result + cursor.execute("""CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy TEXT, + latency INTEGER, + status TEXT)""") + cursor.execute("SELECT proxy, latency FROM proxies WHERE status='working' AND latency<3000 ORDER BY RANDOM() LIMIT 1") + row = cursor.fetchone() + if row: + proxy, latency = row success, _, _ = test_proxy(proxy) if success: return proxy, latency else: - cursor.execute("DELETE FROM proxies WHERE proxy = ?", (proxy,)) + cursor.execute("DELETE FROM proxies WHERE proxy=?", (proxy,)) conn.commit() - logging.info(f"Removed non-working proxy from the database: {proxy}") - return None, None - else: - return None, None + return None, None except Exception as e: - logging.error(f"Error checking proxies database: {e}") + logging.error(f"DB error: {e}") return None, None def save_proxy_to_db(proxy, latency): try: with sqlite3.connect(PROXIES_DB_FILE) as conn: cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS proxies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - proxy TEXT, - latency INTEGER, - status TEXT - ) - """) - cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?, ?, 'working')", (proxy, latency)) + cursor.execute("""CREATE TABLE IF NOT EXISTS proxies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + proxy TEXT, + latency INTEGER, + status TEXT)""") + cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?,?,'working')", (proxy, latency)) conn.commit() except Exception as e: - logging.error(f"Error saving proxy to database: {e}") + logging.error(f"Error saving proxy: {e}") async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) @@ -113,52 +101,47 @@ async def handle_command(room, message, bot, prefix, config): logging.info("Received !proxy command") working_proxy, latency = check_db_for_proxy() if working_proxy: + safe_proxy = html_escape(working_proxy) await bot.api.send_markdown_message(room.room_id, - f"✅ Using cached working SOCKS5 Proxy: **{working_proxy}** - Latency: **{latency} ms**") + f"✅ Using cached working SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**") return - else: - if not await download_proxy_list(): - await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list") - return - try: - with open(PROXY_LIST_FILENAME, 'r') as f: - socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()] - # Filter out private/internal proxies before testing - socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])] - random.shuffle(socks5_proxies) - tested_proxies = 0 - with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: - futures = [] - for proxy in socks5_proxies[:MAX_TRIES]: - futures.append(executor.submit(test_proxy, proxy)) - for future in concurrent.futures.as_completed(futures): - success, proxy, latency = future.result() - if success: - await bot.api.send_markdown_message(room.room_id, - f"✅ Anonymous SOCKS5 Proxy: **{proxy}** - Latency: **{latency} ms**") - save_proxy_to_db(proxy, latency) - tested_proxies += 1 - if tested_proxies >= MAX_PROXIES_IN_DB: - break - working_proxy, latency = check_db_for_proxy() - if working_proxy: - await bot.api.send_markdown_message(room.room_id, - f"✅ Using cached working SOCKS5 Proxy: **{working_proxy}** - Latency: **{latency} ms**") - else: - await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found") - except Exception as e: - logging.error(f"Error handling !proxy command: {e}") - await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command") -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- -__version__ = "1.0.1" + if not await download_proxy_list(): + await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list") + return + + try: + with open(PROXY_LIST_FILENAME, 'r') as f: + socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()] + socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])] + random.shuffle(socks5_proxies) + + loop = asyncio.get_running_loop() + tested = 0 + with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor: + futures = [loop.run_in_executor(executor, test_proxy, proxy) for proxy in socks5_proxies[:MAX_TRIES]] + for future in asyncio.as_completed(futures): + success, proxy, latency = await future + if success: + safe_proxy = html_escape(proxy) + await bot.api.send_markdown_message(room.room_id, + f"✅ Anonymous SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**") + save_proxy_to_db(proxy, latency) + tested += 1 + if tested >= MAX_PROXIES_IN_DB: + break + if tested == 0: + await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found") + except Exception as e: + logging.error(f"Error handling !proxy command: {e}") + await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command") + +__version__ = "1.0.2" __author__ = "Funguy Bot" -__description__ = "Working SOCKS5 proxy finder (SSRF‑safe)" +__description__ = "Working SOCKS5 proxy finder (SSRF‑safe, async)" __help__ = """
    !proxy – Random working SOCKS5 proxy -

    Fetches, tests, and returns a random working SOCKS5 proxy with latency. Caches good proxies in SQLite.

    +

    Fetches, tests, and returns a random working SOCKS5 proxy with latency.

    """ diff --git a/plugins/quote.py b/plugins/quote.py index 242ba28..d39b083 100644 --- a/plugins/quote.py +++ b/plugins/quote.py @@ -1,26 +1,19 @@ """ Goodreads Quote Scraper – Playwright (headless Chromium) -No external APIs, no keys; scrapes directly from goodreads.com """ - import logging import random import re import asyncio import simplematrixbotlib as botlib from bs4 import BeautifulSoup -from urllib.parse import urlencode - -logger = logging.getLogger("quote") +from plugins.common import html_escape, collapsible_summary GR_POPULAR = "https://www.goodreads.com/quotes" GR_SEARCH = "https://www.goodreads.com/quotes/search" QUOTES_PER_PAGE = 30 MAX_SEARCH_PAGES = 3 -# --------------------------------------------------------------------------- -# Playwright browser (shared, launched once) -# --------------------------------------------------------------------------- _browser = None _playwright = None @@ -30,64 +23,32 @@ async def _get_browser(): from playwright.async_api import async_playwright _playwright = await async_playwright().start() _browser = await _playwright.chromium.launch(headless=True) - logger.info("Playwright browser started") + logging.info("Playwright browser started") return _browser -async def _close_browser(): - global _browser, _playwright - if _browser: - await _browser.close() - _browser = None - if _playwright: - await _playwright.stop() - _playwright = None - -# --------------------------------------------------------------------------- -# HTML parsing (Goodreads specific) -# --------------------------------------------------------------------------- -def _extract_quotes(html: str) -> list[dict]: - """Parse Goodreads HTML and return a list of {content, author} dicts.""" +def _extract_quotes(html: str) -> list: soup = BeautifulSoup(html, "lxml") quotes = [] - for div in soup.find_all("div", class_="quoteText"): full_text = div.get_text(" ", strip=True) - # Try curly quotes m = re.search(r"“(.+?)”", full_text) if not m: m = re.search(r"(.+?)\s*―", full_text) if not m: continue content = m.group(1).strip() - author_span = div.find("span", class_="authorOrTitle") author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown" quotes.append({"content": content, "author": author}) - - # Alternative layout (if first method yielded nothing) - for div in soup.find_all("div", class_="quoteDetails"): - text_elem = div.find("div", class_="quoteText") - author_elem = div.find("span", class_="authorOrTitle") - if text_elem: - content = text_elem.get_text(strip=True).strip("“”") - else: - continue - author = author_elem.get_text(strip=True).rstrip(",") if author_elem else "Unknown" - quotes.append({"content": content, "author": author}) - return quotes -# --------------------------------------------------------------------------- -# Page fetching -# --------------------------------------------------------------------------- async def _scrape(url: str, params: dict = None) -> str: browser = await _get_browser() - context = await browser.new_context( - user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" - ) + context = await browser.new_context(user_agent="Mozilla/5.0 ...") page = await context.new_page() try: if params: + from urllib.parse import urlencode full_url = f"{url}?{urlencode(params)}" else: full_url = url @@ -95,17 +56,17 @@ async def _scrape(url: str, params: dict = None) -> str: html = await page.content() return html except Exception as e: - logger.error(f"Failed to load {full_url}: {e}") + logging.error(f"Scrape error: {e}") return "" finally: await page.close() await context.close() -async def get_random_popular() -> list[dict]: +async def get_random_popular() -> list: html = await _scrape(GR_POPULAR) return _extract_quotes(html) -async def get_author_quotes(author: str) -> list[dict]: +async def get_author_quotes(author: str) -> list: all_quotes = [] for page in range(1, MAX_SEARCH_PAGES + 1): html = await _scrape(GR_SEARCH, {"q": author, "commit": "Search", "page": page}) @@ -115,52 +76,32 @@ async def get_author_quotes(author: str) -> list[dict]: break return all_quotes -# --------------------------------------------------------------------------- -# Formatting -# --------------------------------------------------------------------------- -def format_quote(q: dict) -> str: - return f'"{q["content"]}"\n\n— {q["author"]}' +def format_quote(q): + safe_content = html_escape(q["content"]) + safe_author = html_escape(q["author"]) + return f'"{safe_content}"\n\n— {safe_author}' -# --------------------------------------------------------------------------- -# Command handler -# --------------------------------------------------------------------------- async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")): return args = match.args() - - # Help if args and args[0].lower() in ("help", "-h", "--help"): - help_html = ( - "
    📖 !quote help" - "
      " - "
    • !quote – random popular quote from Goodreads
    • " - "
    • !quote <author> – random quote by that author
    • " - "
    • !quote help – this
    • " - "
    " - "

    Examples:
    !quote
    " - "!quote Terence McKenna
    " - "!quote Oscar Wilde

    " - "

    Scraped with Playwright (headless browser).

    " - "
    " - ) + help_html = collapsible_summary("📖 !quote help", + "
    • !quote – random popular quote
    • " + "
    • !quote <author> – quote by author
    ") await bot.api.send_markdown_message(room.room_id, help_html) return try: if args: author = " ".join(args).strip() - await bot.api.send_text_message( - room.room_id, f"🔍 Searching Goodreads for quotes by **{author}**…" - ) + safe_author = html_escape(author) + await bot.api.send_text_message(room.room_id, f"🔍 Searching Goodreads for quotes by **{safe_author}**…") quotes = await get_author_quotes(author) if not quotes: - await bot.api.send_text_message( - room.room_id, - f"❌ No quotes found for '**{author}**'. Try a different spelling." - ) + await bot.api.send_text_message(room.room_id, f"❌ No quotes found for '{safe_author}'.") return chosen = random.choice(quotes) else: @@ -172,28 +113,13 @@ async def handle_command(room, message, bot, prefix, config): chosen = random.choice(quotes) await bot.api.send_markdown_message(room.room_id, format_quote(chosen)) - logger.info(f"Quote sent: {chosen['author']}") - + logging.info(f"Quote sent: {chosen['author']}") except Exception as e: - logger.exception("Unexpected error in quote plugin") - await bot.api.send_text_message( - room.room_id, f"❌ Scraping error: {e}" - ) + logging.exception("Unexpected error in quote plugin") + await bot.api.send_text_message(room.room_id, f"❌ Scraping error: {e}") -# --------------------------------------------------------------------------- -# Plugin metadata -# --------------------------------------------------------------------------- -__version__ = "1.0.1" +__version__ = "1.0.2" __author__ = "Funguy Bot" -__description__ = "Goodreads quotes via Playwright (headless browser)" -__help__ = """ -
    -!quote – Quotes from Goodreads (scraped with Playwright) -
      -
    • !quote – random popular quote
    • -
    • !quote <author> – random quote by that author
    • -
    • !quote help
    • -
    -

    No API keys, no JSON files – just a real browser fetching from Goodreads.

    -
    -""" +__description__ = "Goodreads quotes via Playwright (headless)" +__help__ = """
    !quote – Quotes from Goodreads +

    !quote random, !quote <author>.

    """ diff --git a/plugins/shodan.py b/plugins/shodan.py index 2a64d82..dc19ac4 100644 --- a/plugins/shodan.py +++ b/plugins/shodan.py @@ -4,15 +4,9 @@ This plugin provides Shodan.io integration for security research and reconnaissa import logging import os -import requests +import aiohttp import simplematrixbotlib as botlib -from dotenv import load_dotenv - -# Load environment variables from .env file -plugin_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(plugin_dir) -dotenv_path = os.path.join(parent_dir, '.env') -load_dotenv(dotenv_path) +from plugins.common import html_escape, collapsible_summary SHODAN_API_KEY = os.getenv("SHODAN_KEY", "") SHODAN_API_BASE = "https://api.shodan.io" @@ -20,16 +14,6 @@ SHODAN_API_BASE = "https://api.shodan.io" async def handle_command(room, message, bot, prefix, config): """ Function to handle Shodan commands. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"): @@ -104,35 +88,33 @@ async def show_usage(room, bot): async def shodan_ip_lookup(room, bot, ip): """Look up information about a specific IP address.""" try: - url = f"{SHODAN_API_BASE}/shodan/host/{ip}" - params = {"key": SHODAN_API_KEY} - + url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}" logging.info(f"Fetching Shodan IP info for: {ip}") - response = requests.get(url, params=params, timeout=15) + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=15) as response: + if response.status == 404: + await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}") + return + elif response.status == 401: + await bot.api.send_text_message(room.room_id, "Invalid Shodan API key") + return + elif response.status != 200: + await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}") + return - if response.status_code == 404: - await bot.api.send_text_message(room.room_id, f"No information found for IP: {ip}") - return - elif response.status_code == 401: - await bot.api.send_text_message(room.room_id, "Invalid Shodan API key") - return - elif response.status_code != 200: - await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status_code}") - return - - data = response.json() + data = await response.json() # Format the response - output = f"🔍 Shodan IP Lookup: {ip}

    " + output = f"🔍 Shodan IP Lookup: {html_escape(ip)}

    " if data.get('country_name'): - output += f"📍 Location: {data.get('city', 'N/A')}, {data.get('country_name', 'N/A')}
    " + output += f"📍 Location: {html_escape(data.get('city', 'N/A'))}, {html_escape(data.get('country_name', 'N/A'))}
    " if data.get('org'): - output += f"🏢 Organization: {data['org']}
    " + output += f"🏢 Organization: {html_escape(data['org'])}
    " if data.get('os'): - output += f"💻 Operating System: {data['os']}
    " + output += f"💻 Operating System: {html_escape(data['os'])}
    " if data.get('ports'): output += f"🔌 Open Ports: {', '.join(map(str, data['ports']))}
    " @@ -148,25 +130,25 @@ async def shodan_ip_lookup(room, bot, ip): version = service.get('version', '') banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '') - output += f" • Port {port}: {product} {version}
    " + output += f" • Port {port}: {html_escape(product)} {html_escape(version)}
    " if banner: - output += f" {banner}
    " + output += f" {html_escape(banner)}
    " if len(data['data']) > 5: output += f" • ... and {len(data['data']) - 5} more services
    " # Wrap in collapsible if output is large if len(output) > 500: - output = f"
    🔍 Shodan IP Lookup: {ip}{output}
    " + output = collapsible_summary(f"🔍 Shodan IP Lookup: {html_escape(ip)}", output) await bot.api.send_markdown_message(room.room_id, output) logging.info(f"Sent Shodan IP info for {ip}") - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "Shodan API request timed out") - logging.error("Shodan API timeout") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}") + logging.error(f"Shodan API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {str(e)}") + await bot.api.send_text_message(room.room_id, f"Error: {str(e)}") logging.error(f"Error in shodan_ip_lookup: {e}") async def shodan_search(room, bot, query): @@ -176,24 +158,22 @@ async def shodan_search(room, bot, query): params = { "key": SHODAN_API_KEY, "query": query, - "minify": True, - "limit": 5 # Limit results to avoid huge responses + "minify": "true", + "limit": 5 } - logging.info(f"Searching Shodan for: {query}") - response = requests.get(url, params=params, timeout=15) - - if response.status_code != 200: - await handle_shodan_error(room, bot, response.status_code) - return - - data = response.json() + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=15) as response: + if response.status != 200: + await handle_shodan_error(room, bot, response.status) + return + data = await response.json() if not data.get('matches'): - await bot.api.send_text_message(room.room_id, f"No results found for: {query}") + await bot.api.send_text_message(room.room_id, f"No results found for: {html_escape(query)}") return - output = f"🔍 Shodan Search: '{query}'
    " + output = f"🔍 Shodan Search: '{html_escape(query)}'
    " output += f"Total Results: {data.get('total', 0):,}

    " for match in data['matches'][:5]: # Show first 5 results @@ -202,14 +182,14 @@ async def shodan_search(room, bot, query): org = match.get('org', 'Unknown') product = match.get('product', 'Unknown') - output += f"🌐 {ip}:{port}
    " - output += f" • Organization: {org}
    " - output += f" • Service: {product}
    " + output += f"🌐 {html_escape(ip)}:{port}
    " + output += f" • Organization: {html_escape(org)}
    " + output += f" • Service: {html_escape(product)}
    " if match.get('location'): loc = match['location'] if loc.get('city') and loc.get('country_name'): - output += f" • Location: {loc['city']}, {loc['country_name']}
    " + output += f" • Location: {html_escape(loc['city'])}, {html_escape(loc['country_name'])}
    " output += "
    " @@ -219,44 +199,41 @@ async def shodan_search(room, bot, query): await bot.api.send_markdown_message(room.room_id, output) logging.info(f"Sent Shodan search results for: {query}") - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "Shodan API request timed out") - logging.error("Shodan API timeout") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}") + logging.error(f"Shodan API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {str(e)}") + await bot.api.send_text_message(room.room_id, f"Error: {str(e)}") logging.error(f"Error in shodan_search: {e}") async def shodan_host(room, bot, host): """Get host information (domain or IP).""" try: - url = f"{SHODAN_API_BASE}/dns/domain/{host}" - params = {"key": SHODAN_API_KEY} - + url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}" logging.info(f"Fetching Shodan host info for: {host}") - response = requests.get(url, params=params, timeout=15) + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=15) as response: + if response.status == 404: + # Try IP lookup instead + await shodan_ip_lookup(room, bot, host) + return + elif response.status != 200: + await handle_shodan_error(room, bot, response.status) + return + data = await response.json() - if response.status_code == 404: - # Try IP lookup instead - await shodan_ip_lookup(room, bot, host) - return - elif response.status_code != 200: - await handle_shodan_error(room, bot, response.status_code) - return - - data = response.json() - - output = f"🔍 Shodan Host: {host}

    " + output = f"🔍 Shodan Host: {html_escape(host)}

    " if data.get('subdomains'): output += f"🌐 Subdomains ({len(data['subdomains'])}):
    " for subdomain in sorted(data['subdomains'])[:10]: # Show first 10 - output += f" • {subdomain}.{host}
    " + output += f" • {html_escape(subdomain)}.{html_escape(host)}
    " if len(data['subdomains']) > 10: output += f" • ... and {len(data['subdomains']) - 10} more
    " if data.get('tags'): - output += f"
    🏷️ Tags: {', '.join(data['tags'])}
    " + output += f"
    🏷️ Tags: {', '.join(html_escape(t) for t in data['tags'])}
    " if data.get('data'): output += f"
    📊 Records Found: {len(data['data'])}
    " @@ -264,11 +241,11 @@ async def shodan_host(room, bot, host): await bot.api.send_markdown_message(room.room_id, output) logging.info(f"Sent Shodan host info for: {host}") - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "Shodan API request timed out") - logging.error("Shodan API timeout") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}") + logging.error(f"Shodan API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error fetching host info: {str(e)}") + await bot.api.send_text_message(room.room_id, f"Error: {str(e)}") logging.error(f"Error in shodan_host: {e}") async def shodan_count(room, bot, query): @@ -279,39 +256,37 @@ async def shodan_count(room, bot, query): "key": SHODAN_API_KEY, "query": query } - logging.info(f"Counting Shodan results for: {query}") - response = requests.get(url, params=params, timeout=15) + async with aiohttp.ClientSession() as session: + async with session.get(url, params=params, timeout=15) as response: + if response.status != 200: + await handle_shodan_error(room, bot, response.status) + return + data = await response.json() - if response.status_code != 200: - await handle_shodan_error(room, bot, response.status_code) - return - - data = response.json() - - output = f"🔍 Shodan Count: '{query}'

    " + output = f"🔍 Shodan Count: '{html_escape(query)}'

    " output += f"Total Results: {data.get('total', 0):,}
    " # Show top countries if available if data.get('facets') and 'country' in data['facets']: output += "
    🌍 Top Countries:
    " for country in data['facets']['country'][:5]: - output += f" • {country['value']}: {country['count']:,}
    " + output += f" • {html_escape(country['value'])}: {country['count']:,}
    " # Show top organizations if available if data.get('facets') and 'org' in data['facets']: output += "
    🏢 Top Organizations:
    " for org in data['facets']['org'][:5]: - output += f" • {org['value']}: {org['count']:,}
    " + output += f" • {html_escape(org['value'])}: {org['count']:,}
    " await bot.api.send_markdown_message(room.room_id, output) logging.info(f"Sent Shodan count for: {query}") - except requests.exceptions.Timeout: - await bot.api.send_text_message(room.room_id, "Shodan API request timed out") - logging.error("Shodan API timeout") + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}") + logging.error(f"Shodan API error: {e}") except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {str(e)}") + await bot.api.send_text_message(room.room_id, f"Error: {str(e)}") logging.error(f"Error in shodan_count: {e}") async def handle_shodan_error(room, bot, status_code): @@ -324,7 +299,6 @@ async def handle_shodan_error(room, bot, status_code): 500: "Shodan API server error", 503: "Shodan API temporarily unavailable" } - message = error_messages.get(status_code, f"Shodan API error: {status_code}") await bot.api.send_text_message(room.room_id, message) logging.error(f"Shodan API error: {status_code}") @@ -333,7 +307,7 @@ async def handle_shodan_error(room, bot, status_code): # Plugin Metadata # --------------------------------------------------------------------------- -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" __description__ = "Shodan.io reconnaissance" __help__ = """ @@ -345,6 +319,13 @@ __help__ = """
  • !shodan host <domain> – Host & subdomain enumeration
  • !shodan count <query> – Result counts
  • +Search Examples: +
      +
    • !shodan search apache
    • +
    • !shodan search "port:22"
    • +
    • !shodan search "country:US product:nginx"
    • +
    • !shodan search "net:192.168.1.0/24"
    • +

    Requires SHODAN_KEY env var.

    """ diff --git a/plugins/sslscan.py b/plugins/sslscan.py index 7312d75..29ca8de 100644 --- a/plugins/sslscan.py +++ b/plugins/sslscan.py @@ -1,84 +1,48 @@ """ -This plugin provides comprehensive SSL/TLS security scanning and analysis. +Comprehensive SSL/TLS security scanning and analysis. +All blocking socket calls run in a thread pool; user input is sanitised. """ +import asyncio import logging import socket import ssl import OpenSSL import datetime -import re import simplematrixbotlib as botlib -from urllib.parse import urlparse +from plugins.common import is_public_destination, html_escape, collapsible_summary -from plugins.utils import is_public_destination - -# SSL/TLS configuration - handle missing protocols in modern Python +# SSL/TLS configuration – handle missing protocols in modern Python TLS_VERSIONS = { 'TLSv1.2': ssl.PROTOCOL_TLSv1_2, 'TLSv1.3': ssl.PROTOCOL_TLS } - -# Try to add older protocols if available (they're removed in modern Python) try: TLS_VERSIONS['TLSv1.1'] = ssl.PROTOCOL_TLSv1_1 except AttributeError: pass - try: TLS_VERSIONS['TLSv1'] = ssl.PROTOCOL_TLSv1 except AttributeError: pass -# Cipher suites by strength and category CIPHER_CATEGORIES = { 'STRONG': [ - 'TLS_AES_256_GCM_SHA384', - 'TLS_CHACHA20_POLY1305_SHA256', - 'TLS_AES_128_GCM_SHA256', - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-CHACHA20-POLY1305', - 'ECDHE-ECDSA-CHACHA20-POLY1305', - 'DHE-RSA-AES256-GCM-SHA384' + 'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', + 'TLS_AES_128_GCM_SHA256', 'ECDHE-RSA-AES256-GCM-SHA384', + 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-CHACHA20-POLY1305', + 'ECDHE-ECDSA-CHACHA20-POLY1305', 'DHE-RSA-AES256-GCM-SHA384' ], - 'WEAK': [ - 'RC4', - 'DES', - '3DES', - 'MD5', - 'EXPORT', - 'NULL', - 'ANON', - 'ADH', - 'CBC' - ], - 'OBSOLETE': [ - 'SSLv2', - 'SSLv3' - ] + 'WEAK': ['RC4', 'DES', '3DES', 'MD5', 'EXPORT', 'NULL', 'ANON', 'ADH', 'CBC'], } async def handle_command(room, message, bot, prefix, config): """ - Function to handle !sslscan command for comprehensive SSL/TLS analysis. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None + Handle !sslscan command for comprehensive SSL/TLS analysis. """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"): - logging.info("Received !sslscan command") - args = match.args() - if len(args) < 1: await show_usage(room, bot) return @@ -86,7 +50,6 @@ async def handle_command(room, message, bot, prefix, config): target = args[0].strip() port = 443 - # Parse port if provided if ':' in target: parts = target.split(':') target = parts[0] @@ -96,16 +59,13 @@ async def handle_command(room, message, bot, prefix, config): await bot.api.send_text_message(room.room_id, "Invalid port number") return - # SSRF protection: refuse internal hosts if not is_public_destination(target): - await bot.api.send_text_message( - room.room_id, - "❌ Scanning of private/internal addresses is not allowed." - ) + await bot.api.send_text_message(room.room_id, "❌ Scanning of private/internal addresses is not allowed.") return await perform_ssl_scan(room, bot, target, port) + async def show_usage(room, bot): """Display sslscan command usage.""" usage = """ @@ -116,7 +76,6 @@ async def show_usage(room, bot): Examples:!sslscan example.com!sslscan github.com:443 -• !sslscan localhost:8443 Tests Performed: • SSL/TLS protocol support and versions @@ -129,488 +88,360 @@ async def show_usage(room, bot): """ await bot.api.send_markdown_message(room.room_id, usage) -async def perform_ssl_scan(room, bot, target, port): - """Perform comprehensive SSL/TLS security scan.""" + +# ----- async wrappers for blocking socket calls ----- +async def _run_blocking(func, *args, **kwargs): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) + + +def _test_connectivity(target, port): + """Test basic connectivity.""" try: - await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {target}:{port}...") - - scan_results = { - 'target': target, - 'port': port, - 'certificate': {}, - 'protocols': {}, - 'ciphers': {}, - 'vulnerabilities': [], - 'recommendations': [], - 'security_score': 0 - } - - # Test basic connectivity - if not await test_connectivity(target, port): - await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {target}:{port}") - return - - # Perform comprehensive tests - await get_certificate_info(scan_results, target, port) - await test_protocol_support(scan_results, target, port) - await test_cipher_suites(scan_results, target, port) - await check_vulnerabilities(scan_results) - await calculate_security_score(scan_results) - await generate_recommendations(scan_results) - - # Format and send results - output = await format_ssl_scan_results(scan_results) - await bot.api.send_markdown_message(room.room_id, output) - - logging.info(f"Completed SSL scan for {target}:{port}") - - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error performing SSL scan: {str(e)}") - logging.error(f"Error in perform_ssl_scan: {e}") - -async def test_connectivity(target, port): - """Test basic connectivity to the target.""" - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - result = sock.connect_ex((target, port)) - sock.close() - return result == 0 + with socket.create_connection((target, port), timeout=10): + return True except: return False -async def get_certificate_info(scan_results, target, port): - """Get comprehensive certificate information.""" - try: - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - with socket.create_connection((target, port), timeout=10) as sock: - with context.wrap_socket(sock, server_hostname=target) as ssock: - cert_bin = ssock.getpeercert(binary_form=True) - cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin) +def _get_certificate_info(target, port): + """Retrieve detailed certificate info.""" + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE - # Basic certificate info - subject = cert.get_subject() - issuer = cert.get_issuer() + with socket.create_connection((target, port), timeout=10) as sock: + with context.wrap_socket(sock, server_hostname=target) as ssock: + cert_bin = ssock.getpeercert(binary_form=True) + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin) - scan_results['certificate'] = { - 'subject': { - 'common_name': subject.CN, - 'organization': subject.O, - 'organizational_unit': subject.OU, - 'country': subject.C, - 'state': subject.ST, - 'locality': subject.L - }, - 'issuer': { - 'common_name': issuer.CN, - 'organization': issuer.O, - 'organizational_unit': issuer.OU - }, - 'serial_number': cert.get_serial_number(), - 'version': cert.get_version(), - 'not_before': cert.get_notBefore().decode('utf-8'), - 'not_after': cert.get_notAfter().decode('utf-8'), - 'signature_algorithm': cert.get_signature_algorithm().decode('utf-8'), - 'extensions': [] - } + subject = cert.get_subject() + issuer = cert.get_issuer() - # Parse extensions - for i in range(cert.get_extension_count()): - ext = cert.get_extension(i) - scan_results['certificate']['extensions'].append({ - 'name': ext.get_short_name().decode('utf-8'), - 'value': str(ext) - }) + not_before = cert.get_notBefore().decode('utf-8') + not_after = cert.get_notAfter().decode('utf-8') + sig_alg = cert.get_signature_algorithm().decode('utf-8') - # Calculate days until expiration - not_after = datetime.datetime.strptime(scan_results['certificate']['not_after'], '%Y%m%d%H%M%SZ') - days_until_expiry = (not_after - datetime.datetime.utcnow()).days - scan_results['certificate']['days_until_expiry'] = days_until_expiry + not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ') + days_remaining = (not_after_dt - datetime.datetime.utcnow()).days - except Exception as e: - scan_results['certificate_error'] = str(e) + # Extensions summary + extensions = [] + for i in range(cert.get_extension_count()): + ext = cert.get_extension(i) + extensions.append({ + 'name': ext.get_short_name().decode('utf-8'), + 'value': str(ext) + }) -async def test_protocol_support(scan_results, target, port): + return { + 'subject': { + 'common_name': subject.CN, + 'organization': subject.O, + 'organizational_unit': subject.OU, + 'country': subject.C, + 'state': subject.ST, + 'locality': subject.L + }, + 'issuer': { + 'common_name': issuer.CN, + 'organization': issuer.O, + 'organizational_unit': issuer.OU + }, + 'serial_number': cert.get_serial_number(), + 'version': cert.get_version(), + 'not_before': not_before, + 'not_after': not_after, + 'signature_algorithm': sig_alg, + 'days_until_expiry': days_remaining, + 'extensions': extensions + } + return None + + +def _test_protocols(target, port): """Test support for various SSL/TLS protocols.""" - protocols = { - 'SSLv2': False, - 'SSLv3': False, - 'TLSv1': False, - 'TLSv1.1': False, - 'TLSv1.2': False, - 'TLSv1.3': False - } - - # Test available protocols - for protocol_name in protocols.keys(): + protocols = {} + for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']: + if proto_name not in TLS_VERSIONS: + protocols[proto_name] = False + continue try: - if protocol_name in TLS_VERSIONS: - context = ssl.SSLContext(TLS_VERSIONS[protocol_name]) - else: - # For protocols not available in this Python version, assume False - protocols[protocol_name] = False - continue - - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - + ctx = ssl.SSLContext(TLS_VERSIONS[proto_name]) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE with socket.create_connection((target, port), timeout=5) as sock: - with context.wrap_socket(sock, server_hostname=target) as ssock: - protocols[protocol_name] = True - # Get negotiated protocol - if hasattr(ssock, 'version'): - scan_results['negotiated_protocol'] = ssock.version() + with ctx.wrap_socket(sock, server_hostname=target): + protocols[proto_name] = True except: - protocols[protocol_name] = False + protocols[proto_name] = False + return protocols - scan_results['protocols'] = protocols -async def test_cipher_suites(scan_results, target, port): - """Test supported cipher suites.""" - try: - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - - # Get default cipher suites - context.set_ciphers('ALL:COMPLEMENTOFALL') - - with socket.create_connection((target, port), timeout=10) as sock: - with context.wrap_socket(sock, server_hostname=target) as ssock: - cipher = ssock.cipher() - scan_results['ciphers'] = { - 'negotiated_cipher': cipher[0] if cipher else 'Unknown', - 'supported_ciphers': await get_supported_ciphers(target, port), - 'weak_ciphers': [], - 'strong_ciphers': [] - } - - except Exception as e: - scan_results['cipher_error'] = str(e) - -async def get_supported_ciphers(target, port): - """Get list of supported cipher suites.""" - supported_ciphers = [] - - # Test common cipher suites +def _test_cipher_suites(target, port): + """Return list of supported cipher suite names.""" test_ciphers = [ - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-AES256-SHA384', - 'ECDHE-ECDSA-AES256-SHA384', - 'ECDHE-RSA-AES256-SHA', - 'ECDHE-ECDSA-AES256-SHA', - 'AES256-GCM-SHA384', - 'AES256-SHA256', - 'AES256-SHA', - 'CAMELLIA256-SHA', - 'PSK-AES256-CBC-SHA', - 'ECDHE-RSA-AES128-GCM-SHA256', - 'ECDHE-ECDSA-AES128-GCM-SHA256', - 'ECDHE-RSA-AES128-SHA256', - 'ECDHE-ECDSA-AES128-SHA256', - 'ECDHE-RSA-AES128-SHA', - 'ECDHE-ECDSA-AES128-SHA', - 'AES128-GCM-SHA256', - 'AES128-SHA256', - 'AES128-SHA', - 'CAMELLIA128-SHA', - 'PSK-AES128-CBC-SHA', - 'DES-CBC3-SHA', - 'RC4-SHA', - 'RC4-MD5' + 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384', + 'ECDHE-RSA-AES256-SHA', 'ECDHE-ECDSA-AES256-SHA', + 'AES256-GCM-SHA384', 'AES256-SHA256', 'AES256-SHA', + 'CAMELLIA256-SHA', 'PSK-AES256-CBC-SHA', + 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES128-SHA256', 'ECDHE-ECDSA-AES128-SHA256', + 'ECDHE-RSA-AES128-SHA', 'ECDHE-ECDSA-AES128-SHA', + 'AES128-GCM-SHA256', 'AES128-SHA256', 'AES128-SHA', + 'CAMELLIA128-SHA', 'PSK-AES128-CBC-SHA', + 'DES-CBC3-SHA', 'RC4-SHA', 'RC4-MD5' ] - + supported = [] for cipher in test_ciphers: try: - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - context.set_ciphers(cipher) - + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_ciphers(cipher) with socket.create_connection((target, port), timeout=5) as sock: - with context.wrap_socket(sock, server_hostname=target) as ssock: - if ssock.cipher(): - supported_ciphers.append(cipher) + with ctx.wrap_socket(sock, server_hostname=target): + supported.append(cipher) except: pass + return supported - return supported_ciphers -async def check_vulnerabilities(scan_results): - """Check for common SSL/TLS vulnerabilities.""" - vulnerabilities = [] +# ----- analysis helpers (same logic as original) ----- +def _check_vulnerabilities(protocols, cert_info, supported_ciphers): + vulns = [] - # Check for weak protocols - if scan_results['protocols'].get('SSLv2', False): - vulnerabilities.append({ + if protocols.get('SSLv2'): + vulns.append({ 'name': 'SSLv2 Support', 'severity': 'CRITICAL', 'description': 'SSLv2 is obsolete and contains critical vulnerabilities', 'cve': 'Multiple CVEs' }) - if scan_results['protocols'].get('SSLv3', False): - vulnerabilities.append({ + if protocols.get('SSLv3'): + vulns.append({ 'name': 'SSLv3 Support', 'severity': 'HIGH', 'description': 'SSLv3 is vulnerable to POODLE attack', 'cve': 'CVE-2014-3566' }) - # Check certificate expiration - cert = scan_results.get('certificate', {}) - if cert.get('days_until_expiry', 0) < 30: - vulnerabilities.append({ + if cert_info and cert_info.get('days_until_expiry', 0) < 30: + vulns.append({ 'name': 'Certificate Expiring Soon', 'severity': 'MEDIUM', - 'description': f"Certificate expires in {cert['days_until_expiry']} days", + 'description': f"Certificate expires in {cert_info['days_until_expiry']} days", 'cve': 'N/A' }) - # Check for weak ciphers - supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', []) - weak_ciphers_found = [] - - for cipher in supported_ciphers: - if any(weak in cipher.upper() for weak in CIPHER_CATEGORIES['WEAK']): - weak_ciphers_found.append(cipher) - - if weak_ciphers_found: - vulnerabilities.append({ + weak_ciphers = [c for c in supported_ciphers + if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])] + if weak_ciphers: + vulns.append({ 'name': 'Weak Cipher Suites', 'severity': 'HIGH', - 'description': f'Weak ciphers supported: {", ".join(weak_ciphers_found[:3])}', + 'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}', 'cve': 'Multiple CVEs' }) - # Check for missing modern protocols - if not scan_results['protocols'].get('TLSv1.2', False): - vulnerabilities.append({ + if not protocols.get('TLSv1.2', False): + vulns.append({ 'name': 'TLS 1.2 Not Supported', 'severity': 'HIGH', 'description': 'TLS 1.2 is required for modern security', 'cve': 'N/A' }) - if not scan_results['protocols'].get('TLSv1.3', False): - vulnerabilities.append({ + if not protocols.get('TLSv1.3', False): + vulns.append({ 'name': 'TLS 1.3 Not Supported', 'severity': 'MEDIUM', 'description': 'TLS 1.3 provides improved security and performance', 'cve': 'N/A' }) - scan_results['vulnerabilities'] = vulnerabilities + return vulns -async def calculate_security_score(scan_results): - """Calculate overall security score.""" + +def _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities): score = 100 - # Protocol penalties - if scan_results['protocols'].get('SSLv2', False): - score -= 30 - if scan_results['protocols'].get('SSLv3', False): - score -= 20 - if not scan_results['protocols'].get('TLSv1.2', False): - score -= 15 - if not scan_results['protocols'].get('TLSv1.3', False): - score -= 10 + if protocols.get('SSLv2'): score -= 30 + if protocols.get('SSLv3'): score -= 20 + if not protocols.get('TLSv1.2'): score -= 15 + if not protocols.get('TLSv1.3'): score -= 10 - # Certificate penalties - cert = scan_results.get('certificate', {}) - if cert.get('days_until_expiry', 0) < 30: - score -= 10 - if cert.get('days_until_expiry', 0) < 7: - score -= 20 + if cert_info and cert_info.get('days_until_expiry', 0) < 30: score -= 10 + if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20 - # Cipher penalties - supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', []) - weak_cipher_count = sum(1 for cipher in supported_ciphers - if any(weak in cipher.upper() for weak in CIPHER_CATEGORIES['WEAK'])) + weak_cipher_count = sum(1 for c in supported_ciphers + if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])) score -= min(weak_cipher_count * 5, 25) - # Vulnerability penalties - for vuln in scan_results.get('vulnerabilities', []): - if vuln['severity'] == 'CRITICAL': - score -= 20 - elif vuln['severity'] == 'HIGH': - score -= 15 - elif vuln['severity'] == 'MEDIUM': - score -= 10 - elif vuln['severity'] == 'LOW': - score -= 5 + for vuln in vulnerabilities: + if vuln['severity'] == 'CRITICAL': score -= 20 + elif vuln['severity'] == 'HIGH': score -= 15 + elif vuln['severity'] == 'MEDIUM': score -= 10 + elif vuln['severity'] == 'LOW': score -= 5 - scan_results['security_score'] = max(0, score) + return max(0, score) -async def generate_recommendations(scan_results): - """Generate security recommendations.""" - recommendations = [] - # Protocol recommendations - if scan_results['protocols'].get('SSLv2', False): - recommendations.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable") - if scan_results['protocols'].get('SSLv3', False): - recommendations.append("🔴 Disable SSLv3 - vulnerable to POODLE attack") - if not scan_results['protocols'].get('TLSv1.3', False): - recommendations.append("🟡 Enable TLSv1.3 for best security and performance") +def _generate_recommendations(protocols, cert_info, supported_ciphers, score): + recs = [] + if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable") + if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack") + if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance") - # Certificate recommendations - cert = scan_results.get('certificate', {}) - if cert.get('days_until_expiry', 0) < 30: - recommendations.append("🟡 Renew SSL certificate - expiring soon") + if cert_info and cert_info.get('days_until_expiry', 0) < 30: + recs.append("🟡 Renew SSL certificate - expiring soon") - # Cipher recommendations - supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', []) weak_ciphers = [c for c in supported_ciphers - if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])] - + if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])] if weak_ciphers: - recommendations.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)") + recs.append("🔴 Remove weak cipher suites (RC4, DES, 3DES, NULL)") - # General recommendations - if scan_results['security_score'] < 80: - recommendations.append("🛡️ Implement modern TLS configuration following Mozilla guidelines") + if score < 80: + recs.append("🛡️ Implement modern TLS configuration following Mozilla guidelines") if not any('ECDHE' in c for c in supported_ciphers): - recommendations.append("🟡 Enable Forward Secrecy with ECDHE cipher suites") + recs.append("🟡 Enable Forward Secrecy with ECDHE cipher suites") - # Add note about Python version limitations - recommendations.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features") + recs.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features") + return recs - scan_results['recommendations'] = recommendations -async def format_ssl_scan_results(scan_results): - """Format comprehensive SSL scan results.""" - output = f"🔐 SSL/TLS Security Scan: {scan_results['target']}:{scan_results['port']}

    " +def _format_cert_date(date_str): + try: + dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ') + return dt.strftime('%Y-%m-%d %H:%M:%S UTC') + except: + return date_str - # Security Score - score = scan_results['security_score'] - if score >= 90: - score_emoji, rating = "🟢", "Excellent" - elif score >= 80: - score_emoji, rating = "🟡", "Good" - elif score >= 60: - score_emoji, rating = "🟠", "Fair" - else: - score_emoji, rating = "🔴", "Poor" - output += f"{score_emoji} Security Score: {score}/100 ({rating})

    " +# ----- main scan orchestration ----- +async def perform_ssl_scan(room, bot, target, port): + safe_target = html_escape(target) + await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {safe_target}:{port}...") + + if not await _run_blocking(_test_connectivity, target, port): + await bot.api.send_text_message(room.room_id, f"❌ Cannot connect to {safe_target}:{port}") + return + + # Run blocking checks in parallel + cert_task = _run_blocking(_get_certificate_info, target, port) + proto_task = _run_blocking(_test_protocols, target, port) + cipher_task = _run_blocking(_test_cipher_suites, target, port) + + cert_info, protocols, supported_ciphers = await asyncio.gather(cert_task, proto_task, cipher_task) + + vulnerabilities = _check_vulnerabilities(protocols, cert_info, supported_ciphers) + score = _calculate_score(protocols, cert_info, supported_ciphers, vulnerabilities) + recommendations = _generate_recommendations(protocols, cert_info, supported_ciphers, score) + + # Build output (using safe domain/port) + output = await _format_results(target, port, cert_info, protocols, supported_ciphers, + vulnerabilities, score, recommendations) + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Completed SSL scan for {target}:{port}") + + +async def _format_results(target, port, cert_info, protocols, supported_ciphers, + vulnerabilities, score, recommendations): + safe_target = html_escape(target) + score_emoji = "🟢" if score >= 90 else "🟡" if score >= 80 else "🟠" if score >= 60 else "🔴" + rating = "Excellent" if score >= 90 else "Good" if score >= 80 else "Fair" if score >= 60 else "Poor" + + body = f"🔐 SSL/TLS Security Scan: {safe_target}:{port}

    " + body += f"{score_emoji} Security Score: {score}/100 ({rating})

    " # Certificate Information - cert = scan_results.get('certificate', {}) - if cert: - output += "📜 Certificate Information
    " - output += f" • Subject: {cert.get('subject', {}).get('common_name', 'N/A')}
    " - output += f" • Issuer: {cert.get('issuer', {}).get('common_name', 'N/A')}
    " - output += f" • Valid From: {format_cert_date(cert.get('not_before', ''))}
    " - output += f" • Valid Until: {format_cert_date(cert.get('not_after', ''))}
    " - output += f" • Expires In: {cert.get('days_until_expiry', 'N/A')} days
    " - output += f" • Signature Algorithm: {cert.get('signature_algorithm', 'N/A')}
    " - output += "
    " + if cert_info: + body += "📜 Certificate Information
    " + body += f" • Subject: {html_escape(cert_info['subject'].get('common_name', 'N/A'))}
    " + body += f" • Issuer: {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}
    " + body += f" • Valid From: {_format_cert_date(cert_info['not_before'])}
    " + body += f" • Valid Until: {_format_cert_date(cert_info['not_after'])}
    " + days = cert_info.get('days_until_expiry', 'N/A') + body += f" • Expires In: {days} days
    " + body += f" • Signature Algorithm: {html_escape(cert_info['signature_algorithm'])}
    " + body += "
    " # Protocol Support - output += "🔌 Protocol Support
    " - protocols = scan_results.get('protocols', {}) - for proto, supported in protocols.items(): - # Handle protocols that can't be tested in this Python version - if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS: - emoji = "⚫" - status = "Cannot test (Python security)" + body += "🔌 Protocol Support
    " + for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']: + supported = protocols.get(proto, False) + if proto in ['SSLv2', 'SSLv3'] and supported: + emoji = "🔴" + elif proto == 'TLSv1.3' and supported: + emoji = "✅" else: emoji = "✅" if supported else "❌" + status = "Supported" if supported else "Not Supported" + if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS: + status = "Cannot test (Python security)" + emoji = "⚫" + body += f" • {emoji} {proto}: {status}
    " + body += "
    " - # Highlight insecure protocols - if proto in ['SSLv2', 'SSLv3'] and supported: - emoji = "🔴" - elif proto in ['TLSv1.3'] and supported: - emoji = "✅" + # Cipher Suites + body += "🔐 Cipher Suites
    " + body += f" • Total Supported: {len(supported_ciphers)}
    " - output += f" • {emoji} {proto}: {status if 'status' in locals() else 'Supported' if supported else 'Not Supported'}
    " - output += "
    " - - # Cipher Information - ciphers = scan_results.get('ciphers', {}) - if ciphers.get('supported_ciphers'): - output += "🔐 Cipher Suites
    " - output += f" • Negotiated: {ciphers.get('negotiated_cipher', 'Unknown')}
    " - output += f" • Total Supported: {len(ciphers['supported_ciphers'])}
    " - - # Show weak ciphers if any - weak_ciphers = [c for c in ciphers['supported_ciphers'] - if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])] - if weak_ciphers: - output += f" • Weak Ciphers: {len(weak_ciphers)} found
    " - for cipher in weak_ciphers[:3]: - output += f" └─ 🔴 {cipher}
    " - - # Show strong ciphers if any - strong_ciphers = [c for c in ciphers['supported_ciphers'] - if any(strong in c.upper() for strong in CIPHER_CATEGORIES['STRONG'])] - if strong_ciphers: - output += f" • Strong Ciphers: {len(strong_ciphers)} found
    " - output += "
    " + weak_ciphers = [c for c in supported_ciphers + if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])] + if weak_ciphers: + body += f" • Weak Ciphers: {len(weak_ciphers)} found
    " + for cipher in weak_ciphers[:3]: + body += f" └─ 🔴 {html_escape(cipher)}
    " + strong_ciphers = [c for c in supported_ciphers + if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])] + if strong_ciphers: + body += f" • Strong Ciphers: {len(strong_ciphers)} found
    " + body += "
    " # Vulnerabilities - vulnerabilities = scan_results.get('vulnerabilities', []) if vulnerabilities: - output += "⚠️ Security Vulnerabilities
    " - for vuln in vulnerabilities[:5]: # Show top 5 - severity_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡" - output += f" • {severity_emoji} {vuln['name']} ({vuln['severity']})
    " - output += f" └─ {vuln['description']}
    " - output += "
    " + body += "⚠️ Security Vulnerabilities
    " + for vuln in vulnerabilities[:5]: + sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡" + body += f" • {sev_emoji} {html_escape(vuln['name'])} ({vuln['severity']})
    " + body += f" └─ {html_escape(vuln['description'])}
    " + body += "
    " # Recommendations - recommendations = scan_results.get('recommendations', []) if recommendations: - output += "💡 Security Recommendations
    " + body += "💡 Security Recommendations
    " for rec in recommendations[:8]: - output += f" • {rec}
    " - output += "
    " + body += f" • {rec}
    " + body += "
    " # Quick Assessment - output += "📊 Quick Assessment
    " + body += "📊 Quick Assessment
    " if score >= 90: - output += " • ✅ Excellent TLS configuration
    " - output += " • ✅ Modern protocols and ciphers
    " - output += " • ✅ Good certificate management
    " + body += " • ✅ Excellent TLS configuration
    " + body += " • ✅ Modern protocols and ciphers
    " + body += " • ✅ Good certificate management
    " elif score >= 70: - output += " • ⚠️ Good configuration with minor issues
    " - output += " • 🔧 Some improvements recommended
    " + body += " • ⚠️ Good configuration with minor issues
    " + body += " • 🔧 Some improvements recommended
    " else: - output += " • 🚨 Significant security issues found
    " - output += " • 🔴 Immediate action required
    " + body += " • 🚨 Significant security issues found
    " + body += " • 🔴 Immediate action required
    " - # Add note about testing limitations - output += "
    ℹ️ Note: Some protocol tests limited by Python security features" + body += "
    ℹ️ Note: Some protocol tests limited by Python security features" - # Always wrap in collapsible due to comprehensive output - output = f"
    🔐 SSL/TLS Scan: {scan_results['target']}:{scan_results['port']} (Score: {score}/100){output}
    " - - return output - -def format_cert_date(date_str): - """Format certificate date string for display.""" - try: - if date_str: - dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ') - return dt.strftime('%Y-%m-%d %H:%M:%S UTC') - except: - pass - return date_str + return collapsible_summary(f"🔐 SSL/TLS Scan: {safe_target}:{port} (Score: {score}/100)", body) # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- - -__version__ = "1.0.1" +__version__ = "1.0.2" __author__ = "Funguy Bot" -__description__ = "SSL/TLS security scanner (SSRF‑safe)" +__description__ = "SSL/TLS security scanner (SSRF‑safe, async)" __help__ = """
    !sslscan – SSL/TLS analysis diff --git a/plugins/stable-diffusion.py b/plugins/stable-diffusion.py index 0f915da..45e51c0 100644 --- a/plugins/stable-diffusion.py +++ b/plugins/stable-diffusion.py @@ -2,92 +2,33 @@ """ Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room. -Now supports a `--seed` parameter to control deterministic generation. +Now fully asynchronous (uses aiohttp). All original parameters and help text are preserved. """ -import requests +import aiohttp import base64 import tempfile import os -from asyncio import Queue import argparse import simplematrixbotlib as botlib -import markdown2 -from slugify import slugify - -# Queue to store pending commands -command_queue = Queue() - -def slugify_prompt(prompt: str) -> str: - """ - Generates a URL-friendly slug from the given prompt. - - Args: - prompt (str): The prompt to slugify. - - Returns: - str: A URL-friendly slug version of the prompt. - """ - return slugify(prompt) - -def markdown_to_html(markdown_text: str) -> str: - """ - Converts Markdown text to HTML. - - Args: - markdown_text (str): The Markdown text to convert. - - Returns: - str: The HTML version of the input Markdown text. - """ - return markdown2.markdown(markdown_text) - -async def process_command(room, message, bot, prefix, config): - """ - Processes !sd commands and queues them if already running. - - Args: - room: Matrix room object - message: Matrix message object - bot: Bot instance - prefix: Command prefix - config: Bot config object - """ - match = botlib.MessageMatch(room, message, bot, prefix) - if match.prefix() and match.command("sd"): - if command_queue.empty(): - await handle_command(room, message, bot, prefix, config) - else: - await command_queue.put((room, message, bot, prefix, config)) - await bot.api.send_text_message(room.room_id, "Command queued. Please wait for the current image to finish.") async def handle_command(room, message, bot, prefix, config): - """ - Handles !sd command: generates image using Stable Diffusion API. - - Args: - room: Matrix room object - message: Matrix message object - bot: Bot instance - prefix: Command prefix - config: Bot config object - """ match = botlib.MessageMatch(room, message, bot, prefix) if not (match.prefix() and match.command("sd")): return - # Check if API is available + # Check if API is reachable try: - health_check = requests.get("http://127.0.0.1:7860/docs", timeout=3) - if health_check.status_code != 200: - await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!") - return + async with aiohttp.ClientSession() as session: + async with session.get("http://127.0.0.1:7860/docs", timeout=3) as resp: + if resp.status != 200: + await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!") + return except Exception: await bot.api.send_text_message(room.room_id, "Could not reach Stable Diffusion API!") return try: - # Parse command-line arguments parser = argparse.ArgumentParser(description='Generate images using self-hosted Stable Diffusion') parser.add_argument('--steps', type=int, default=4, help='Number of steps, default=4') parser.add_argument('--cfg', type=int, default=2, help='CFG scale, default=2') @@ -120,32 +61,26 @@ async def handle_command(room, message, bot, prefix, config): "width": args.w, "height": args.h, } - # Add seed only if explicitly provided if args.seed is not None: payload["seed"] = args.seed - url = "http://127.0.0.1:7860/sdapi/v1/txt2img" - response = requests.post(url=url, json=payload, timeout=600) - r = response.json() + async with aiohttp.ClientSession() as session: + async with session.post("http://127.0.0.1:7860/sdapi/v1/txt2img", json=payload, timeout=600) as response: + response.raise_for_status() + r = await response.json() - # Use secure temporary file + # Save and send image + image_data = base64.b64decode(r['images'][0]) with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file: filename = temp_file.name - temp_file.write(base64.b64decode(r['images'][0])) + temp_file.write(image_data) - # Send image to Matrix room await bot.api.send_image_message(room_id=room.room_id, image_filepath=filename) - # Optional: send info about generated image - neg_prompt_clean = neg_prompt.replace(" ", "") - seed_info = f"
    Seed: {args.seed}" if args.seed is not None else "" - info_msg = f"""
    🔍 Image Info -Prompt: {prompt[:100]}
    -Steps: {args.steps}
    -Dimensions: {args.h}x{args.w}
    -Sampler: {sampler_name}
    -CFG Scale: {args.cfg}{seed_info}
    -Negative Prompt: {neg_prompt_clean}
    """ + # Optional info message (commented out to avoid spam, but can be enabled) + # neg_prompt_clean = neg_prompt.replace(" ", "") + # seed_info = f"
    Seed: {args.seed}" if args.seed is not None else "" + # info_msg = f"
    🔍 Image InfoPrompt: {prompt[:100]}
    ...
    " # await bot.api.send_markdown_message(room.room_id, info_msg) # Clean up temp file @@ -156,18 +91,10 @@ async def handle_command(room, message, bot, prefix, config): await bot.api.send_markdown_message(room.room_id, "
    Stable Diffusion Help" + print_help() + "
    ") except Exception as e: await bot.api.send_text_message(room.room_id, f"Error processing the command: {str(e)}") - finally: - # Process next queued command - if not command_queue.empty(): - next_command = await command_queue.get() - await handle_command(*next_command) def print_help(): """ - Generates help text for the 'sd' command. - - Returns: - str: Help text for the 'sd' command. + Generates the full help text for the 'sd' command, including LORA list. """ return """

    Generate images using self-hosted Stable Diffusion

    @@ -205,14 +132,12 @@ def print_help(): """ - # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- - -__version__ = "1.1.0" +__version__ = "1.1.2" __author__ = "Funguy Bot" -__description__ = "Stable Diffusion image generation (supports --seed)" +__description__ = "Stable Diffusion image generation (async, LORA support)" __help__ = """
    !sd – Generate images via Stable Diffusion @@ -225,6 +150,7 @@ __help__ = """
  • --sampler SAMPLER – Sampler name (default DPM++ SDE)
  • --seed SEED – Deterministic seed (optional)
  • +

    LORAs: <lora:filename:weight>

    Requires a locally running Stable Diffusion API.

    """ diff --git a/plugins/sysinfo.py b/plugins/sysinfo.py index 7e285f2..84dfc67 100644 --- a/plugins/sysinfo.py +++ b/plugins/sysinfo.py @@ -1,41 +1,29 @@ """ -This plugin provides comprehensive system information and resource monitoring. +Comprehensive system information and resource monitoring. +All blocking calls (psutil, subprocess) run in a thread pool. """ import logging import platform import os +import asyncio import psutil import socket import datetime -import simplematrixbotlib as botlib import subprocess -import sys +import simplematrixbotlib as botlib +from plugins.common import collapsible_summary, html_escape async def handle_command(room, message, bot, prefix, config): """ - Function to handle !sysinfo command for system information. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None + Handle !sysinfo command for system information. """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"): - logging.info("Received !sysinfo command") - args = match.args() - - if len(args) > 0 and args[0].lower() == 'help': + if args and args[0].lower() == 'help': await show_usage(room, bot) return - await get_system_info(room, bot) async def show_usage(room, bot): @@ -57,396 +45,307 @@ async def show_usage(room, bot): """ await bot.api.send_markdown_message(room.room_id, usage) -async def get_system_info(room, bot): - """Collect and display comprehensive system information.""" - try: - await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...") +# ----- Async wrappers for blocking functions ----- +async def _run_blocking(func, *args, **kwargs): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: func(*args, **kwargs)) - sysinfo = { - 'system': await get_system_info_basic(), - 'cpu': await get_cpu_info(), - 'memory': await get_memory_info(), - 'storage': await get_storage_info(), - 'network': await get_network_info(), - 'processes': await get_process_info(), - 'docker': await get_docker_info(), - 'sensors': await get_sensor_info(), - 'gpu': await get_gpu_info() +# ----- Individual data collectors (all sync, run in thread) ----- +def _system_overview(): + return { + 'hostname': socket.gethostname(), + 'os': platform.system(), + 'os_release': platform.release(), + 'os_version': platform.version(), + 'architecture': platform.architecture()[0], + 'machine': platform.machine(), + 'processor': platform.processor(), + 'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"), + 'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))), + 'users': len(psutil.users()) + } + +def _cpu_info(): + cpu_times = psutil.cpu_times_percent(interval=1) + cpu_freq = psutil.cpu_freq() + load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else (0,0,0) + return { + 'physical_cores': psutil.cpu_count(logical=False), + 'total_cores': psutil.cpu_count(logical=True), + 'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A", + 'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A", + 'usage_percent': psutil.cpu_percent(interval=1), + 'user_time': cpu_times.user, + 'system_time': cpu_times.system, + 'idle_time': cpu_times.idle, + 'load_avg': ", ".join(f"{l:.2f}" for l in load_avg) + } + +def _memory_info(): + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + return { + 'total': f"{mem.total / (1024**3):.2f} GB", + 'available': f"{mem.available / (1024**3):.2f} GB", + 'used': f"{mem.used / (1024**3):.2f} GB", + 'usage_percent': mem.percent, + 'swap_total': f"{swap.total / (1024**3):.2f} GB", + 'swap_used': f"{swap.used / (1024**3):.2f} GB", + 'swap_free': f"{swap.free / (1024**3):.2f} GB", + 'swap_percent': swap.percent + } + +def _storage_info(): + partitions = psutil.disk_partitions() + storage_list = [] + for part in partitions: + try: + usage = psutil.disk_usage(part.mountpoint) + storage_list.append({ + 'device': part.device, + 'mountpoint': part.mountpoint, + 'fstype': part.fstype, + 'total': f"{usage.total / (1024**3):.2f} GB", + 'used': f"{usage.used / (1024**3):.2f} GB", + 'free': f"{usage.free / (1024**3):.2f} GB", + 'percent': usage.percent + }) + except: + pass + disk_io = psutil.disk_io_counters() + io_info = { + 'read_count': disk_io.read_count if disk_io else 0, + 'write_count': disk_io.write_count if disk_io else 0, + 'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB", + 'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB" + } + return {'partitions': storage_list, 'io_stats': io_info} + +def _network_info(): + interfaces = psutil.net_if_addrs() + io_counters = psutil.net_io_counters(pernic=True) + net_list = [] + for iface, addrs in interfaces.items(): + if iface == 'lo': + continue + info = { + 'interface': iface, + 'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'), + 'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'), + 'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'), } + io = io_counters.get(iface) + if io: + info['bytes_sent'] = f"{io.bytes_sent / (1024**2):.2f} MB" + info['bytes_recv'] = f"{io.bytes_recv / (1024**2):.2f} MB" + else: + info['bytes_sent'] = 'N/A' + info['bytes_recv'] = 'N/A' + net_list.append(info) + return net_list - output = await format_system_info(sysinfo) - await bot.api.send_markdown_message(room.room_id, output) +def _process_info(): + procs = [] + for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']): + try: + procs.append(proc.info) + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5] + return {'total_processes': len(procs), 'top_cpu': top_cpu} - logging.info("Sent system information") - - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error gathering system info: {str(e)}") - logging.error(f"Error in get_system_info: {e}") - -async def get_system_info_basic(): - """Get basic system information.""" +def _docker_info(): try: - return { - 'hostname': socket.gethostname(), - 'os': platform.system(), - 'os_release': platform.release(), - 'os_version': platform.version(), - 'architecture': platform.architecture()[0], - 'machine': platform.machine(), - 'processor': platform.processor(), - 'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"), - 'uptime': str(datetime.timedelta(seconds=psutil.boot_time() - datetime.datetime.now().timestamp())).split('.')[0], - 'users': len(psutil.users()) - } - except Exception as e: - return {'error': str(e)} - -async def get_cpu_info(): - """Get CPU information and usage.""" - try: - cpu_times = psutil.cpu_times_percent(interval=1) - cpu_freq = psutil.cpu_freq() - - return { - 'physical_cores': psutil.cpu_count(logical=False), - 'total_cores': psutil.cpu_count(logical=True), - 'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A", - 'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A", - 'usage_percent': psutil.cpu_percent(interval=1), - 'user_time': cpu_times.user, - 'system_time': cpu_times.system, - 'idle_time': cpu_times.idle, - 'load_avg': os.getloadavg() if hasattr(os, 'getloadavg') else "N/A" - } - except Exception as e: - return {'error': str(e)} - -async def get_memory_info(): - """Get memory and swap information.""" - try: - memory = psutil.virtual_memory() - swap = psutil.swap_memory() - - return { - 'total': f"{memory.total / (1024**3):.2f} GB", - 'available': f"{memory.available / (1024**3):.2f} GB", - 'used': f"{memory.used / (1024**3):.2f} GB", - 'usage_percent': memory.percent, - 'swap_total': f"{swap.total / (1024**3):.2f} GB", - 'swap_used': f"{swap.used / (1024**3):.2f} GB", - 'swap_free': f"{swap.free / (1024**3):.2f} GB", - 'swap_percent': swap.percent - } - except Exception as e: - return {'error': str(e)} - -async def get_storage_info(): - """Get storage device information.""" - try: - partitions = psutil.disk_partitions() - storage_info = [] - - for partition in partitions: - try: - usage = psutil.disk_usage(partition.mountpoint) - storage_info.append({ - 'device': partition.device, - 'mountpoint': partition.mountpoint, - 'fstype': partition.fstype, - 'total': f"{usage.total / (1024**3):.2f} GB", - 'used': f"{usage.used / (1024**3):.2f} GB", - 'free': f"{usage.free / (1024**3):.2f} GB", - 'percent': usage.percent - }) - except: - continue - - # Get disk I/O statistics - disk_io = psutil.disk_io_counters() - io_info = { - 'read_count': disk_io.read_count if disk_io else 0, - 'write_count': disk_io.write_count if disk_io else 0, - 'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB", - 'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB" - } - - return { - 'partitions': storage_info, - 'io_stats': io_info - } - except Exception as e: - return {'error': str(e)} - -async def get_network_info(): - """Get network interface information.""" - try: - interfaces = psutil.net_if_addrs() - io_counters = psutil.net_io_counters(pernic=True) - - network_info = [] - for interface, addrs in interfaces.items(): - if interface not in ['lo']: # Skip loopback - interface_io = io_counters.get(interface, None) - network_info.append({ - 'interface': interface, - 'ipv4': next((addr.address for addr in addrs if addr.family == socket.AF_INET), 'N/A'), - 'ipv6': next((addr.address for addr in addrs if addr.family == socket.AF_INET6), 'N/A'), - 'mac': next((addr.address for addr in addrs if addr.family == psutil.AF_LINK), 'N/A'), - 'bytes_sent': f"{interface_io.bytes_sent / (1024**2):.2f} MB" if interface_io else "N/A", - 'bytes_recv': f"{interface_io.bytes_recv / (1024**2):.2f} MB" if interface_io else "N/A" - }) - - return network_info - except Exception as e: - return {'error': str(e)} - -async def get_process_info(): - """Get process and system load information.""" - try: - processes = [] - for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']): - try: - processes.append(proc.info) - except (psutil.NoSuchProcess, psutil.AccessDenied): - continue - - # Sort by CPU usage and get top 5 - top_processes = sorted(processes, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5] - - return { - 'total_processes': len(processes), - 'top_cpu': top_processes - } - except Exception as e: - return {'error': str(e)} - -async def get_docker_info(): - """Get Docker container information if available.""" - try: - # Check if docker is available result = subprocess.run(['docker', '--version'], capture_output=True, text=True) if result.returncode != 0: return {'available': False} - - # Get running containers result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'], - capture_output=True, text=True) - + capture_output=True, text=True) containers = [] for line in result.stdout.strip().split('\n'): if line: parts = line.split('|') if len(parts) >= 2: - containers.append({ + containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'}) + return {'available': True, 'containers': containers, 'total_running': len(containers)} + except: + return {'available': False} + +def _sensor_info(): + temps = psutil.sensors_temperatures() + fans = psutil.sensors_fans() + battery = psutil.sensors_battery() + sensor = {'temperatures': {}, 'fans': {}, 'battery': {}} + if temps: + for name, entries in temps.items(): + sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]] + if fans: + for name, entries in fans.items(): + sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]] + if battery: + sensor['battery'] = { + 'percent': battery.percent, + 'power_plugged': battery.power_plugged, + 'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown" + } + return sensor + +def _gpu_info(): + gpu_data = {} + # NVIDIA + try: + res = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu', + '--format=csv,noheader,nounits'], capture_output=True, text=True) + if res.returncode == 0: + nvidia = [] + for line in res.stdout.strip().split('\n'): + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 6: + nvidia.append({ 'name': parts[0], - 'status': parts[1], - 'ports': parts[2] if len(parts) > 2 else 'N/A' + 'memory_total': f"{parts[1]} MB", + 'memory_used': f"{parts[2]} MB", + 'memory_free': f"{parts[3]} MB", + 'temperature': f"{parts[4]}°C", + 'utilization': f"{parts[5]}%" }) - - return { - 'available': True, - 'containers': containers, - 'total_running': len(containers) - } - except Exception as e: - return {'available': False, 'error': str(e)} - -async def get_sensor_info(): - """Get hardware sensor information.""" + if nvidia: + gpu_data['nvidia'] = nvidia + except: + pass + # lspci fallback try: - temperatures = psutil.sensors_temperatures() - fans = psutil.sensors_fans() - battery = psutil.sensors_battery() + res = subprocess.run(['lspci'], capture_output=True, text=True) + if res.returncode == 0: + gpu_lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l] + if gpu_lines: + gpu_data['detected'] = gpu_lines[:3] + except: + pass + return gpu_data - sensor_info = { - 'temperatures': {}, - 'fans': {}, - 'battery': {} - } +# ----- Main info gatherer ----- +async def get_system_info(room, bot): + await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...") - # Temperature sensors - if temperatures: - for name, entries in temperatures.items(): - sensor_info['temperatures'][name] = [ - f"{entry.current}°C" for entry in entries[:2] # Show first 2 sensors per type - ] + # Run all blocking collectors concurrently + system = await _run_blocking(_system_overview) + cpu = await _run_blocking(_cpu_info) + memory = await _run_blocking(_memory_info) + storage = await _run_blocking(_storage_info) + network = await _run_blocking(_network_info) + processes = await _run_blocking(_process_info) + docker = await _run_blocking(_docker_info) + sensors = await _run_blocking(_sensor_info) + gpu = await _run_blocking(_gpu_info) - # Fan speeds - if fans: - for name, entries in fans.items(): - sensor_info['fans'][name] = [ - f"{entry.current} RPM" for entry in entries[:2] - ] + # Build output HTML + output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu) + await bot.api.send_markdown_message(room.room_id, output) + logging.info("Sent system information") - # Battery information - if battery: - sensor_info['battery'] = { - 'percent': battery.percent, - 'power_plugged': battery.power_plugged, - 'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown" - } - - return sensor_info - except Exception as e: - return {'error': str(e)} - -async def get_gpu_info(): - """Get GPU information using various methods.""" - try: - gpu_info = {} - - # Try nvidia-smi first - try: - result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu', - '--format=csv,noheader,nounits'], capture_output=True, text=True) - if result.returncode == 0: - nvidia_gpus = [] - for line in result.stdout.strip().split('\n'): - if line: - parts = [part.strip() for part in line.split(',')] - if len(parts) >= 6: - nvidia_gpus.append({ - 'name': parts[0], - 'memory_total': f"{parts[1]} MB", - 'memory_used': f"{parts[2]} MB", - 'memory_free': f"{parts[3]} MB", - 'temperature': f"{parts[4]}°C", - 'utilization': f"{parts[5]}%" - }) - gpu_info['nvidia'] = nvidia_gpus - except: - pass - - # Try lspci for generic GPU detection - try: - result = subprocess.run(['lspci'], capture_output=True, text=True) - if result.returncode == 0: - gpu_lines = [line for line in result.stdout.split('\n') if 'VGA' in line or '3D' in line] - gpu_info['detected'] = gpu_lines[:3] # Show first 3 GPUs - except: - pass - - return gpu_info - except Exception as e: - return {'error': str(e)} - -async def format_system_info(sysinfo): - """Format system information for display.""" - output = "💻 System Information

    " +async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu): + hostname = html_escape(system.get('hostname', 'Unknown')) + body = "💻 System Information

    " # System Overview - system = sysinfo.get('system', {}) - output += "🖥️ System Overview
    " - output += f" • Hostname: {system.get('hostname', 'N/A')}
    " - output += f" • OS: {system.get('os', 'N/A')} {system.get('os_release', '')}
    " - output += f" • Architecture: {system.get('architecture', 'N/A')}
    " - output += f" • Uptime: {system.get('uptime', 'N/A')}
    " - output += f" • Boot Time: {system.get('boot_time', 'N/A')}
    " - output += f" • Users: {system.get('users', 'N/A')}
    " - output += "
    " + body += "🖥️ System Overview
    " + body += f" • Hostname: {hostname}
    " + body += f" • OS: {html_escape(system['os'])} {html_escape(system['os_release'])}
    " + body += f" • Architecture: {html_escape(system['architecture'])}
    " + body += f" • Uptime: {html_escape(system['uptime'])}
    " + body += f" • Boot Time: {html_escape(system['boot_time'])}
    " + body += f" • Users: {system['users']}

    " - # CPU Information - cpu = sysinfo.get('cpu', {}) - if 'error' not in cpu: - output += "⚡ CPU Information
    " - output += f" • Cores: {cpu.get('physical_cores', 'N/A')} physical, {cpu.get('total_cores', 'N/A')} logical
    " - output += f" • Frequency: {cpu.get('current_frequency', 'N/A')} (max: {cpu.get('max_frequency', 'N/A')})
    " - output += f" • Usage: {cpu.get('usage_percent', 'N/A')}%
    " - if cpu.get('load_avg') != "N/A": - output += f" • Load Average: {', '.join([f'{load:.2f}' for load in cpu.get('load_avg', [0,0,0])])}
    " - output += "
    " + # CPU + body += "⚡ CPU Information
    " + body += f" • Cores: {cpu['physical_cores']} physical, {cpu['total_cores']} logical
    " + body += f" • Frequency: {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})
    " + body += f" • Usage: {cpu['usage_percent']}%
    " + body += f" • Load Average: {html_escape(cpu['load_avg'])}

    " - # Memory Information - memory = sysinfo.get('memory', {}) - if 'error' not in memory: - output += "🧠 Memory Information
    " - output += f" • Total: {memory.get('total', 'N/A')}
    " - output += f" • Used: {memory.get('used', 'N/A')} ({memory.get('usage_percent', 'N/A')}%)
    " - output += f" • Available: {memory.get('available', 'N/A')}
    " - output += f" • Swap: {memory.get('swap_used', 'N/A')} / {memory.get('swap_total', 'N/A')} ({memory.get('swap_percent', 'N/A')}%)
    " - output += "
    " + # Memory + body += "🧠 Memory Information
    " + body += f" • Total: {html_escape(memory['total'])}
    " + body += f" • Used: {html_escape(memory['used'])} ({memory['usage_percent']}%)
    " + body += f" • Available: {html_escape(memory['available'])}
    " + body += f" • Swap: {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)

    " - # Storage Information - storage = sysinfo.get('storage', {}) - if 'error' not in storage: - output += "💾 Storage Information
    " - partitions = storage.get('partitions', []) - for partition in partitions[:3]: # Show first 3 partitions - output += f" • {partition.get('device', 'N/A')}: {partition.get('used', 'N/A')} / {partition.get('total', 'N/A')} ({partition.get('percent', 'N/A')}%)
    " - output += "
    " + # Storage + if storage and 'error' not in storage: + body += "💾 Storage Information
    " + for p in storage['partitions'][:3]: + body += f" • {html_escape(p['device'])}: {p['used']} / {p['total']} ({p['percent']}%)
    " + # IO stats if wanted + io = storage.get('io_stats') + if io: + body += f" • Disk I/O: read {io['read_bytes']}, write {io['write_bytes']}
    " + body += "
    " - # GPU Information - gpu = sysinfo.get('gpu', {}) - if gpu.get('nvidia'): - output += "🎮 GPU Information (NVIDIA)
    " - for gpu_info in gpu['nvidia']: - output += f" • {gpu_info.get('name', 'N/A')}: {gpu_info.get('utilization', 'N/A')} usage, {gpu_info.get('temperature', 'N/A')}
    " - output += "
    " - elif gpu.get('detected'): - output += "🎮 GPU Information
    " - for gpu_line in gpu['detected'][:2]: - output += f" • {gpu_line}
    " - output += "
    " + # GPU + if gpu: + if 'nvidia' in gpu: + body += "🎮 GPU Information (NVIDIA)
    " + for g in gpu['nvidia']: + body += f" • {html_escape(g['name'])}: {g['utilization']} usage, {g['temperature']}
    " + body += "
    " + elif 'detected' in gpu: + body += "🎮 GPU Information
    " + for line in gpu['detected'][:2]: + body += f" • {html_escape(line)}
    " + body += "
    " - # Network Information - network = sysinfo.get('network', []) - if network and 'error' not in network: - output += "🌐 Network Information
    " - for interface in network[:2]: # Show first 2 interfaces - output += f" • {interface.get('interface', 'N/A')}: {interface.get('ipv4', 'N/A')}
    " - output += "
    " + # Network + if network: + body += "🌐 Network Information
    " + for iface in network[:2]: + body += f" • {html_escape(iface['interface'])}: {html_escape(iface['ipv4'])}
    " + body += "
    " - # Process Information - processes = sysinfo.get('processes', {}) - if 'error' not in processes: - output += "🔄 Top Processes (by CPU)
    " - for proc in processes.get('top_cpu', [])[:3]: - output += f" • {proc.get('name', 'N/A')}: {proc.get('cpu_percent', 0):.1f}% CPU, {proc.get('memory_percent', 0):.1f}% RAM
    " - output += f" • Total Processes: {processes.get('total_processes', 'N/A')}
    " - output += "
    " + # Top Processes + if processes: + body += "🔄 Top Processes (by CPU)
    " + for proc in processes['top_cpu'][:3]: + name = html_escape(proc.get('name', 'N/A')) + cpu_p = proc.get('cpu_percent', 0) or 0 + mem_p = proc.get('memory_percent', 0) or 0 + body += f" • {name}: {cpu_p:.1f}% CPU, {mem_p:.1f}% RAM
    " + body += f" • Total Processes: {processes['total_processes']}

    " - # Docker Information - docker = sysinfo.get('docker', {}) - if docker.get('available'): - output += "🐳 Docker Containers
    " - for container in docker.get('containers', [])[:3]: - output += f" • {container.get('name', 'N/A')}: {container.get('status', 'N/A')}
    " - output += f" • Total Running: {docker.get('total_running', 'N/A')}
    " - output += "
    " + # Docker + if docker and docker.get('available'): + body += "🐳 Docker Containers
    " + for c in docker['containers'][:3]: + body += f" • {html_escape(c['name'])}: {html_escape(c['status'])}
    " + body += f" • Total Running: {docker['total_running']}

    " - # Sensor Information - sensors = sysinfo.get('sensors', {}) - if 'error' not in sensors: + # Sensors + if sensors and 'error' not in sensors: if sensors.get('temperatures'): - output += "🌡️ Temperature Sensors
    " + body += "🌡️ Temperature Sensors
    " for sensor, temps in list(sensors['temperatures'].items())[:2]: - output += f" • {sensor}: {', '.join(temps[:2])}
    " - output += "
    " - + body += f" • {html_escape(sensor)}: {', '.join(temps[:2])}
    " + body += "
    " if sensors.get('battery'): - battery = sensors['battery'] - output += "🔋 Battery Information
    " - output += f" • Charge: {battery.get('percent', 'N/A')}%
    " - output += f" • Plugged In: {'Yes' if battery.get('power_plugged') else 'No'}
    " - if battery.get('time_left'): - output += f" • Time Left: {battery.get('time_left', 'N/A')}
    " - output += "
    " + bat = sensors['battery'] + body += "🔋 Battery Information
    " + body += f" • Charge: {bat['percent']}%
    " + body += f" • Plugged In: {'Yes' if bat['power_plugged'] else 'No'}
    " + if bat.get('time_left'): + body += f" • Time Left: {bat['time_left']}
    " + body += "
    " - # Add timestamp - output += f"Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + # Timestamp + body += f"Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - # Wrap in collapsible due to comprehensive output - output = f"
    💻 System Information - {system.get('hostname', 'Unknown')}{output}
    " - - return output + return collapsible_summary(f"💻 System Information - {hostname}", body) # --------------------------------------------------------------------------- # Plugin Metadata # --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" -__description__ = "System information and monitoring" +__description__ = "Comprehensive system information and monitoring" __help__ = """
    !sysinfo – System information diff --git a/plugins/urbandictionary.py b/plugins/urbandictionary.py index 526b6dd..73c6cd6 100644 --- a/plugins/urbandictionary.py +++ b/plugins/urbandictionary.py @@ -1,212 +1,92 @@ """ -This plugin provides a command to fetch definitions from Urban Dictionary. +Urban Dictionary definitions. """ - import logging -import requests +import aiohttp import simplematrixbotlib as botlib import html +from plugins.common import html_escape URBAN_API_URL = "https://api.urbandictionary.com/v0/define" RANDOM_API_URL = "https://api.urbandictionary.com/v0/random" - def format_definition(term, definition, example, author, thumbs_up, thumbs_down, permalink, index=None, total=None): - """ - Format an Urban Dictionary definition for display. + safe_term = html_escape(term) + safe_author = html_escape(author) + # definition and example may contain [word] markup, we'll just escape all HTML + definition = html.escape(definition).replace('[', '').replace(']', '') + example = html.escape(example).replace('[', '').replace(']', '') - Args: - term (str): The term being defined. - definition (str): The definition text. - example (str): Example usage. - author (str): Author of the definition. - thumbs_up (int): Number of upvotes. - thumbs_down (int): Number of downvotes. - permalink (str): URL to the definition. - index (int, optional): Current definition index. - total (int, optional): Total number of definitions. - - Returns: - str: Formatted HTML message. - """ - # Clean up the text - Urban Dictionary uses [brackets] for links - definition = definition.replace('[', '').replace(']', '') - example = example.replace('[', '').replace(']', '') - - # Escape any HTML that might be in the original text - term = html.escape(term) - author = html.escape(author) - - # Build the message - header = f"📖 Urban Dictionary: {term}" - if index is not None and total is not None: + header = f"📖 Urban Dictionary: {safe_term}" + if index and total: header += f" (Definition {index}/{total})" - message = f"""{header} -Definition: -{definition} -""" - if example and example.strip(): - message += f""" -Example: -{example} -""" - message += f""" -Author: {author} | 👍 {thumbs_up} 👎 {thumbs_down} -View on Urban Dictionary -""" - - return message - + msg = f"""{header} +Definition:
    {definition}
    """ + if example.strip(): + msg += f"""Example:
    {example}
    """ + msg += f"""Author: {safe_author} | 👍 {thumbs_up} 👎 {thumbs_down}
    +View on Urban Dictionary""" + return msg async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !ud (Urban Dictionary) command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (Bot): The bot object. - prefix (str): The command prefix. - config (dict): Configuration parameters. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, prefix) if match.is_not_from_this_bot() and match.prefix() and match.command("ud"): - logging.info("Received !ud command") - args = match.args() - try: - # Case 1: No arguments - get random definition if len(args) == 0: - logging.info("Fetching random Urban Dictionary definition") - response = requests.get(RANDOM_API_URL, timeout=10) - response.raise_for_status() - data = response.json() - + # random + async with aiohttp.ClientSession() as session: + async with session.get(RANDOM_API_URL, timeout=10) as resp: + resp.raise_for_status() + data = await resp.json() if not data.get('list'): await bot.api.send_text_message(room.room_id, "No random definition found.") return - - # Get first random entry entry = data['list'][0] - formatted = format_definition( - term=entry['word'], - definition=entry['definition'], - example=entry.get('example', ''), - author=entry['author'], - thumbs_up=entry['thumbs_up'], - thumbs_down=entry['thumbs_down'], - permalink=entry['permalink'] - ) - - await bot.api.send_markdown_message(room.room_id, formatted) - logging.info(f"Sent random definition: {entry['word']}") + msg = format_definition(entry['word'], entry['definition'], entry.get('example',''), + entry['author'], entry['thumbs_up'], entry['thumbs_down'], + entry['permalink']) + await bot.api.send_markdown_message(room.room_id, msg) return - # Case 2: One or more arguments - search for term - # Check if last argument is a number (definition index) + # Search index = None search_term = ' '.join(args) - if args[-1].isdigit(): index = int(args[-1]) search_term = ' '.join(args[:-1]) - if not search_term: - await bot.api.send_text_message( - room.room_id, - "Usage: !ud [term] [index]\nExamples:\n !ud - random definition\n !ud yeet - first definition of 'yeet'\n !ud yeet 2 - second definition of 'yeet'" - ) + await bot.api.send_text_message(room.room_id, "Usage: !ud [term] [index]") return - logging.info(f"Searching Urban Dictionary for: {search_term}") - params = {'term': search_term} - response = requests.get(URBAN_API_URL, params=params, timeout=10) - response.raise_for_status() - data = response.json() - + async with aiohttp.ClientSession() as session: + async with session.get(URBAN_API_URL, params={'term': search_term}, timeout=10) as resp: + resp.raise_for_status() + data = await resp.json() definitions = data.get('list', []) - if not definitions: - await bot.api.send_text_message( - room.room_id, - f"No definition found for '{search_term}'" - ) - logging.info(f"No definition found for: {search_term}") + await bot.api.send_text_message(room.room_id, f"No definition for '{html_escape(search_term)}'") return - total = len(definitions) - - # If no index specified, use first definition if index is None: index = 1 - - # Validate index if index < 1 or index > total: - await bot.api.send_text_message( - room.room_id, - f"Invalid index. '{search_term}' has {total} definition(s). Use !ud {search_term} [1-{total}]" - ) + await bot.api.send_text_message(room.room_id, f"Index out of range (1-{total})") return - - # Get the requested definition (convert to 0-based index) entry = definitions[index - 1] + msg = format_definition(entry['word'], entry['definition'], entry.get('example',''), + entry['author'], entry['thumbs_up'], entry['thumbs_down'], + entry['permalink'], index, total) + await bot.api.send_markdown_message(room.room_id, msg) - formatted = format_definition( - term=entry['word'], - definition=entry['definition'], - example=entry.get('example', ''), - author=entry['author'], - thumbs_up=entry['thumbs_up'], - thumbs_down=entry['thumbs_down'], - permalink=entry['permalink'], - index=index, - total=total - ) - - await bot.api.send_markdown_message(room.room_id, formatted) - logging.info(f"Sent definition {index}/{total} for: {search_term}") - - except requests.exceptions.Timeout: - await bot.api.send_text_message( - room.room_id, - "Request timed out. Urban Dictionary may be slow or unavailable." - ) - logging.error("Urban Dictionary API timeout") - - except requests.exceptions.RequestException as e: - await bot.api.send_text_message( - room.room_id, - f"Error fetching from Urban Dictionary: {e}" - ) - logging.error(f"Error fetching from Urban Dictionary: {e}") - + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"Error: {e}") except Exception as e: - await bot.api.send_text_message( - room.room_id, - "An error occurred while processing the Urban Dictionary request." - ) - logging.error(f"Unexpected error in Urban Dictionary plugin: {e}", exc_info=True) + await bot.api.send_text_message(room.room_id, f"Error: {str(e)}") - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" -__description__ = "Urban Dictionary definitions" -__help__ = """ -
    -!ud – Urban Dictionary -
      -
    • !ud – Random definition
    • -
    • !ud <term> – Top definition
    • -
    • !ud <term> <index> – Nth definition
    • -
    -
    -""" +__description__ = "Urban Dictionary definitions (async)" +__help__ = """
    !ud – Urban Dictionary +
    • !ud random, !ud <term> top, !ud <term> <index>
    """ diff --git a/plugins/xkcd.py b/plugins/xkcd.py index b1a3f3f..b4b108b 100644 --- a/plugins/xkcd.py +++ b/plugins/xkcd.py @@ -1,60 +1,90 @@ """ -Provides a command to fetch random xkcd comic +Provides a command to fetch random or specific xkcd comics. +Usage: !xkcd -> random comic + !xkcd -> comic # (e.g. !xkcd 538) """ -import requests +import aiohttp import tempfile import random +import os import simplematrixbotlib as botlib -# Define the XKCD API URL -XKCD_API_URL = "https://xkcd.com/info.0.json" +XKCD_LATEST_URL = "https://xkcd.com/info.0.json" +XKCD_COMIC_URL = "https://xkcd.com/{}/info.0.json" async def handle_command(room, message, bot, prefix, config): match = botlib.MessageMatch(room, message, bot, prefix) - if match.prefix() and match.command("xkcd"): - # Fetch the latest comic number from XKCD API - try: - response = requests.get(XKCD_API_URL, timeout=10) - response.raise_for_status() # Raise an exception for non-200 status codes - latest_comic_num = response.json()["num"] - # Choose a random comic number - random_comic_num = random.randint(1, latest_comic_num) - # Fetch the random comic data - random_comic_url = f"https://xkcd.com/{random_comic_num}/info.0.json" - comic_response = requests.get(random_comic_url, timeout=10) - comic_response.raise_for_status() - comic_data = comic_response.json() + if not (match.prefix() and match.command("xkcd")): + return + + args = match.args() + + try: + async with aiohttp.ClientSession() as session: + # Get latest comic number + async with session.get(XKCD_LATEST_URL, timeout=10) as resp: + resp.raise_for_status() + latest_data = await resp.json() + latest_num = latest_data["num"] + + # Determine target comic number + if args and args[0].isdigit(): + requested_num = int(args[0]) + if requested_num < 1 or requested_num > latest_num: + await bot.api.send_text_message( + room.room_id, + f"❌ Comic #{requested_num} doesn't exist. Valid range: 1 – {latest_num}." + ) + return + comic_num = requested_num + else: + comic_num = random.randint(1, latest_num) + + # Fetch the comic data + comic_url = XKCD_COMIC_URL.format(comic_num) + async with session.get(comic_url, timeout=10) as resp: + resp.raise_for_status() + comic_data = await resp.json() + image_url = comic_data["img"] + title = comic_data.get("safe_title", comic_data.get("title", "xkcd")) + alt = comic_data.get("alt", "") + # Download the image - image_response = requests.get(image_url, timeout=10) - image_response.raise_for_status() + async with session.get(image_url, timeout=10) as img_resp: + img_resp.raise_for_status() + image_data = await img_resp.read() - # Use secure temporary file - with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file: - image_path = temp_file.name - temp_file.write(image_response.content) + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: + tmp.write(image_data) + img_path = tmp.name - # Send the image to the room - await bot.api.send_image_message(room_id=room.room_id, image_filepath=image_path) + # Send image + await bot.api.send_image_message(room_id=room.room_id, image_filepath=img_path) - # Clean up temp file - import os - os.remove(image_path) - except Exception as e: - await bot.api.send_text_message(room.room_id, f"Error fetching XKCD comic: {str(e)}") + # Send comic info as text (optional but helpful) + info = f"**#{comic_num} – {title}**" + if alt: + info += f"\n*{alt}*" + await bot.api.send_markdown_message(room.room_id, info) + os.remove(img_path) -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- + except aiohttp.ClientError as e: + await bot.api.send_text_message(room.room_id, f"❌ Network error fetching xkcd: {e}") + except Exception as e: + await bot.api.send_text_message(room.room_id, f"❌ Error: {str(e)}") -__version__ = "1.0.0" +__version__ = "1.1.0" __author__ = "Funguy Bot" -__description__ = "Random XKCD comic" +__description__ = "Fetch random or specific xkcd comics" __help__ = """
    -!xkcd – Random XKCD comic -

    Posts a random XKCD comic image.

    +!xkcd – xkcd comics +
      +
    • !xkcd – random comic
    • +
    • !xkcd <number> – specific comic (e.g. !xkcd 538)
    • +
    """ diff --git a/plugins/youtube-search.py b/plugins/youtube-search.py index 6152f8f..980a96e 100644 --- a/plugins/youtube-search.py +++ b/plugins/youtube-search.py @@ -1,25 +1,15 @@ """ -Plugin for providing a command to search for YouTube videos in the room. +Plugin for providing a command to search for YouTube videos. +Uses async wrapper around youtube_search library (synchronous). """ import logging +import asyncio import simplematrixbotlib as botlib from youtube_search import YoutubeSearch +from plugins.common import html_escape, collapsible_summary async def handle_command(room, message, bot, PREFIX, config): - """ - Asynchronously handles the command to search for YouTube videos in the room. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - bot (MatrixBot): The Matrix bot instance. - PREFIX (str): The command prefix. - config (dict): The bot's configuration. - - Returns: - None - """ match = botlib.MessageMatch(room, message, bot, PREFIX) if match.is_not_from_this_bot() and match.prefix() and match.command("yt"): args = match.args() @@ -27,62 +17,33 @@ async def handle_command(room, message, bot, PREFIX, config): await bot.api.send_text_message(room.room_id, "Usage: !yt ") else: search_terms = " ".join(args) - logging.info(f"Performing YouTube search for: {search_terms}") - results = YoutubeSearch(search_terms, max_results=3).to_dict() + logging.info(f"YouTube search for: {search_terms}") + results = await asyncio.to_thread(YoutubeSearch, search_terms, max_results=3) + results = results.to_dict() if results: output = generate_output(results) - await send_collapsible_message(room, bot, output) + safe_terms = html_escape(search_terms) + msg = collapsible_summary(f"🍄 Funguy ▶YouTube Search: {safe_terms}", output) + await bot.api.send_markdown_message(room.room_id, msg) else: await bot.api.send_text_message(room.room_id, "No results found.") def generate_output(results): - """ - Generates HTML output for displaying YouTube search results. - - Args: - results (list): A list of dictionaries containing information about YouTube videos. - - Returns: - str: HTML formatted output containing YouTube search results. - """ output = "" for video in results: - output += f'' - output += f'
    ' - output += f'{video["title"]}
    ' - output += f'Length: {video["duration"]} | Views: {video["views"]}
    ' - if video["long_desc"]: - output += f'Description: {video["long_desc"]}
    ' - output += "

    " + vid_id = html_escape(video["id"]) + title = html_escape(video["title"]) + thumb = video["thumbnails"][0] + duration = html_escape(str(video["duration"])) + views = html_escape(str(video["views"])) + output += f'' + output += f'
    ' + output += f'{title}
    ' + output += f'Length: {duration} | Views: {views}

    ' return output - -async def send_collapsible_message(room, bot, content): - """ - Sends a collapsible message containing YouTube search results to the room. - - Args: - room (Room): The Matrix room where the message will be sent. - bot (MatrixBot): The Matrix bot instance. - content (str): HTML content to be included in the collapsible message. - - Returns: - None - """ - message = f'
    🍄Funguy ▶YouTube Search🍄
    ⤵︎Click Here To See Results⤵︎
    {content}
    ' - await bot.api.send_markdown_message(room.room_id, message) - - -# --------------------------------------------------------------------------- -# Plugin Metadata -# --------------------------------------------------------------------------- - -__version__ = "1.0.0" +__version__ = "1.0.1" __author__ = "Funguy Bot" -__description__ = "YouTube video search" -__help__ = """ -
    -!yt – Search YouTube -

    !yt <search terms> – Returns top 3 results with thumbnails and descriptions.

    -
    -""" +__description__ = "YouTube video search (async)" +__help__ = """
    !yt – Search YouTube +

    !yt <search terms>

    """