Compare commits

...

2 Commits

24 changed files with 2889 additions and 2703 deletions
+115 -209
View File
@@ -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 <plugin> <room_id>") # 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 <plugin> <room_id>")
else:
plugin_name, room_id = args # Extracting plugin name and room ID
await self.disable_plugin(room_id, plugin_name) # Disabling plugin
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'") # Sending success message
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 <plugin> <room_id>") # 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 <plugin> <room_id>")
else:
plugin_name, room_id = args # Extracting plugin name and room ID
await self.enable_plugin(room_id, plugin_name) # Enabling plugin
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'") # Sending success message
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)
+11 -72
View File
@@ -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)
async with aiohttp.ClientSession() as session:
async with session.get(BITCOIN_API_URL, headers=headers, timeout=10) as response:
response.raise_for_status()
data = response.json()
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"<strong>₿ BTC/USD</strong>"
message_text += f"<strong> Current Price:</strong> {price_formatted}"
message_text += ", <em>bitcointicker.co</em>"
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__ = """
+82
View File
@@ -0,0 +1,82 @@
"""
Shared utilities for FunguyBot plugins.
"""
import html
import ipaddress
import socket
import logging
logger = logging.getLogger(__name__)
# Networks considered unsafe for outbound connections
_PRIVATE_RANGES = [
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'),
ipaddress.ip_network('fe80::/10'),
ipaddress.ip_network('::/128'),
]
def html_escape(text: str) -> str:
"""Escape HTML special characters for safe embedding in messages."""
return html.escape(str(text), quote=False)
def collapsible_summary(title: str, body: str, expanded: bool = False) -> str:
"""Wrap content in a collapsible HTML details block."""
open_attr = ' open' if expanded else ''
return f"<details{open_attr}>\n<summary><strong>{title}</strong></summary>\n{body}\n</details>"
def is_public_destination(target: str) -> bool:
"""
Returns True if `target` (hostname or IP) does NOT resolve to any
private, loopback, or linklocal address.
"""
try:
addr = ipaddress.ip_address(target)
if any(addr in net for net in _PRIVATE_RANGES):
return False
return True
except ValueError:
pass
try:
addrinfo = socket.getaddrinfo(target, None)
for _, _, _, _, sockaddr in addrinfo:
ip = sockaddr[0]
addr = ipaddress.ip_address(ip)
if any(addr in net for net in _PRIVATE_RANGES):
return False
return True
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
)
+33 -93
View File
@@ -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 <query>")
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 <query>")
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 <query>")
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 <query>")
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 <query>")
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 <word>")
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 <expression>")
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 <details>)
# ==============================
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"🦆 <strong>DuckDuckGo: {escape(query)}</strong><br><br>Error fetching results. Try again later."
f"🦆 <strong>DuckDuckGo: {safe_query}</strong><br><br>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"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
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.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
msg = f"""<details>
<summary>🦆 DuckDuckGo: {escape(query)}</summary>
{content}
</details>"""
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"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
msg = f"""<details>
<summary>🔍 Search: {escape(query)}</summary>
{items}
</details>"""
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"• <a href='{img['image']}'>{title}</a>"
if img.get("width") and img.get("height"):
items += f" ({img['width']}×{img['height']})"
items += "<br>"
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"<br>🔍 <a href='{search_url}'>View all images</a>"
msg = f"""<details>
<summary>🖼️ Images: {escape(query)}</summary>
{items}
</details>"""
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"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
msg = f"""<details>
<summary>📰 News: {escape(query)}</summary>
{items}
</details>"""
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"• <a href='{v['content']}'>{title}</a><br>"
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"<br>🔍 <a href='{search_url}'>View all videos</a>"
msg = f"""<details>
<summary>🎬 Videos: {escape(query)}</summary>
{items}
</details>"""
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"🔗 <a href='{search_url}'>Search with {escape(bang_query)} on DuckDuckGo</a>"
msg = f"""<details>
<summary>🎯 Bang: {escape(bang_query)}</summary>
{content}
</details>"""
safe_query = html_escape(bang_query)
search_url = f"https://duckduckgo.com/?q={html_escape(bang_query)}"
content = f"🔗 <a href='{search_url}'>Search with {safe_query} on DuckDuckGo</a>"
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 = """
<strong>🎯 DuckDuckGo Bangs</strong><br>
@@ -302,7 +247,6 @@ Usage: <code>!ddg bang !bang query</code><br><br>
"""
await bot.api.send_markdown_message(room.room_id, msg)
async def send_help(room, bot):
help_msg = """
<strong>🦆 DuckDuckGo Commands</strong><br>
@@ -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__ = """
+57 -207
View File
@@ -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 = """
<strong>🔍 DNSDumpster Commands:</strong>
usage = """<strong>🔍 DNSDumpster Commands:</strong>
<strong>!dnsdumpster &lt;domain_name&gt;</strong> - Get comprehensive DNS reconnaissance for a domain
<strong>!dnsdumpster test</strong> - Test API connection
<strong>Examples:</strong>
• <code>!dnsdumpster google.com</code>
• <code>!dnsdumpster github.com</code>
• <code>!dnsdumpster example.com</code>
<strong>Rate Limit:</strong> 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
}
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"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>Test Domain: {test_domain}<br>"
logging.info(f"Testing DNSDumpster API with domain: {test_domain}")
response = requests.get(url, headers=headers, timeout=15)
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>"
debug_info += f"<strong>Status Code:</strong> {response.status_code}<br>"
debug_info += f"<strong>Test Domain:</strong> {test_domain}<br>"
debug_info += f"<strong>Headers Used:</strong> X-API-Key<br>"
if response.status_code == 200:
data = response.json()
debug_info += "<strong>✅ SUCCESS - API is working!</strong><br>"
debug_info += f"<strong>Response Keys:</strong> {list(data.keys())}<br>"
# Show some sample data
if status == 200:
data = await response.json()
debug_info += "<strong>✅ SUCCESS</strong><br>"
if data.get('a'):
debug_info += f"<strong>A Records Found:</strong> {len(data['a'])}<br>"
if data.get('ns'):
debug_info += f"<strong>NS Records Found:</strong> {len(data['ns'])}<br>"
if data.get('total_a_recs'):
debug_info += f"<strong>Total A Records:</strong> {data['total_a_recs']}<br>"
elif response.status_code == 400:
debug_info += "<strong>❌ Bad Request - Check domain format</strong><br>"
debug_info += f"<strong>Response:</strong> {response.text[:200]}<br>"
elif response.status_code == 401:
debug_info += f"A Records Found: {len(data['a'])}<br>"
elif status == 401:
debug_info += "<strong>❌ Unauthorized - Invalid API key</strong><br>"
elif response.status_code == 429:
debug_info += "<strong>⚠️ Rate Limit Exceeded - Wait 2 seconds</strong><br>"
elif status == 429:
debug_info += "<strong>⚠️ Rate Limit Exceeded</strong><br>"
else:
debug_info += f"<strong>❌ Error:</strong> {response.status_code} - {response.text[:200]}<br>"
debug_info += f"<strong>❌ Error:</strong> {status}<br>"
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
}
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]}")
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()
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"<strong>🔍 DNSDumpster Report: {domain}</strong><br><br>"
# Summary statistics
safe_domain = html_escape(domain)
output = f"<strong>🔍 DNSDumpster Report: {safe_domain}</strong><br><br>"
if data.get('total_a_recs'):
output += f"<strong>📊 Summary</strong><br>"
output += f" • <strong>Total A Records:</strong> {data['total_a_recs']}<br>"
output += f"<strong>📊 Summary</strong><br>Total A Records: {data['total_a_recs']}<br>"
# A Records - Show ALL records
if data.get('a') and data['a']:
output += f"<br><strong>📍 A Records (IPv4) - {len(data['a'])} found</strong><br>"
for record in data['a']: # Show ALL A records
host = record.get('host', 'N/A')
ips = record.get('ips', [])
output += f" • <strong>{host}</strong><br>"
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})<br>"
output += f" └─ {asn_name}<br>"
# Show banner information if available
banners = ip_info.get('banners', {})
if banners.get('http') or banners.get('https'):
output += f" └─ <em>Web Services:</em> "
services = []
if banners.get('http'):
services.append("HTTP")
if banners.get('https'):
services.append("HTTPS")
output += f"{', '.join(services)}<br>"
# NS Records - Show ALL records
if data.get('ns') and data['ns']:
output += f"<br><strong>🔗 NS Records (Name Servers) - {len(data['ns'])} found</strong><br>"
for record in data['ns']: # Show ALL NS records
host = record.get('host', 'N/A')
ips = record.get('ips', [])
output += f" • <strong>{host}</strong><br>"
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})<br>"
# MX Records - Show ALL records
if data.get('mx') and data['mx']:
output += f"<br><strong>📧 MX Records (Mail Servers) - {len(data['mx'])} found</strong><br>"
for record in data['mx']: # Show ALL MX records
host = record.get('host', 'N/A')
ips = record.get('ips', [])
output += f" • <strong>{host}</strong><br>"
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})<br>"
# CNAME Records - Show ALL records
if data.get('cname') and data['cname']:
output += f"<br><strong>🔀 CNAME Records - {len(data['cname'])} found</strong><br>"
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}<br>"
# TXT Records - Show ALL records
if data.get('txt') and data['txt']:
output += f"<br><strong>📄 TXT Records - {len(data['txt'])} found</strong><br>"
for txt in data['txt']: # Show ALL TXT records
# Truncate very long TXT records but show more content
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"<br><strong>{label} ({len(data[record_type])} found)</strong><br>"
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}<br>"
# Additional record types that might be present - Show ALL records
other_records = ['aaaa', 'srv', 'soa', 'ptr']
for record_type in other_records:
if data.get(record_type) and data[record_type]:
output += f"<br><strong>🔧 {record_type.upper()} Records - {len(data[record_type])} found</strong><br>"
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}<br>"
elif record_type == 'a':
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
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})<br>"
else:
output += f"{record}<br>"
host = html_escape(rec.get('host','N/A'))
ips = rec.get('ips',[])
output += f" • <strong>{host}</strong><br>"
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})<br>"
# Add rate limit reminder
output += "<br><em>💡 Rate Limit: 1 request per 2 seconds</em>"
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"<details><summary><strong>🔍 DNSDumpster Report: {domain} (Click to expand)</strong></summary>{output}</details>"
return output
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.0.1"
__author__ = "Funguy Bot"
__description__ = "DNSDumpster domain reconnaissance"
__help__ = """
<details>
<summary><strong>!dnsdumpster</strong> Comprehensive DNS mapping via DNSDumpster</summary>
<ul>
<li><code>!dnsdumpster &lt;domain&gt;</code> Full recon (A, NS, MX, CNAME, TXT, etc.)</li>
<li><code>!dnsdumpster &lt;domain&gt;</code> Full recon</li>
<li><code>!dnsdumpster test</code> Test API connection</li>
</ul>
<p>Requires <strong>DNSDUMPSTER_KEY</strong> env var. Rate limit: 1 req/2 sec.</p>
<p>Requires <strong>DNSDUMPSTER_KEY</strong> env var.</p>
</details>
"""
+1203
View File
File diff suppressed because it is too large Load Diff
+41 -183
View File
@@ -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"""<strong>💣 Exploit {index}/{total}</strong><br>
return f"""<strong>💣 Exploit {index}/{total}</strong><br>
<strong>Title:</strong> {title}<br>
<strong>EDB-ID:</strong> {edb_id}<br>
<strong>Type:</strong> {exploit_type} | <strong>Platform:</strong> {platform}<br>
<strong>Author:</strong> {author} | <strong>Date:</strong> {date}<br>
<strong>URL:</strong> <a href="{url}">{url}</a>"""
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...")
headers = {
'User-Agent': 'FunguyBot/1.0',
}
# Download the CSV file
response = requests.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30)
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
# 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"""<strong>💣 Exploit-DB Search for: {query}</strong><br><br>
<strong>Direct Search:</strong><br>
<a href="{exploitdb_search_url}">{exploitdb_search_url}</a><br><br>
<strong>Google Site Search:</strong><br>
<a href="{google_search_url}">{google_search_url}</a><br><br>
<em>💡 Tip: You can also use <code>searchsploit</code> command-line tool for offline searches.</em><br>
<em>⚠️ Use responsibly and only on systems you have permission to test.</em>"""
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 <search term> [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 <search term> [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)"
)
# Try CSV search first
await bot.api.send_text_message(room.room_id, f"🔍 Searching Exploit-DB for: {safe_query}...")
exploits = await search_exploitdb_csv(query, max_results)
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)
await bot.api.send_text_message(room.room_id, "❌ Failed to search Exploit-DB (network error).")
return
if not exploits:
# Also provide search links when no results
output = f"No exploits found in local search for: <strong>{query}</strong><br><br>"
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}")
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 <strong>{safe_query}</strong>.<br>Direct: <a href='{exploitdb_url}'>Exploit-DB</a> | <a href='{google_url}'>Google</a>"
await bot.api.send_markdown_message(room.room_id, msg)
return
total = len(exploits)
logging.info(f"Found {total} exploit(s) for: {query}")
output = f"<strong>💣 Exploit-DB Search Results for: {safe_query}</strong><br><br>"
for idx, exp in enumerate(exploits, 1):
output += format_exploit(exp, idx, total) + "<br><br>"
output += "<em>⚠️ Use responsibly</em>"
# Format all results
output = f"<strong>💣 Exploit-DB Search Results for: {query}</strong><br><br>"
for idx, exploit in enumerate(exploits, 1):
output += format_exploit(exploit, idx, total)
output += "<br><br>"
output += f"<em>⚠️ Use responsibly and only on systems you have permission to test.</em>"
# Wrap in collapsible details if more than 2 results
if total > 2:
summary = f"<strong>💣 Exploit-DB: {query}</strong> ({total} results)"
output = f"<details><summary>{summary}</summary>{output}</details>"
output = collapsible_summary(f"💣 Exploit-DB: {safe_query} ({total} results)", 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__ = """
<details>
<summary><strong>!exploitdb</strong> Search Exploit Database</summary>
<p><code>!exploitdb &lt;search term&gt; [max_results]</code> Search for exploits (title, EDB-ID, type, platform, author, link).<br>
Example: <code>!exploitdb wordpress 5</code></p>
<p>Fetches from the official Exploit-DB CSV. Falls back to search links if unavailable.</p>
</details>
"""
__help__ = """<details><summary><strong>!exploitdb</strong> Search Exploit Database</summary>
<p><code>!exploitdb &lt;search term&gt; [max_results]</code></p></details>"""
+19 -28
View File
@@ -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__ = """
<details>
<summary><strong>!fortune</strong> Random fortune</summary>
<p>Runs the <code>/usr/games/fortune</code> utility and posts a random quote.</p>
</details>
"""
__help__ = """<details><summary><strong>!fortune</strong> Random fortune</summary>
<p>Runs the <code>/usr/games/fortune</code> utility.</p></details>"""
+30 -96
View File
@@ -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,66 +20,48 @@ 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}")
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}")
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'))
@@ -95,94 +73,50 @@ async def format_geolocation_results(ip, data):
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"<strong>🔍 IP Geolocation Results for {ip}</strong><br><br>"
content += f"<strong>Country:</strong> {country} ({country_code})<br>"
content += f"<strong>Region:</strong> {region}<br>"
content += f"<strong>City:</strong> {city}<br>"
content += f"<strong>Postal Code:</strong> {postal}<br>"
content += f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
content += f"<strong>Timezone:</strong> {timezone}<br>"
content += f"<strong>ISP/Organization:</strong> {isp}<br>"
content += f"<strong>ASN:</strong> {asn}<br>"
message = f"<details><summary><strong>🔍 Geolocation: {ip}</strong></summary>{content}</details>"
return message
content = (f"<strong>Country:</strong> {country} ({country_code})<br>"
f"<strong>Region:</strong> {region}<br>"
f"<strong>City:</strong> {city}<br>"
f"<strong>Postal Code:</strong> {postal}<br>"
f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
f"<strong>Timezone:</strong> {timezone}<br>"
f"<strong>ISP/Organization:</strong> {isp}<br>"
f"<strong>ASN:</strong> {asn}<br>")
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 <ip_address/domain>\nExample: !geo 8.8.8.8\nExample: !geo example.com"
)
await bot.api.send_text_message(room.room_id, "Usage: !geo <ip/domain>")
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..."
)
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 domain {query} to IP address.")
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,
"❌ That domain resolves to a private/internal IP, geo not allowed.")
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"Domain {query} resolved to IP {ip}")
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 address or domain format: {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,
"❌ Geolocation of private IP addresses is not allowed.")
await bot.api.send_text_message(room.room_id, "❌ Private IP 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__ = """
<details>
<summary><strong>!geo</strong> IP / domain geolocation</summary>
<ul>
<li><code>!geo &lt;ip&gt;</code> Locate an IP address</li>
<li><code>!geo &lt;domain&gt;</code> Resolves domain then locates</li>
</ul>
<p>Shows country, region, city, coordinates, ISP, ASN. Uses ip-api.com / ipapi.co.</p>
</details>
"""
__help__ = """<details><summary><strong>!geo</strong> IP / domain geolocation</summary>
<ul><li><code>!geo &lt;ip&gt;</code> or <code>!geo &lt;domain&gt;</code></li></ul></details>"""
+48 -110
View File
@@ -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,47 +112,37 @@ 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
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)
# 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:
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)
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_code
except requests.exceptions.RequestException as e:
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."""
"""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()
results['ssl_info'] = {
return {
'subject': dict(x[0] for x in cert['subject']),
'issuer': dict(x[0] for x in cert['issuer']),
'not_before': cert['notBefore'],
@@ -170,16 +151,21 @@ async def analyze_ssl_certificate(results, domain):
'version': cert.get('version'),
'serial_number': cert.get('serialNumber')
}
except Exception as e:
results['ssl_error'] = str(e)
return f"Error: {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"<strong>🔒 Security Headers Analysis: {results['url']}</strong><br><br>"
safe_url = html_escape(results['url'])
output = f"<strong>🔒 Security Headers Analysis: {safe_url}</strong><br><br>"
# Security Score
score = results['security_score']
@@ -296,13 +268,12 @@ async def format_header_analysis(results):
# Basic Information
output += "<strong>📊 Basic Information</strong><br>"
output += f" • <strong>Final URL:</strong> {results.get('final_url', 'N/A')}<br>"
output += f" • <strong>Final URL:</strong> {html_escape(results.get('final_url', 'N/A'))}<br>"
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>"
if results.get('redirects_to_https'):
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
else:
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
output += f" • <strong>Redirect Chain:</strong> {len(results.get('redirect_chain', []))} hops<br>"
output += "<br>"
# Security Headers Analysis
@@ -310,10 +281,10 @@ async def format_header_analysis(results):
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
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} <strong>{header}:</strong> ✅ {value}<br>"
else:
output += f"{emoji} <strong>{header}:</strong> ❌ Missing<br>"
output += "<br>"
# Other Headers (Information Disclosure)
output += "<strong>📋 Other Headers</strong><br>"
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" • 🔍 <strong>{header}:</strong> {headers[header]}<br>"
output += f" • 🔍 <strong>{header}:</strong> {html_escape(str(headers[header]))}<br>"
output += "<br>"
# SSL Certificate Information (if available)
if results.get('ssl_info'):
if results.get('ssl_info') and 'subject' in results['ssl_info']:
output += "<strong>🔐 SSL Certificate</strong><br>"
ssl_info = results['ssl_info']
if ssl_info.get('subject'):
output += f" • <strong>Subject:</strong> {ssl_info['subject'].get('commonName', 'N/A')}<br>"
output += f" • <strong>Subject:</strong> {html_escape(ssl_info['subject'].get('commonName', 'N/A'))}<br>"
if ssl_info.get('issuer'):
output += f" • <strong>Issuer:</strong> {ssl_info['issuer'].get('organizationName', 'N/A')}<br>"
output += f" • <strong>Issuer:</strong> {html_escape(ssl_info['issuer'].get('organizationName', 'N/A'))}<br>"
if ssl_info.get('not_after'):
output += f" • <strong>Expires:</strong> {ssl_info['not_after']}<br>"
if ssl_info.get('san'):
san_count = len([san for san in ssl_info['san'] if san[0] == 'DNS'])
output += f" • <strong>SAN Entries:</strong> {san_count}<br>"
output += f" • <strong>Expires:</strong> {html_escape(ssl_info['not_after'])}<br>"
output += "<br>"
# Recommendations
if results.get('recommendations'):
output += "<strong>💡 Security Recommendations</strong><br>"
for rec in results['recommendations'][:8]: # Show first 8 recommendations
for rec in results['recommendations'][:8]:
output += f"{rec}<br>"
if len(results['recommendations']) > 8:
output += f" • ... and {len(results['recommendations']) - 8} more recommendations<br>"
output += "<br>"
# Missing Headers Summary
if results.get('missing_headers'):
output += "<strong>⚠️ Critical Headers Missing</strong><br>"
for header in results['missing_headers']:
output += f" • ❌ {header}<br>"
output += "<br>"
# 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"<strong>📈 Security Rating:</strong> {rating}<br>"
output += f"<strong>📝 Assessment:</strong> {description}<br>"
# Wrap in collapsible if content is large
if len(output) > 1000:
output = f"<details><summary><strong>🔒 Security Headers Analysis: {results['url']}</strong></summary>{output}</details>"
# 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 (SSRFsafe, async)"
__help__ = """
<details>
<summary><strong>!headers</strong> HTTP security header scanner</summary>
+33 -100
View File
@@ -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 = """
<strong>📄 Infermatic Text Generation Usage:</strong>
@@ -119,75 +85,57 @@ async def show_usage(room, bot):
<strong>Examples:</strong>
• <code>!text write a python function to calculate fibonacci</code>
• <code>!text --list-models</code>
• <code>!text --use-model llama-v3-8b-instruct explain quantum computing</code>
• <code>!text --temperature 0.7 write a haiku about AI</code>
"""
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"
}
response = requests.get(url, headers=headers, timeout=30)
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()
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 = "<strong>🔧 Available Models:</strong><br><br>"
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"<strong>• {model_name}</strong><br>"
output += f" └─ ID: <code>{model_id}</code><br>"
output += f" └─ Context: {context_length}<br>"
if pricing:
prompt_price = pricing.get('prompt', '0')
completion_price = pricing.get('completion', '0')
output += f" └─ Price: ${prompt_price}/${completion_price} per 1M tokens<br>"
output += f" └─ <strong>Usage:</strong> <code>!text --use-model {model_id} &lt;prompt&gt;</code><br><br>"
# Wrap in collapsible details since list can be long
output = f"<details><summary><strong>🔧 Available Models (Click to expand)</strong></summary>{output}</details>"
# 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)
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 (numberdot 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"<strong>Model:</strong> <code>{safe_model}</code><br><strong>Prompt:</strong> {safe_prompt}<br><br><strong>Response:</strong><br><br>{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__ = """
<details>
<summary><strong>!text</strong> AI text generation (Infermatic)</summary>
+10 -46
View File
@@ -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__ = """
<details>
<summary><strong>!joke</strong> Random jokes</summary>
<p>Get random jokes from the Official Joke API.<br>
Usage: <code>!joke</code> for a general joke<br>
Usage: <code>!joke programming</code> for a programming joke</p>
<p><code>!joke</code> for general, <code>!joke programming</code> for programming jokes.</p>
</details>
"""
+13 -66
View File
@@ -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 <query> - 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"<details{open_attr}>\n<summary>📰 {title}</summary>\n\n{content}\n\n</details>"
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"<li>\n"
f"<strong>{index}. {title}</strong><br/>\n"
@@ -91,17 +54,13 @@ def _format_news_article(article: Dict, index: int) -> str:
f"</li>"
)
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"</ul>\n\n<em>Fetched {len(articles[:limit])} articles</em>"
# 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
# ---------------------------------------------------------------------------
+40 -57
View File
@@ -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)
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(response.text)
logging.info("Proxy list downloaded successfully")
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 (
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy TEXT,
latency INTEGER,
status TEXT
)
""")
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
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,))
conn.commit()
logging.info(f"Removed non-working proxy from the database: {proxy}")
return None, None
else:
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 (
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxy TEXT,
latency INTEGER,
status TEXT
)
""")
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
loop = asyncio.get_running_loop()
tested = 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()
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: **{proxy}** - Latency: **{latency} ms**")
f"✅ Anonymous SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**")
save_proxy_to_db(proxy, latency)
tested_proxies += 1
if tested_proxies >= MAX_PROXIES_IN_DB:
tested += 1
if tested >= 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:
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")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.1"
__version__ = "1.0.2"
__author__ = "Funguy Bot"
__description__ = "Working SOCKS5 proxy finder (SSRFsafe)"
__description__ = "Working SOCKS5 proxy finder (SSRFsafe, async)"
__help__ = """
<details>
<summary><strong>!proxy</strong> Random working SOCKS5 proxy</summary>
<p>Fetches, tests, and returns a random working SOCKS5 proxy with latency. Caches good proxies in SQLite.</p>
<p>Fetches, tests, and returns a random working SOCKS5 proxy with latency.</p>
</details>
"""
+25 -99
View File
@@ -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 = (
"<details><summary><strong>📖 !quote help</strong></summary>"
"<ul>"
"<li><code>!quote</code> random popular quote from Goodreads</li>"
"<li><code>!quote &lt;author&gt;</code> random quote by that author</li>"
"<li><code>!quote help</code> this</li>"
"</ul>"
"<p><b>Examples:</b><br><code>!quote</code><br>"
"<code>!quote Terence McKenna</code><br>"
"<code>!quote Oscar Wilde</code></p>"
"<p>Scraped with Playwright (headless browser).</p>"
"</details>"
)
help_html = collapsible_summary("📖 !quote help",
"<ul><li><code>!quote</code> random popular quote</li>"
"<li><code>!quote &lt;author&gt;</code> quote by author</li></ul>")
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__ = """
<details>
<summary><strong>!quote</strong> Quotes from Goodreads (scraped with Playwright)</summary>
<ul>
<li><code>!quote</code> random popular quote</li>
<li><code>!quote &lt;author&gt;</code> random quote by that author</li>
<li><code>!quote help</code></li>
</ul>
<p>No API keys, no JSON files just a real browser fetching from Goodreads.</p>
</details>
"""
__description__ = "Goodreads quotes via Playwright (headless)"
__help__ = """<details><summary><strong>!quote</strong> Quotes from Goodreads</summary>
<p><code>!quote</code> random, <code>!quote &lt;author&gt;</code>.</p></details>"""
+73 -92
View File
@@ -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)
if response.status_code == 404:
await bot.api.send_text_message(room.room_id, f"No information found for IP: {ip}")
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_code == 401:
elif response.status == 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}")
elif response.status != 200:
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}")
return
data = response.json()
data = await response.json()
# Format the response
output = f"<strong>🔍 Shodan IP Lookup: {ip}</strong><br><br>"
output = f"<strong>🔍 Shodan IP Lookup: {html_escape(ip)}</strong><br><br>"
if data.get('country_name'):
output += f"<strong>📍 Location:</strong> {data.get('city', 'N/A')}, {data.get('country_name', 'N/A')}<br>"
output += f"<strong>📍 Location:</strong> {html_escape(data.get('city', 'N/A'))}, {html_escape(data.get('country_name', 'N/A'))}<br>"
if data.get('org'):
output += f"<strong>🏢 Organization:</strong> {data['org']}<br>"
output += f"<strong>🏢 Organization:</strong> {html_escape(data['org'])}<br>"
if data.get('os'):
output += f"<strong>💻 Operating System:</strong> {data['os']}<br>"
output += f"<strong>💻 Operating System:</strong> {html_escape(data['os'])}<br>"
if data.get('ports'):
output += f"<strong>🔌 Open Ports:</strong> {', '.join(map(str, data['ports']))}<br>"
@@ -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" • <strong>Port {port}:</strong> {product} {version}<br>"
output += f" • <strong>Port {port}:</strong> {html_escape(product)} {html_escape(version)}<br>"
if banner:
output += f" <em>{banner}</em><br>"
output += f" <em>{html_escape(banner)}</em><br>"
if len(data['data']) > 5:
output += f" • ... and {len(data['data']) - 5} more services<br>"
# Wrap in collapsible if output is large
if len(output) > 500:
output = f"<details><summary><strong>🔍 Shodan IP Lookup: {ip}</strong></summary>{output}</details>"
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)
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 = response.json()
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"<strong>🔍 Shodan Search: '{query}'</strong><br>"
output = f"<strong>🔍 Shodan Search: '{html_escape(query)}'</strong><br>"
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>"
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"<strong>🌐 {ip}:{port}</strong><br>"
output += f" • <strong>Organization:</strong> {org}<br>"
output += f" • <strong>Service:</strong> {product}<br>"
output += f"<strong>🌐 {html_escape(ip)}:{port}</strong><br>"
output += f" • <strong>Organization:</strong> {html_escape(org)}<br>"
output += f" • <strong>Service:</strong> {html_escape(product)}<br>"
if match.get('location'):
loc = match['location']
if loc.get('city') and loc.get('country_name'):
output += f" • <strong>Location:</strong> {loc['city']}, {loc['country_name']}<br>"
output += f" • <strong>Location:</strong> {html_escape(loc['city'])}, {html_escape(loc['country_name'])}<br>"
output += "<br>"
@@ -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)
if response.status_code == 404:
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_code != 200:
await handle_shodan_error(room, bot, response.status_code)
elif response.status != 200:
await handle_shodan_error(room, bot, response.status)
return
data = await response.json()
data = response.json()
output = f"<strong>🔍 Shodan Host: {host}</strong><br><br>"
output = f"<strong>🔍 Shodan Host: {html_escape(host)}</strong><br><br>"
if data.get('subdomains'):
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
output += f"{subdomain}.{host}<br>"
output += f"{html_escape(subdomain)}.{html_escape(host)}<br>"
if len(data['subdomains']) > 10:
output += f" • ... and {len(data['subdomains']) - 10} more<br>"
if data.get('tags'):
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(data['tags'])}<br>"
output += f"<br><strong>🏷️ Tags:</strong> {', '.join(html_escape(t) for t in data['tags'])}<br>"
if data.get('data'):
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
@@ -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)
if response.status_code != 200:
await handle_shodan_error(room, bot, response.status_code)
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()
data = response.json()
output = f"<strong>🔍 Shodan Count: '{query}'</strong><br><br>"
output = f"<strong>🔍 Shodan Count: '{html_escape(query)}'</strong><br><br>"
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>"
# Show top countries if available
if data.get('facets') and 'country' in data['facets']:
output += "<br><strong>🌍 Top Countries:</strong><br>"
for country in data['facets']['country'][:5]:
output += f"{country['value']}: {country['count']:,}<br>"
output += f"{html_escape(country['value'])}: {country['count']:,}<br>"
# Show top organizations if available
if data.get('facets') and 'org' in data['facets']:
output += "<br><strong>🏢 Top Organizations:</strong><br>"
for org in data['facets']['org'][:5]:
output += f"{org['value']}: {org['count']:,}<br>"
output += f"{html_escape(org['value'])}: {org['count']:,}<br>"
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__ = """
<li><code>!shodan host &lt;domain&gt;</code> Host & subdomain enumeration</li>
<li><code>!shodan count &lt;query&gt;</code> Result counts</li>
</ul>
<strong>Search Examples:</strong>
<ul>
<li><code>!shodan search apache</code></li>
<li><code>!shodan search "port:22"</code></li>
<li><code>!shodan search "country:US product:nginx"</code></li>
<li><code>!shodan search "net:192.168.1.0/24"</code></li>
</ul>
<p>Requires <strong>SHODAN_KEY</strong> env var.</p>
</details>
"""
+253 -422
View File
@@ -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):
<strong>Examples:</strong>
<code>!sslscan example.com</code>
<code>!sslscan github.com:443</code>
<code>!sslscan localhost:8443</code>
<strong>Tests Performed:</strong>
SSL/TLS protocol support and versions
@@ -129,59 +88,24 @@ 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:
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
@@ -191,11 +115,26 @@ async def get_certificate_info(scan_results, target, port):
cert_bin = ssock.getpeercert(binary_form=True)
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin)
# Basic certificate info
subject = cert.get_subject()
issuer = cert.get_issuer()
scan_results['certificate'] = {
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')
not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
# 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)
})
return {
'subject': {
'common_name': subject.CN,
'organization': subject.O,
@@ -211,406 +150,298 @@ async def get_certificate_info(scan_results, target, port):
},
'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': []
'not_before': not_before,
'not_after': not_after,
'signature_algorithm': sig_alg,
'days_until_expiry': days_remaining,
'extensions': extensions
}
return None
# 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)
})
# 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
except Exception as e:
scan_results['certificate_error'] = str(e)
async def test_protocol_support(scan_results, target, port):
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():
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
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
context.check_hostname = False
context.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()
except:
protocols[protocol_name] = False
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
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 ctx.wrap_socket(sock, server_hostname=target):
protocols[proto_name] = True
except:
protocols[proto_name] = False
return protocols
# 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"<strong>🔐 SSL/TLS Security Scan: {scan_results['target']}:{scan_results['port']}</strong><br><br>"
# 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"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
# Certificate Information
cert = scan_results.get('certificate', {})
if cert:
output += "<strong>📜 Certificate Information</strong><br>"
output += f" • <strong>Subject:</strong> {cert.get('subject', {}).get('common_name', 'N/A')}<br>"
output += f" • <strong>Issuer:</strong> {cert.get('issuer', {}).get('common_name', 'N/A')}<br>"
output += f" • <strong>Valid From:</strong> {format_cert_date(cert.get('not_before', ''))}<br>"
output += f" • <strong>Valid Until:</strong> {format_cert_date(cert.get('not_after', ''))}<br>"
output += f" • <strong>Expires In:</strong> {cert.get('days_until_expiry', 'N/A')} days<br>"
output += f" • <strong>Signature Algorithm:</strong> {cert.get('signature_algorithm', 'N/A')}<br>"
output += "<br>"
# Protocol Support
output += "<strong>🔌 Protocol Support</strong><br>"
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)"
else:
emoji = "" if supported else ""
# Highlight insecure protocols
if proto in ['SSLv2', 'SSLv3'] and supported:
emoji = "🔴"
elif proto in ['TLSv1.3'] and supported:
emoji = ""
output += f"{emoji} <strong>{proto}:</strong> {status if 'status' in locals() else 'Supported' if supported else 'Not Supported'}<br>"
output += "<br>"
# Cipher Information
ciphers = scan_results.get('ciphers', {})
if ciphers.get('supported_ciphers'):
output += "<strong>🔐 Cipher Suites</strong><br>"
output += f" • <strong>Negotiated:</strong> {ciphers.get('negotiated_cipher', 'Unknown')}<br>"
output += f" • <strong>Total Supported:</strong> {len(ciphers['supported_ciphers'])}<br>"
# 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" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
for cipher in weak_ciphers[:3]:
output += f" └─ 🔴 {cipher}<br>"
# 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>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
output += "<br>"
# Vulnerabilities
vulnerabilities = scan_results.get('vulnerabilities', [])
if vulnerabilities:
output += "<strong>⚠️ Security Vulnerabilities</strong><br>"
for vuln in vulnerabilities[:5]: # Show top 5
severity_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
output += f"{severity_emoji} <strong>{vuln['name']}</strong> ({vuln['severity']})<br>"
output += f" └─ {vuln['description']}<br>"
output += "<br>"
# Recommendations
recommendations = scan_results.get('recommendations', [])
if recommendations:
output += "<strong>💡 Security Recommendations</strong><br>"
for rec in recommendations[:8]:
output += f"{rec}<br>"
output += "<br>"
# Quick Assessment
output += "<strong>📊 Quick Assessment</strong><br>"
if score >= 90:
output += " • ✅ Excellent TLS configuration<br>"
output += " • ✅ Modern protocols and ciphers<br>"
output += " • ✅ Good certificate management<br>"
elif score >= 70:
output += " • ⚠️ Good configuration with minor issues<br>"
output += " • 🔧 Some improvements recommended<br>"
else:
output += " • 🚨 Significant security issues found<br>"
output += " • 🔴 Immediate action required<br>"
# Add note about testing limitations
output += "<br><em>️ Note: Some protocol tests limited by Python security features</em>"
# Always wrap in collapsible due to comprehensive output
output = f"<details><summary><strong>🔐 SSL/TLS Scan: {scan_results['target']}:{scan_results['port']} (Score: {score}/100)</strong></summary>{output}</details>"
return output
def format_cert_date(date_str):
"""Format certificate date string for display."""
def _format_cert_date(date_str):
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
# ----- 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"<strong>🔐 SSL/TLS Security Scan: {safe_target}:{port}</strong><br><br>"
body += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
# Certificate Information
if cert_info:
body += "<strong>📜 Certificate Information</strong><br>"
body += f" • <strong>Subject:</strong> {html_escape(cert_info['subject'].get('common_name', 'N/A'))}<br>"
body += f" • <strong>Issuer:</strong> {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}<br>"
body += f" • <strong>Valid From:</strong> {_format_cert_date(cert_info['not_before'])}<br>"
body += f" • <strong>Valid Until:</strong> {_format_cert_date(cert_info['not_after'])}<br>"
days = cert_info.get('days_until_expiry', 'N/A')
body += f" • <strong>Expires In:</strong> {days} days<br>"
body += f" • <strong>Signature Algorithm:</strong> {html_escape(cert_info['signature_algorithm'])}<br>"
body += "<br>"
# Protocol Support
body += "<strong>🔌 Protocol Support</strong><br>"
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} <strong>{proto}:</strong> {status}<br>"
body += "<br>"
# Cipher Suites
body += "<strong>🔐 Cipher Suites</strong><br>"
body += f" • <strong>Total Supported:</strong> {len(supported_ciphers)}<br>"
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" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
for cipher in weak_ciphers[:3]:
body += f" └─ 🔴 {html_escape(cipher)}<br>"
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>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
body += "<br>"
# Vulnerabilities
if vulnerabilities:
body += "<strong>⚠️ Security Vulnerabilities</strong><br>"
for vuln in vulnerabilities[:5]:
sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
body += f"{sev_emoji} <strong>{html_escape(vuln['name'])}</strong> ({vuln['severity']})<br>"
body += f" └─ {html_escape(vuln['description'])}<br>"
body += "<br>"
# Recommendations
if recommendations:
body += "<strong>💡 Security Recommendations</strong><br>"
for rec in recommendations[:8]:
body += f"{rec}<br>"
body += "<br>"
# Quick Assessment
body += "<strong>📊 Quick Assessment</strong><br>"
if score >= 90:
body += " • ✅ Excellent TLS configuration<br>"
body += " • ✅ Modern protocols and ciphers<br>"
body += " • ✅ Good certificate management<br>"
elif score >= 70:
body += " • ⚠️ Good configuration with minor issues<br>"
body += " • 🔧 Some improvements recommended<br>"
else:
body += " • 🚨 Significant security issues found<br>"
body += " • 🔴 Immediate action required<br>"
body += "<br><em>️ Note: Some protocol tests limited by Python security features</em>"
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 (SSRFsafe)"
__description__ = "SSL/TLS security scanner (SSRFsafe, async)"
__help__ = """
<details>
<summary><strong>!sslscan</strong> SSL/TLS analysis</summary>
+34 -88
View File
@@ -1,82 +1,27 @@
#!/usr/bin/env python3
"""
Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room.
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:
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:
@@ -84,14 +29,18 @@ async def handle_command(room, message, bot, prefix, config):
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')
parser.add_argument('--h', type=int, default=512, help='Height of the image, default=512')
parser.add_argument('--w', type=int, default=512, help='Width of the image, default=512')
parser.add_argument('--neg', type=str, nargs='+', default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'], help='Negative prompt')
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE'], help='Sampler name, default=DPM++ SDE')
parser.add_argument('--neg', type=str, nargs='+',
default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'],
help='Negative prompt')
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE Karras'],
help='Sampler name, default=DPM++ SDE')
parser.add_argument('--seed', type=int, default=None,
help='Seed for deterministic generation (omit for random)')
parser.add_argument('prompt', type=str, nargs='*', help='Prompt for the image')
args = parser.parse_args(message.body.split()[1:]) # skip command prefix
@@ -112,22 +61,26 @@ async def handle_command(room, message, bot, prefix, config):
"width": args.w,
"height": args.h,
}
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(" ", "")
info_msg = f"""<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br><strong>Steps:</strong> {args.steps}<br><strong>Dimensions:</strong> {args.h}x{args.w}<br><strong>Sampler:</strong> {sampler_name}<br><strong>CFG Scale:</strong> {args.cfg}<br><strong>Negative Prompt:</strong> {neg_prompt_clean}</details>"""
# Optional info message (commented out to avoid spam, but can be enabled)
# neg_prompt_clean = neg_prompt.replace(" ", "")
# seed_info = f"<br><strong>Seed:</strong> {args.seed}" if args.seed is not None else ""
# info_msg = f"<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br>...</details>"
# await bot.api.send_markdown_message(room.room_id, info_msg)
# Clean up temp file
@@ -138,18 +91,10 @@ async def handle_command(room, message, bot, prefix, config):
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
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 """
<p>Generate images using self-hosted Stable Diffusion</p>
@@ -167,6 +112,7 @@ def print_help():
<li>--w W - Width of the image, default=512</li>
<li>--neg NEG - Negative prompt, default=((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))</li>
<li>--sampler SAMPLER - Sampler name, default=DPM++ SDE</li>
<li>--seed SEED - Seed for deterministic generation (omit for random)</li>
</ul>
<p>LORA List:</p>
@@ -186,14 +132,12 @@ def print_help():
</ul>
"""
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.2"
__author__ = "Funguy Bot"
__description__ = "Stable Diffusion image generation"
__description__ = "Stable Diffusion image generation (async, LORA support)"
__help__ = """
<details>
<summary><strong>!sd</strong> Generate images via Stable Diffusion</summary>
@@ -204,7 +148,9 @@ __help__ = """
<li><code>--h H --w W</code> Image dimensions (default 512)</li>
<li><code>--neg &lt;negative prompt&gt;</code></li>
<li><code>--sampler SAMPLER</code> Sampler name (default DPM++ SDE)</li>
<li><code>--seed SEED</code> Deterministic seed (optional)</li>
</ul>
<p>LORAs: <code>&lt;lora:filename:weight&gt;</code></p>
<p>Requires a locally running Stable Diffusion API.</p>
</details>
"""
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
plugins/subnet.py Subnet calculator and network splitting plugin for Funguy Bot.
Provides the following commands:
!subnet info <CIDR> Show detailed info about a network
!subnet split <CIDR> --prefix <N> Split network into smaller subnets (new prefix length)
!subnet split <CIDR> --diff <N> Split network into equal subnets (prefixlen delta)
!subnet adjacent <CIDR> <count> Show given network and next <count> adjacent ones
!subnet help Display this help
Examples:
!subnet info 192.168.4.0/26
!subnet split 192.168.4.0/24 --prefix 26
!subnet split 10.0.0.0/16 --diff 2
!subnet adjacent 192.168.4.0/26 3
"""
import ipaddress
import sys
from typing import Union
# ------------------------------- helper functions --------------------------------
def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str:
"""Return a humanreadable string with all relevant subnet details."""
nw = net.network_address
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
total = net.num_addresses
if net.version == 4:
if net.prefixlen == 32:
usable_count = 1
first = last = nw
elif net.prefixlen == 31:
usable_count = 2
first = nw
last = bc
else:
usable_count = max(0, total - 2)
first = nw + 1 if usable_count > 0 else None
last = bc - 1 if usable_count > 0 else None
else:
hosts_iter = net.hosts()
try:
first = next(hosts_iter)
last = net.network_address + (total - 1)
usable_count = total
except StopIteration:
first = last = None
usable_count = 0
lines = [
f"CIDR: {net.with_prefixlen}",
f"Network: {nw}",
f"Broadcast: {bc if bc is not None else 'N/A'}",
f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}",
f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}",
f"Total IPs: {total}",
f"Usable Hosts: {usable_count}",
]
if first is not None and last is not None:
lines.append(f"First Usable: {first}")
lines.append(f"Last Usable: {last}")
lines.append(f"Usable Range: {first} - {last}")
return "\n".join(lines)
def _split_by_prefix(net, new_prefix: int) -> str:
if new_prefix < net.prefixlen:
return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split."
out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"]
for i, sub in enumerate(net.subnets(new_prefix=new_prefix)):
out.append(f"\n-- Subnet #{i+1} --")
out.append(_fmt_subnet_info(sub))
return "\n".join(out)
def _split_by_diff(net, diff: int) -> str:
new_prefix = net.prefixlen + diff
return _split_by_prefix(net, new_prefix)
def _adjacent_networks(net, count: int) -> str:
out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"]
current = net
for i in range(count + 1):
out.append(f"\n-- Adjacent #{i} --")
out.append(_fmt_subnet_info(current))
try:
next_net_addr = current.network_address + current.num_addresses
current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True)
except ValueError:
out.append("[!] Reached address space limit.")
break
return "\n".join(out)
# ------------------------------- bot plugin entry -------------------------------
async def handle_command(room, message, bot, prefix, config):
import simplematrixbotlib as botlib
match = botlib.MessageMatch(room, message, bot, prefix)
if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")):
return
args = match.args()
if not args:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet <info|split|adjacent> ...\n"
" !subnet help show full help"
)
return
subcmd = args[0].lower()
# --- help ---
if subcmd in ("help", "-h", "--help"):
# Send nicely formatted HTML in a details tag via markdown
html = "<details><summary><strong>!subnet</strong> Subnet calculator and exploration</summary>\n"
html += "<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>\n"
html += "<h4>Commands</h4>\n"
html += "<ul>\n"
html += "<li><b>info</b> Show detailed info for a network<br>\n"
html += "<code>!subnet info &lt;CIDR&gt;</code><br>\n"
html += "Example: <code>!subnet info 192.168.1.0/24</code></li>\n"
html += "<li><b>split</b> Split a network into smaller subnets<br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code><br>\n"
html += "Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code><br>\n"
html += "<i>Alternatively, use --diff to split by prefix delta:</i><br>\n"
html += "<code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code><br>\n"
html += "Example: <code>!subnet split 10.0.0.0/16 --diff 2</code> (creates 4 subnets)</li>\n"
html += "<li><b>adjacent</b> Show the current network and adjacent ones<br>\n"
html += "<code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code><br>\n"
html += "Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>\n"
html += "</ul>\n"
html += "<h4>Notes</h4>\n"
html += "<ul>\n"
html += "<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>\n"
html += "<li>IPv6 networks list all addresses as hosts (no broadcast).</li>\n"
html += "</ul>\n"
html += "</details>"
await bot.api.send_markdown_message(room.room_id, html)
return
# --- info (or a CIDR passed directly) ---
if subcmd == "info" or "/" in subcmd:
cidr = args[1] if subcmd == "info" else subcmd
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return
await bot.api.send_text_message(room.room_id, _fmt_subnet_info(net))
return
# --- split ---
if subcmd == "split":
if len(args) < 2:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --prefix <new_prefix> OR --diff <delta>"
)
return
cidr = args[1]
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return
if "--prefix" in args:
try:
idx = args.index("--prefix")
new_prefix = int(args[idx + 1])
except (ValueError, IndexError):
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --prefix <number>"
)
return
result = _split_by_prefix(net, new_prefix)
elif "--diff" in args:
try:
idx = args.index("--diff")
diff = int(args[idx + 1])
except (ValueError, IndexError):
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet split <CIDR> --diff <delta>"
)
return
result = _split_by_diff(net, diff)
else:
await bot.api.send_text_message(
room.room_id,
"You must provide either --prefix <N> or --diff <N> for split."
)
return
await bot.api.send_text_message(room.room_id, result)
return
# --- adjacent ---
if subcmd == "adjacent":
if len(args) < 3:
await bot.api.send_text_message(
room.room_id,
"Usage: !subnet adjacent <CIDR> <count>"
)
return
cidr = args[1]
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError as e:
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
return
try:
count = int(args[2])
except ValueError:
await bot.api.send_text_message(
room.room_id,
"Count must be an integer."
)
return
result = _adjacent_networks(net, count)
await bot.api.send_text_message(room.room_id, result)
return
# Unknown subcommand
await bot.api.send_text_message(
room.room_id,
f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
)
# Plugin metadata
__version__ = "1.0.1"
__author__ = "Funguy Bot"
__description__ = "Subnet calculator, splitter, and adjacent network enumerator"
__help__ = """
<details>
<summary><strong>!subnet</strong> Subnet calculator and exploration</summary>
<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>
<ul>
<li><code>!subnet info &lt;CIDR&gt;</code> Show detailed info for a network<br>
Example: <code>!subnet info 192.168.1.0/24</code></li>
<li><code>!subnet split &lt;CIDR&gt; --prefix &lt;new_prefix&gt;</code> Split into smaller subnets<br>
Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code></li>
<li><code>!subnet split &lt;CIDR&gt; --diff &lt;delta&gt;</code> Split by prefix delta<br>
Example: <code>!subnet split 10.0.0.0/16 --diff 2</code></li>
<li><code>!subnet adjacent &lt;CIDR&gt; &lt;count&gt;</code> Show adjacent networks<br>
Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>
</ul>
</details>
"""
+192 -293
View File
@@ -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,35 +45,13 @@ 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()
}
output = await format_system_info(sysinfo)
await bot.api.send_markdown_message(room.room_id, output)
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."""
try:
# ----- Individual data collectors (all sync, run in thread) -----
def _system_overview():
return {
'hostname': socket.gethostname(),
'os': platform.system(),
@@ -95,18 +61,14 @@ async def get_system_info_basic():
'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],
'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
'users': len(psutil.users())
}
except Exception as e:
return {'error': str(e)}
async def get_cpu_info():
"""Get CPU information and usage."""
try:
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),
@@ -116,52 +78,40 @@ async def get_cpu_info():
'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"
'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
}
except Exception as e:
return {'error': str(e)}
async def get_memory_info():
"""Get memory and swap information."""
try:
memory = psutil.virtual_memory()
def _memory_info():
mem = 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,
'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
}
except Exception as e:
return {'error': str(e)}
async def get_storage_info():
"""Get storage device information."""
try:
def _storage_info():
partitions = psutil.disk_partitions()
storage_info = []
for partition in partitions:
storage_list = []
for part in partitions:
try:
usage = psutil.disk_usage(partition.mountpoint)
storage_info.append({
'device': partition.device,
'mountpoint': partition.mountpoint,
'fstype': partition.fstype,
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:
continue
# Get disk I/O statistics
pass
disk_io = psutil.disk_io_counters()
io_info = {
'read_count': disk_io.read_count if disk_io else 0,
@@ -169,143 +119,89 @@ async def get_storage_info():
'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}
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:
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
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 = []
def _process_info():
procs = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
try:
processes.append(proc.info)
procs.append(proc.info)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
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}
# 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."""
def _docker_info():
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)
containers = []
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) >= 2:
containers.append({
'name': parts[0],
'status': parts[1],
'ports': parts[2] if len(parts) > 2 else 'N/A'
})
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}
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."""
try:
temperatures = psutil.sensors_temperatures()
def _sensor_info():
temps = psutil.sensors_temperatures()
fans = psutil.sensors_fans()
battery = psutil.sensors_battery()
sensor_info = {
'temperatures': {},
'fans': {},
'battery': {}
}
# 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
]
# Fan speeds
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_info['fans'][name] = [
f"{entry.current} RPM" for entry in entries[:2]
]
# Battery information
sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]]
if battery:
sensor_info['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
return sensor_info
except Exception as e:
return {'error': str(e)}
async def get_gpu_info():
"""Get GPU information using various methods."""
def _gpu_info():
gpu_data = {}
# NVIDIA
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',
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 result.returncode == 0:
nvidia_gpus = []
for line in result.stdout.strip().split('\n'):
if line:
parts = [part.strip() for part in line.split(',')]
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_gpus.append({
nvidia.append({
'name': parts[0],
'memory_total': f"{parts[1]} MB",
'memory_used': f"{parts[2]} MB",
@@ -313,140 +209,143 @@ async def get_gpu_info():
'temperature': f"{parts[4]}°C",
'utilization': f"{parts[5]}%"
})
gpu_info['nvidia'] = nvidia_gpus
if nvidia:
gpu_data['nvidia'] = nvidia
except:
pass
# Try lspci for generic GPU detection
# lspci fallback
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
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
return gpu_info
except Exception as e:
return {'error': str(e)}
# ----- Main info gatherer -----
async def get_system_info(room, bot):
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
async def format_system_info(sysinfo):
"""Format system information for display."""
output = "<strong>💻 System Information</strong><br><br>"
# 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)
# 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")
async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu):
hostname = html_escape(system.get('hostname', 'Unknown'))
body = "<strong>💻 System Information</strong><br><br>"
# System Overview
system = sysinfo.get('system', {})
output += "<strong>🖥️ System Overview</strong><br>"
output += f" • <strong>Hostname:</strong> {system.get('hostname', 'N/A')}<br>"
output += f" • <strong>OS:</strong> {system.get('os', 'N/A')} {system.get('os_release', '')}<br>"
output += f" • <strong>Architecture:</strong> {system.get('architecture', 'N/A')}<br>"
output += f" • <strong>Uptime:</strong> {system.get('uptime', 'N/A')}<br>"
output += f" • <strong>Boot Time:</strong> {system.get('boot_time', 'N/A')}<br>"
output += f" • <strong>Users:</strong> {system.get('users', 'N/A')}<br>"
output += "<br>"
body += "<strong>🖥️ System Overview</strong><br>"
body += f"<strong>Hostname:</strong> {hostname}<br>"
body += f" • <strong>OS:</strong> {html_escape(system['os'])} {html_escape(system['os_release'])}<br>"
body += f" • <strong>Architecture:</strong> {html_escape(system['architecture'])}<br>"
body += f" • <strong>Uptime:</strong> {html_escape(system['uptime'])}<br>"
body += f" • <strong>Boot Time:</strong> {html_escape(system['boot_time'])}<br>"
body += f" • <strong>Users:</strong> {system['users']}<br><br>"
# CPU Information
cpu = sysinfo.get('cpu', {})
if 'error' not in cpu:
output += "<strong>⚡ CPU Information</strong><br>"
output += f" • <strong>Cores:</strong> {cpu.get('physical_cores', 'N/A')} physical, {cpu.get('total_cores', 'N/A')} logical<br>"
output += f" • <strong>Frequency:</strong> {cpu.get('current_frequency', 'N/A')} (max: {cpu.get('max_frequency', 'N/A')})<br>"
output += f" • <strong>Usage:</strong> {cpu.get('usage_percent', 'N/A')}%<br>"
if cpu.get('load_avg') != "N/A":
output += f" • <strong>Load Average:</strong> {', '.join([f'{load:.2f}' for load in cpu.get('load_avg', [0,0,0])])}<br>"
output += "<br>"
# CPU
body += "<strong>⚡ CPU Information</strong><br>"
body += f" • <strong>Cores:</strong> {cpu['physical_cores']} physical, {cpu['total_cores']} logical<br>"
body += f"<strong>Frequency:</strong> {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})<br>"
body += f" • <strong>Usage:</strong> {cpu['usage_percent']}%<br>"
body += f" • <strong>Load Average:</strong> {html_escape(cpu['load_avg'])}<br><br>"
# Memory Information
memory = sysinfo.get('memory', {})
if 'error' not in memory:
output += "<strong>🧠 Memory Information</strong><br>"
output += f" • <strong>Total:</strong> {memory.get('total', 'N/A')}<br>"
output += f" • <strong>Used:</strong> {memory.get('used', 'N/A')} ({memory.get('usage_percent', 'N/A')}%)<br>"
output += f" • <strong>Available:</strong> {memory.get('available', 'N/A')}<br>"
output += f" • <strong>Swap:</strong> {memory.get('swap_used', 'N/A')} / {memory.get('swap_total', 'N/A')} ({memory.get('swap_percent', 'N/A')}%)<br>"
output += "<br>"
# Memory
body += "<strong>🧠 Memory Information</strong><br>"
body += f" • <strong>Total:</strong> {html_escape(memory['total'])}<br>"
body += f"<strong>Used:</strong> {html_escape(memory['used'])} ({memory['usage_percent']}%)<br>"
body += f" • <strong>Available:</strong> {html_escape(memory['available'])}<br>"
body += f" • <strong>Swap:</strong> {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)<br><br>"
# Storage Information
storage = sysinfo.get('storage', {})
if 'error' not in storage:
output += "<strong>💾 Storage Information</strong><br>"
partitions = storage.get('partitions', [])
for partition in partitions[:3]: # Show first 3 partitions
output += f" • <strong>{partition.get('device', 'N/A')}:</strong> {partition.get('used', 'N/A')} / {partition.get('total', 'N/A')} ({partition.get('percent', 'N/A')}%)<br>"
output += "<br>"
# Storage
if storage and 'error' not in storage:
body += "<strong>💾 Storage Information</strong><br>"
for p in storage['partitions'][:3]:
body += f" • <strong>{html_escape(p['device'])}:</strong> {p['used']} / {p['total']} ({p['percent']}%)<br>"
# IO stats if wanted
io = storage.get('io_stats')
if io:
body += f" • <strong>Disk I/O:</strong> read {io['read_bytes']}, write {io['write_bytes']}<br>"
body += "<br>"
# GPU Information
gpu = sysinfo.get('gpu', {})
if gpu.get('nvidia'):
output += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
for gpu_info in gpu['nvidia']:
output += f" • <strong>{gpu_info.get('name', 'N/A')}:</strong> {gpu_info.get('utilization', 'N/A')} usage, {gpu_info.get('temperature', 'N/A')}<br>"
output += "<br>"
elif gpu.get('detected'):
output += "<strong>🎮 GPU Information</strong><br>"
for gpu_line in gpu['detected'][:2]:
output += f"{gpu_line}<br>"
output += "<br>"
# GPU
if gpu:
if 'nvidia' in gpu:
body += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
for g in gpu['nvidia']:
body += f" • <strong>{html_escape(g['name'])}:</strong> {g['utilization']} usage, {g['temperature']}<br>"
body += "<br>"
elif 'detected' in gpu:
body += "<strong>🎮 GPU Information</strong><br>"
for line in gpu['detected'][:2]:
body += f"{html_escape(line)}<br>"
body += "<br>"
# Network Information
network = sysinfo.get('network', [])
if network and 'error' not in network:
output += "<strong>🌐 Network Information</strong><br>"
for interface in network[:2]: # Show first 2 interfaces
output += f" • <strong>{interface.get('interface', 'N/A')}:</strong> {interface.get('ipv4', 'N/A')}<br>"
output += "<br>"
# Network
if network:
body += "<strong>🌐 Network Information</strong><br>"
for iface in network[:2]:
body += f" • <strong>{html_escape(iface['interface'])}:</strong> {html_escape(iface['ipv4'])}<br>"
body += "<br>"
# Process Information
processes = sysinfo.get('processes', {})
if 'error' not in processes:
output += "<strong>🔄 Top Processes (by CPU)</strong><br>"
for proc in processes.get('top_cpu', [])[:3]:
output += f" • <strong>{proc.get('name', 'N/A')}:</strong> {proc.get('cpu_percent', 0):.1f}% CPU, {proc.get('memory_percent', 0):.1f}% RAM<br>"
output += f" • <strong>Total Processes:</strong> {processes.get('total_processes', 'N/A')}<br>"
output += "<br>"
# Top Processes
if processes:
body += "<strong>🔄 Top Processes (by CPU)</strong><br>"
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" • <strong>{name}:</strong> {cpu_p:.1f}% CPU, {mem_p:.1f}% RAM<br>"
body += f" • <strong>Total Processes:</strong> {processes['total_processes']}<br><br>"
# Docker Information
docker = sysinfo.get('docker', {})
if docker.get('available'):
output += "<strong>🐳 Docker Containers</strong><br>"
for container in docker.get('containers', [])[:3]:
output += f" • <strong>{container.get('name', 'N/A')}:</strong> {container.get('status', 'N/A')}<br>"
output += f" • <strong>Total Running:</strong> {docker.get('total_running', 'N/A')}<br>"
output += "<br>"
# Docker
if docker and docker.get('available'):
body += "<strong>🐳 Docker Containers</strong><br>"
for c in docker['containers'][:3]:
body += f" • <strong>{html_escape(c['name'])}:</strong> {html_escape(c['status'])}<br>"
body += f" • <strong>Total Running:</strong> {docker['total_running']}<br><br>"
# Sensor Information
sensors = sysinfo.get('sensors', {})
if 'error' not in sensors:
# Sensors
if sensors and 'error' not in sensors:
if sensors.get('temperatures'):
output += "<strong>🌡️ Temperature Sensors</strong><br>"
body += "<strong>🌡️ Temperature Sensors</strong><br>"
for sensor, temps in list(sensors['temperatures'].items())[:2]:
output += f" • <strong>{sensor}:</strong> {', '.join(temps[:2])}<br>"
output += "<br>"
body += f" • <strong>{html_escape(sensor)}:</strong> {', '.join(temps[:2])}<br>"
body += "<br>"
if sensors.get('battery'):
battery = sensors['battery']
output += "<strong>🔋 Battery Information</strong><br>"
output += f" • <strong>Charge:</strong> {battery.get('percent', 'N/A')}%<br>"
output += f" • <strong>Plugged In:</strong> {'Yes' if battery.get('power_plugged') else 'No'}<br>"
if battery.get('time_left'):
output += f" • <strong>Time Left:</strong> {battery.get('time_left', 'N/A')}<br>"
output += "<br>"
bat = sensors['battery']
body += "<strong>🔋 Battery Information</strong><br>"
body += f" • <strong>Charge:</strong> {bat['percent']}%<br>"
body += f" • <strong>Plugged In:</strong> {'Yes' if bat['power_plugged'] else 'No'}<br>"
if bat.get('time_left'):
body += f" • <strong>Time Left:</strong> {bat['time_left']}<br>"
body += "<br>"
# Add timestamp
output += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
# Timestamp
body += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
# Wrap in collapsible due to comprehensive output
output = f"<details><summary><strong>💻 System Information - {system.get('hostname', 'Unknown')}</strong></summary>{output}</details>"
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__ = """
<details>
<summary><strong>!sysinfo</strong> System information</summary>
+45 -165
View File
@@ -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('[', '<strong>').replace(']', '</strong>')
example = html.escape(example).replace('[', '<em>').replace(']', '</em>')
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('[', '<strong>').replace(']', '</strong>')
example = example.replace('[', '<em>').replace(']', '</em>')
# Escape any HTML that might be in the original text
term = html.escape(term)
author = html.escape(author)
# Build the message
header = f"<strong>📖 Urban Dictionary: {term}</strong>"
if index is not None and total is not None:
header = f"<strong>📖 Urban Dictionary: {safe_term}</strong>"
if index and total:
header += f" (Definition {index}/{total})"
message = f"""{header}
<strong>Definition:</strong>
{definition}
"""
if example and example.strip():
message += f"""
<strong>Example:</strong>
<em>{example}</em>
"""
message += f"""
<strong>Author:</strong> {author} | 👍 {thumbs_up} 👎 {thumbs_down}
<a href="{permalink}">View on Urban Dictionary</a>
"""
return message
msg = f"""{header}
<strong>Definition:</strong><br>{definition}<br>"""
if example.strip():
msg += f"""<strong>Example:</strong><br><em>{example}</em><br>"""
msg += f"""<strong>Author:</strong> {safe_author} | 👍 {thumbs_up} 👎 {thumbs_down}<br>
<a href="{permalink}">View on Urban Dictionary</a>"""
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__ = """
<details>
<summary><strong>!ud</strong> Urban Dictionary</summary>
<ul>
<li><code>!ud</code> Random definition</li>
<li><code>!ud &lt;term&gt;</code> Top definition</li>
<li><code>!ud &lt;term&gt; &lt;index&gt;</code> Nth definition</li>
</ul>
</details>
"""
__description__ = "Urban Dictionary definitions (async)"
__help__ = """<details><summary><strong>!ud</strong> Urban Dictionary</summary>
<ul><li><code>!ud</code> random, <code>!ud &lt;term&gt;</code> top, <code>!ud &lt;term&gt; &lt;index&gt;</code></li></ul></details>"""
+67 -37
View File
@@ -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 <number> -> comic #<number> (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
if not (match.prefix() and match.command("xkcd")):
return
args = match.args()
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()
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)
# 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)
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 fetching XKCD comic: {str(e)}")
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
# ---------------------------------------------------------------------------
# Plugin Metadata
# ---------------------------------------------------------------------------
__version__ = "1.0.0"
__version__ = "1.1.0"
__author__ = "Funguy Bot"
__description__ = "Random XKCD comic"
__description__ = "Fetch random or specific xkcd comics"
__help__ = """
<details>
<summary><strong>!xkcd</strong> Random XKCD comic</summary>
<p>Posts a random XKCD comic image.</p>
<summary><strong>!xkcd</strong> xkcd comics</summary>
<ul>
<li><code>!xkcd</code> random comic</li>
<li><code>!xkcd &lt;number&gt;</code> specific comic (e.g. <code>!xkcd 538</code>)</li>
</ul>
</details>
"""
+23 -62
View File
@@ -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 <search terms>")
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'<a href="https://www.youtube.com/watch?v={video["id"]}">'
output += f'<img src="{video["thumbnails"][0]}"></img><br>'
output += f'<strong>{video["title"]}</strong><br>'
output += f'Length: {video["duration"]} | Views: {video["views"]}<br>'
if video["long_desc"]:
output += f'Description: {video["long_desc"]}<br>'
output += "</a><br>"
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'<a href="https://www.youtube.com/watch?v={vid_id}">'
output += f'<img src="{thumb}"></img><br>'
output += f'<strong>{title}</strong><br>'
output += f'Length: {duration} | Views: {views}<br></a><br>'
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'<details><summary><strong>🍄Funguy ▶YouTube Search🍄<br>⤵︎Click Here To See Results⤵︎</strong></summary>{content}</details>'
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__ = """
<details>
<summary><strong>!yt</strong> Search YouTube</summary>
<p><code>!yt &lt;search terms&gt;</code> Returns top 3 results with thumbnails and descriptions.</p>
</details>
"""
__description__ = "YouTube video search (async)"
__help__ = """<details><summary><strong>!yt</strong> Search YouTube</summary>
<p><code>!yt &lt;search terms&gt;</code></p></details>"""
+7
View File
@@ -24,3 +24,10 @@ ddgs
playwright
lxml
beautifulsoup4
cryptography
bcrypt
argon2-cffi
yara-python
asn1crypto
PyYAML
lxml