refactor: async I/O, input sanitisation, and shared utilities cleanup
This commit is contained in:
@@ -4,70 +4,52 @@
|
|||||||
Funguy Bot Class
|
Funguy Bot Class
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Importing necessary libraries and modules
|
import os
|
||||||
import os # Operating System functions
|
import logging
|
||||||
import logging # Logging library for logging messages
|
import importlib
|
||||||
import importlib # Library for dynamically importing modules
|
import simplematrixbotlib as botlib
|
||||||
import simplematrixbotlib as botlib # Library for interacting with Matrix chat
|
from dotenv import load_dotenv
|
||||||
from dotenv import load_dotenv # Library for loading environment variables from a .env file
|
import time
|
||||||
import time # Time-related functions
|
import sys
|
||||||
import sys # System-specific parameters and functions
|
import toml
|
||||||
import toml # Library for parsing TOML configuration files
|
import socket
|
||||||
import socket # For network diagnostics
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
# Importing FunguyConfig class from plugins.config module
|
|
||||||
from plugins.config import FunguyConfig
|
from plugins.config import FunguyConfig
|
||||||
|
|
||||||
|
# Rate limiter settings
|
||||||
|
RATE_LIMIT_WINDOW = 5.0 # seconds
|
||||||
|
MAX_COMMANDS_PER_WINDOW = 5
|
||||||
|
|
||||||
|
|
||||||
class FunguyBot:
|
class FunguyBot:
|
||||||
"""
|
"""
|
||||||
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
A bot class for managing plugins and handling commands in a Matrix chat environment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
|
||||||
Constructor method for FunguyBot class.
|
|
||||||
"""
|
|
||||||
print("[INIT] Starting FunguyBot initialization...")
|
print("[INIT] Starting FunguyBot initialization...")
|
||||||
|
|
||||||
# Setting up instance variables
|
self.PLUGINS_DIR = "plugins"
|
||||||
self.PLUGINS_DIR = "plugins" # Directory where plugins are stored
|
self.PLUGINS = {}
|
||||||
self.PLUGINS = {} # Dictionary to store loaded plugins
|
self.config = None
|
||||||
self.config = None # Configuration object
|
self.bot = None
|
||||||
self.bot = None # Bot object
|
self.disabled_plugins = {}
|
||||||
self.disabled_plugins = {} # Dictionary to store disabled plugins for each room
|
|
||||||
|
|
||||||
print("[INIT] Loading environment variables...")
|
# Rate limiter state: {sender: [(timestamp, room_id), ...]}
|
||||||
self.load_dotenv() # Loading environment variables from .env file
|
self._rate_limit_buckets = defaultdict(list)
|
||||||
|
|
||||||
print("[INIT] Setting up logging...")
|
load_dotenv() # load once here
|
||||||
self.setup_logging() # Setting up logging configurations
|
self.setup_logging()
|
||||||
|
self.load_plugins()
|
||||||
print("[INIT] Loading plugins...")
|
self.load_config()
|
||||||
self.load_plugins() # Loading plugins
|
self.load_disabled_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
|
|
||||||
|
|
||||||
print("[INIT] FunguyBot initialization complete!")
|
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):
|
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()
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
# Convert string to logging constant
|
|
||||||
level_map = {
|
level_map = {
|
||||||
"DEBUG": logging.DEBUG,
|
"DEBUG": logging.DEBUG,
|
||||||
"INFO": logging.INFO,
|
"INFO": logging.INFO,
|
||||||
@@ -82,37 +64,23 @@ class FunguyBot:
|
|||||||
level=level
|
level=level
|
||||||
)
|
)
|
||||||
logging.getLogger().setLevel(level)
|
logging.getLogger().setLevel(level)
|
||||||
|
|
||||||
# Optionally silence noisy libraries
|
|
||||||
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||||||
logging.getLogger("nio").setLevel(logging.WARNING)
|
logging.getLogger("nio").setLevel(logging.WARNING)
|
||||||
|
|
||||||
logging.info(f"Logging configured with level: {log_level}")
|
logging.info(f"Logging configured with level: {log_level}")
|
||||||
|
|
||||||
def load_plugins(self):
|
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):
|
for plugin_file in os.listdir(self.PLUGINS_DIR):
|
||||||
if plugin_file.endswith(".py"): # Checking if file is a Python file
|
if plugin_file.endswith(".py") and plugin_file != "__init__.py":
|
||||||
plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name
|
plugin_name = os.path.splitext(plugin_file)[0]
|
||||||
try:
|
try:
|
||||||
# Importing plugin module dynamically
|
|
||||||
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
||||||
self.PLUGINS[plugin_name] = module # Storing loaded plugin module
|
self.PLUGINS[plugin_name] = module
|
||||||
logging.info(f"Loaded plugin: {plugin_name}") # Logging successful plugin loading
|
logging.info(f"Loaded plugin: {plugin_name}")
|
||||||
except Exception as e:
|
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):
|
def setup_plugins(self):
|
||||||
"""
|
"""Call setup(bot) on any plugin that defines it, after self.bot exists."""
|
||||||
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.
|
|
||||||
"""
|
|
||||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||||
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
|
||||||
try:
|
try:
|
||||||
@@ -122,107 +90,101 @@ class FunguyBot:
|
|||||||
logging.error(f"Error during setup of plugin {plugin_name}: {e}")
|
logging.error(f"Error during setup of plugin {plugin_name}: {e}")
|
||||||
|
|
||||||
def reload_plugins(self):
|
def reload_plugins(self):
|
||||||
"""
|
self.PLUGINS.clear()
|
||||||
Method to reload all plugins.
|
|
||||||
"""
|
|
||||||
self.PLUGINS = {} # Clearing loaded plugins dictionary
|
|
||||||
# Unloading modules from sys.modules
|
|
||||||
for plugin_name in list(sys.modules.keys()):
|
for plugin_name in list(sys.modules.keys()):
|
||||||
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
if plugin_name.startswith(self.PLUGINS_DIR + "."):
|
||||||
del sys.modules[plugin_name] # Deleting plugin module from system modules
|
del sys.modules[plugin_name]
|
||||||
self.load_plugins() # Reloading plugins
|
self.load_plugins()
|
||||||
# Re-run setup for any plugin that needs it (bot already exists at this point)
|
|
||||||
if self.bot is not None:
|
if self.bot is not None:
|
||||||
self.setup_plugins()
|
self.setup_plugins()
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
"""
|
self.config = FunguyConfig()
|
||||||
Method to load configuration settings.
|
|
||||||
"""
|
|
||||||
self.config = FunguyConfig() # Creating instance of FunguyConfig to load configuration
|
|
||||||
logging.info("Configuration loaded")
|
logging.info("Configuration loaded")
|
||||||
|
|
||||||
def load_disabled_plugins(self):
|
def load_disabled_plugins(self):
|
||||||
"""
|
|
||||||
Method to load disabled plugins from configuration file.
|
|
||||||
"""
|
|
||||||
# Checking if configuration file exists
|
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading configuration data from TOML file
|
|
||||||
with open('funguy.conf', 'r') as f:
|
with open('funguy.conf', 'r') as f:
|
||||||
config_data = toml.load(f)
|
config_data = toml.load(f)
|
||||||
# Extracting disabled plugins from configuration data
|
|
||||||
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {})
|
||||||
|
|
||||||
def save_disabled_plugins(self):
|
def save_disabled_plugins(self):
|
||||||
"""
|
|
||||||
Method to save disabled plugins to configuration file.
|
|
||||||
"""
|
|
||||||
existing_config = {}
|
existing_config = {}
|
||||||
# Checking if configuration file exists
|
|
||||||
if os.path.exists('funguy.conf'):
|
if os.path.exists('funguy.conf'):
|
||||||
# Loading existing configuration data
|
|
||||||
with open('funguy.conf', 'r') as f:
|
with open('funguy.conf', 'r') as f:
|
||||||
existing_config = toml.load(f)
|
existing_config = toml.load(f)
|
||||||
# Updating configuration data with disabled plugins
|
|
||||||
existing_config['plugins'] = {'disabled': self.disabled_plugins}
|
existing_config['plugins'] = {'disabled': self.disabled_plugins}
|
||||||
# Writing updated configuration data back to file
|
|
||||||
with open('funguy.conf', 'w') as f:
|
with open('funguy.conf', 'w') as f:
|
||||||
toml.dump(existing_config, 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):
|
async def handle_commands(self, room, message):
|
||||||
"""
|
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
|
||||||
Method to handle incoming commands and dispatch them to appropriate plugins.
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix) # Matching message against bot's prefix
|
|
||||||
|
|
||||||
# Reloading plugins command
|
# 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 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
|
if sender == self.config.admin_user:
|
||||||
self.reload_plugins() # Reloading plugins
|
self.reload_plugins()
|
||||||
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully") # Sending success message
|
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully")
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
# Disable plugin command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("disable"):
|
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
|
if sender == self.config.admin_user:
|
||||||
args = match.args() # Getting command arguments
|
args = match.args()
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
if len(args) != 2:
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") # Sending usage message
|
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
|
||||||
else:
|
else:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
plugin_name, room_id = args
|
||||||
await self.disable_plugin(room_id, plugin_name) # Disabling plugin
|
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}'") # Sending success message
|
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'")
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
# Enable plugin command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("enable"):
|
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
|
if sender == self.config.admin_user:
|
||||||
args = match.args() # Getting command arguments
|
args = match.args()
|
||||||
if len(args) != 2: # Checking if correct number of arguments provided
|
if len(args) != 2:
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") # Sending usage message
|
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
|
||||||
else:
|
else:
|
||||||
plugin_name, room_id = args # Extracting plugin name and room ID
|
plugin_name, room_id = args
|
||||||
await self.enable_plugin(room_id, plugin_name) # Enabling plugin
|
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}'") # Sending success message
|
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'")
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
# Rehash config command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
|
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
|
if sender == self.config.admin_user:
|
||||||
self.rehash_config() # Rehashing configuration
|
self.rehash_config()
|
||||||
await self.bot.api.send_text_message(room.room_id, "Config rehashed") # Sending success message
|
await self.bot.api.send_text_message(room.room_id, "Config rehashed")
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
# Dispatching commands to plugins
|
# Dispatch to active plugins
|
||||||
for plugin_name, plugin_module in self.PLUGINS.items():
|
for plugin_name, plugin_module in self.PLUGINS.items():
|
||||||
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
if plugin_name not in self.disabled_plugins.get(room.room_id, []):
|
||||||
try:
|
try:
|
||||||
@@ -231,46 +193,30 @@ class FunguyBot:
|
|||||||
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
|
logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True)
|
||||||
|
|
||||||
def rehash_config(self):
|
def rehash_config(self):
|
||||||
"""
|
del self.config
|
||||||
Method to rehash the configuration settings.
|
self.config = FunguyConfig()
|
||||||
"""
|
|
||||||
del self.config # Deleting current configuration object
|
|
||||||
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration
|
|
||||||
|
|
||||||
async def disable_plugin(self, room_id, plugin_name):
|
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:
|
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]:
|
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.disabled_plugins[room_id].append(plugin_name)
|
||||||
self.save_disabled_plugins() # Saving disabled plugins to configuration file
|
self.save_disabled_plugins()
|
||||||
|
|
||||||
async def enable_plugin(self, room_id, plugin_name):
|
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]:
|
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.disabled_plugins[room_id].remove(plugin_name)
|
||||||
self.save_disabled_plugins() # Saving disabled plugins to configuration file
|
self.save_disabled_plugins()
|
||||||
|
|
||||||
def test_connectivity(self, hostname, port=443):
|
def test_connectivity(self, hostname, port=443):
|
||||||
"""
|
|
||||||
Test network connectivity to Matrix server.
|
|
||||||
"""
|
|
||||||
logging.info(f"Testing connectivity to {hostname}:{port}...")
|
logging.info(f"Testing connectivity to {hostname}:{port}...")
|
||||||
try:
|
try:
|
||||||
# Test DNS resolution
|
|
||||||
ip_address = socket.gethostbyname(hostname)
|
ip_address = socket.gethostbyname(hostname)
|
||||||
logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}")
|
logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}")
|
||||||
|
|
||||||
# Test socket connection
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(10)
|
sock.settimeout(10)
|
||||||
result = sock.connect_ex((hostname, port))
|
result = sock.connect_ex((hostname, port))
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|
||||||
if result == 0:
|
if result == 0:
|
||||||
logging.info(f"✓ Socket connection successful to {hostname}:{port}")
|
logging.info(f"✓ Socket connection successful to {hostname}:{port}")
|
||||||
return True
|
return True
|
||||||
@@ -285,46 +231,29 @@ class FunguyBot:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
|
||||||
Method to initialize and run the bot.
|
|
||||||
"""
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("FUNGUY BOT - STARTING")
|
print("FUNGUY BOT - STARTING")
|
||||||
print("="*60 + "\n")
|
print("="*60 + "\n")
|
||||||
|
|
||||||
# Retrieving Matrix credentials from environment variables
|
|
||||||
MATRIX_URL = os.getenv("MATRIX_URL")
|
MATRIX_URL = os.getenv("MATRIX_URL")
|
||||||
MATRIX_USER = os.getenv("MATRIX_USER")
|
MATRIX_USER = os.getenv("MATRIX_USER")
|
||||||
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
MATRIX_PASS = os.getenv("MATRIX_PASS")
|
||||||
|
|
||||||
# Validate credentials
|
if not MATRIX_URL or not MATRIX_USER or not MATRIX_PASS:
|
||||||
if not MATRIX_URL:
|
logging.error("Missing MATRIX_URL / MATRIX_USER / MATRIX_PASS in .env")
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Matrix URL: {MATRIX_URL}")
|
logging.info(f"Matrix URL: {MATRIX_URL}")
|
||||||
logging.info(f"Matrix User: {MATRIX_USER}")
|
logging.info(f"Matrix User: {MATRIX_USER}")
|
||||||
|
|
||||||
# Extract hostname from URL for connectivity test
|
|
||||||
hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0]
|
hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0]
|
||||||
|
|
||||||
# Test connectivity before attempting to connect
|
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
logging.info("RUNNING NETWORK DIAGNOSTICS")
|
logging.info("RUNNING NETWORK DIAGNOSTICS")
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
|
|
||||||
if not self.test_connectivity(hostname, 443):
|
if not self.test_connectivity(hostname, 443):
|
||||||
logging.error("Connectivity test failed. Please check:")
|
logging.error("Connectivity test failed. See above messages.")
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
@@ -332,69 +261,46 @@ class FunguyBot:
|
|||||||
logging.info("="*40)
|
logging.info("="*40)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logging.info(f"Creating credentials object for {MATRIX_USER}...")
|
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS)
|
||||||
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object
|
self.bot = botlib.Bot(creds, self.config)
|
||||||
logging.info("✓ Credentials object created")
|
|
||||||
|
|
||||||
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()
|
self.setup_plugins()
|
||||||
logging.info("✓ Plugin setup complete")
|
|
||||||
|
|
||||||
# ----- NEW: Expose plugins dictionary on bot object -----
|
|
||||||
self.bot.plugins = self.PLUGINS
|
self.bot.plugins = self.PLUGINS
|
||||||
logging.info("✓ Plugin dictionary exposed on bot.plugins")
|
|
||||||
# --------------------------------------------------------
|
|
||||||
|
|
||||||
# Defining listener for message events
|
|
||||||
@self.bot.listener.on_message_event
|
@self.bot.listener.on_message_event
|
||||||
async def wrapper_handle_commands(room, message):
|
async def wrapper_handle_commands(room, message):
|
||||||
await self.handle_commands(room, message) # Calling handle_commands method for incoming messages
|
await self.handle_commands(room, message)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
self.bot.run()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Fatal error during bot startup: {e}", exc_info=True)
|
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
|
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__":
|
if __name__ == "__main__":
|
||||||
print("\n" + "="*60)
|
print("\n" + "="*60)
|
||||||
print("FUNGUY BOT LAUNCHER")
|
print("FUNGUY BOT LAUNCHER")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
|
bot = None
|
||||||
try:
|
try:
|
||||||
print("Creating bot instance...")
|
bot = FunguyBot()
|
||||||
bot = FunguyBot() # Creating instance of FunguyBot
|
bot.run()
|
||||||
print("Bot instance created. Running bot...")
|
|
||||||
bot.run() # Running the bot
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n[!] Bot stopped by user")
|
print("\n[!] Bot stopped by user")
|
||||||
|
if bot:
|
||||||
|
bot.stop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n[!] Fatal error: {e}")
|
print(f"\n[!] Fatal error: {e}")
|
||||||
logging.error(f"Unhandled exception: {e}", exc_info=True)
|
logging.error(f"Unhandled exception: {e}", exc_info=True)
|
||||||
|
if bot:
|
||||||
|
bot.stop()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
+12
-73
@@ -1,119 +1,58 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to fetch the current Bitcoin price.
|
This plugin provides a command to fetch the current Bitcoin price.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/"
|
BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/"
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("btc"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("btc"):
|
||||||
logging.info("Received !btc command")
|
logging.info("Received !btc command")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch Bitcoin price data
|
|
||||||
headers = {
|
headers = {
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
'Accept-Encoding': 'gzip, deflate',
|
||||||
'User-Agent': 'FunguyBot/1.0'
|
'User-Agent': 'FunguyBot/1.0'
|
||||||
}
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
logging.info(f"Fetching Bitcoin price from {BITCOIN_API_URL}")
|
async with session.get(BITCOIN_API_URL, headers=headers, timeout=10) as response:
|
||||||
response = requests.get(BITCOIN_API_URL, headers=headers, timeout=10)
|
response.raise_for_status()
|
||||||
response.raise_for_status()
|
data = await response.json()
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data or len(data) == 0:
|
if not data or len(data) == 0:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "No Bitcoin price data available.")
|
||||||
room.room_id,
|
|
||||||
"No Bitcoin price data available."
|
|
||||||
)
|
|
||||||
logging.warning("No Bitcoin price data returned from API")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the most recent trade (last item in the array)
|
|
||||||
latest_trade = data[-1]
|
latest_trade = data[-1]
|
||||||
price = latest_trade.get('price')
|
price = latest_trade.get('price')
|
||||||
|
|
||||||
if price is None:
|
if price is None:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Could not extract Bitcoin price from API response.")
|
||||||
room.room_id,
|
|
||||||
"Could not extract Bitcoin price from API response."
|
|
||||||
)
|
|
||||||
logging.error("Price field not found in API response")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert to float and format with commas
|
|
||||||
try:
|
try:
|
||||||
price_float = float(price)
|
price_float = float(price)
|
||||||
price_formatted = f"${price_float:,.2f}"
|
price_formatted = f"${price_float:,.2f}"
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
price_formatted = f"${price}"
|
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>₿ BTC/USD</strong>"
|
||||||
message_text += f"<strong> Current Price:</strong> {price_formatted}"
|
message_text += f"<strong> Current Price:</strong> {price_formatted}"
|
||||||
|
|
||||||
message_text += ", <em>bitcointicker.co</em>"
|
message_text += ", <em>bitcointicker.co</em>"
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, message_text)
|
await bot.api.send_markdown_message(room.room_id, message_text)
|
||||||
logging.info(f"Sent Bitcoin price: {price_formatted}")
|
logging.info(f"Sent Bitcoin price: {price_formatted}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Error fetching Bitcoin price: {e}")
|
||||||
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}"
|
|
||||||
)
|
|
||||||
logging.error(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:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "An unexpected error occurred.")
|
||||||
room.room_id,
|
|
||||||
"An unexpected error occurred while fetching Bitcoin price."
|
|
||||||
)
|
|
||||||
logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True)
|
logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True)
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Current Bitcoin price"
|
__description__ = "Current Bitcoin price"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
|
|||||||
@@ -55,3 +55,28 @@ def is_public_destination(target: str) -> bool:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Cannot resolve {target}: {e}")
|
logger.warning(f"Cannot resolve {target}: {e}")
|
||||||
return False
|
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
@@ -9,19 +9,15 @@ from html import escape
|
|||||||
|
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from ddgs import DDGS
|
from ddgs import DDGS
|
||||||
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
logger = logging.getLogger("ddg")
|
logger = logging.getLogger("ddg")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Async search wrapper (ddgs is sync, run in executor)
|
||||||
# Async search wrapper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def _async_search(func, *args, **kwargs):
|
async def _async_search(func, *args, **kwargs):
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Command handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("ddg")):
|
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()
|
subcommand = args[0].lower()
|
||||||
|
|
||||||
# ---- Instant answer (default) ----
|
|
||||||
if subcommand in ("instant", "i"):
|
if subcommand in ("instant", "i"):
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg instant <query>")
|
||||||
return
|
return
|
||||||
await instant_answer(room, bot, query)
|
await instant_answer(room, bot, query)
|
||||||
|
|
||||||
# ---- Web search ----
|
|
||||||
elif subcommand == "search":
|
elif subcommand == "search":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg search <query>")
|
||||||
return
|
return
|
||||||
await web_search(room, bot, query)
|
await web_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Image search ----
|
|
||||||
elif subcommand == "image":
|
elif subcommand == "image":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg image <query>")
|
||||||
return
|
return
|
||||||
await image_search(room, bot, query)
|
await image_search(room, bot, query)
|
||||||
|
|
||||||
# ---- News search ----
|
|
||||||
elif subcommand == "news":
|
elif subcommand == "news":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg news <query>")
|
||||||
return
|
return
|
||||||
await news_search(room, bot, query)
|
await news_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Video search ----
|
|
||||||
elif subcommand == "video":
|
elif subcommand == "video":
|
||||||
query = " ".join(args[1:]) if len(args) > 1 else ""
|
query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not query:
|
if not query:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg video <query>")
|
||||||
return
|
return
|
||||||
await video_search(room, bot, query)
|
await video_search(room, bot, query)
|
||||||
|
|
||||||
# ---- Bang search ----
|
|
||||||
elif subcommand == "bang":
|
elif subcommand == "bang":
|
||||||
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
bang_query = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not bang_query:
|
if not bang_query:
|
||||||
await bang_help(room, bot)
|
await bang_help(room, bot)
|
||||||
return
|
return
|
||||||
await bang_search(room, bot, bang_query)
|
await bang_search(room, bot, bang_query)
|
||||||
|
|
||||||
# ---- Definitions ----
|
|
||||||
elif subcommand == "define":
|
elif subcommand == "define":
|
||||||
word = " ".join(args[1:]) if len(args) > 1 else ""
|
word = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not word:
|
if not word:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg define <word>")
|
||||||
return
|
return
|
||||||
await definition(room, bot, word)
|
await definition(room, bot, word)
|
||||||
|
|
||||||
# ---- Calculator ----
|
|
||||||
elif subcommand == "calc":
|
elif subcommand == "calc":
|
||||||
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
expr = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not expr:
|
if not expr:
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
await bot.api.send_text_message(room.room_id, "Usage: !ddg calc <expression>")
|
||||||
return
|
return
|
||||||
await calculator(room, bot, expr)
|
await calculator(room, bot, expr)
|
||||||
|
|
||||||
# ---- Weather ----
|
|
||||||
elif subcommand == "weather":
|
elif subcommand == "weather":
|
||||||
location = " ".join(args[1:]) if len(args) > 1 else ""
|
location = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
if not location:
|
if not location:
|
||||||
location = "current location"
|
location = "current location"
|
||||||
await weather(room, bot, location)
|
await weather(room, bot, location)
|
||||||
|
|
||||||
# ---- Help ----
|
|
||||||
elif subcommand == "help":
|
elif subcommand == "help":
|
||||||
await send_help(room, bot)
|
await send_help(room, bot)
|
||||||
|
|
||||||
# ---- Default: treat as instant answer ----
|
|
||||||
else:
|
else:
|
||||||
query = " ".join(args)
|
query = " ".join(args)
|
||||||
await instant_answer(room, bot, query)
|
await instant_answer(room, bot, query)
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
|
||||||
# Result functions (all wrapped in <details>)
|
|
||||||
# ==============================
|
|
||||||
|
|
||||||
async def instant_answer(room, bot, query):
|
async def instant_answer(room, bot, query):
|
||||||
"""Top web result wrapped in a collapsible box."""
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.text, query, max_results=1)
|
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}")
|
logger.error(f"DDG instant answer error: {e}")
|
||||||
await bot.api.send_markdown_message(
|
await bot.api.send_markdown_message(
|
||||||
room.room_id,
|
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
|
return
|
||||||
|
|
||||||
content = ""
|
content = ""
|
||||||
if results:
|
if results:
|
||||||
r = results[0]
|
r = results[0]
|
||||||
title = escape(r.get("title", "Result"))
|
title = html_escape(r.get("title", "Result"))
|
||||||
body = escape(r.get("body", ""))
|
body = html_escape(r.get("body", ""))
|
||||||
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
content = f"💡 <strong>{title}</strong><br>{body[:300]}…<br><a href='{r['href']}'>Read more</a>"
|
||||||
else:
|
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>"
|
content = f"No results found.<br>🔍 <a href='{search_url}'>Search on DuckDuckGo</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🦆 DuckDuckGo: {safe_query}", content)
|
||||||
<summary>🦆 DuckDuckGo: {escape(query)}</summary>
|
|
||||||
{content}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def web_search(room, bot, query):
|
async def web_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.text, query, max_results=5)
|
results = await _async_search(ddgs.text, query, max_results=5)
|
||||||
@@ -159,23 +126,20 @@ async def web_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
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
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for r in results:
|
for r in results:
|
||||||
title = escape(r.get("title", "Result"))
|
title = html_escape(r.get("title", "Result"))
|
||||||
body = escape(r.get("body", ""))
|
body = html_escape(r.get("body", ""))
|
||||||
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
items += f"• <a href='{r['href']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🔍 Search: {safe_query}", items)
|
||||||
<summary>🔍 Search: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def image_search(room, bot, query):
|
async def image_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.images, query, max_results=3)
|
results = await _async_search(ddgs.images, query, max_results=3)
|
||||||
@@ -185,28 +149,25 @@ async def image_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
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
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for img in results:
|
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>"
|
items += f"• <a href='{img['image']}'>{title}</a>"
|
||||||
if img.get("width") and img.get("height"):
|
if img.get("width") and img.get("height"):
|
||||||
items += f" ({img['width']}×{img['height']})"
|
items += f" ({img['width']}×{img['height']})"
|
||||||
items += "<br>"
|
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>"
|
items += f"<br>🔍 <a href='{search_url}'>View all images</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🖼️ Images: {safe_query}", items)
|
||||||
<summary>🖼️ Images: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def news_search(room, bot, query):
|
async def news_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.news, query, max_results=3)
|
results = await _async_search(ddgs.news, query, max_results=3)
|
||||||
@@ -216,23 +177,20 @@ async def news_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
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
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for n in results:
|
for n in results:
|
||||||
title = escape(n.get("title", "Article"))
|
title = html_escape(n.get("title", "Article"))
|
||||||
body = escape(n.get("body", ""))
|
body = html_escape(n.get("body", ""))
|
||||||
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
items += f"• <a href='{n['url']}'>{title}</a><br> {body[:200]}…<br><br>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"📰 News: {safe_query}", items)
|
||||||
<summary>📰 News: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def video_search(room, bot, query):
|
async def video_search(room, bot, query):
|
||||||
|
safe_query = html_escape(query)
|
||||||
try:
|
try:
|
||||||
with DDGS() as ddgs:
|
with DDGS() as ddgs:
|
||||||
results = await _async_search(ddgs.videos, query, max_results=3)
|
results = await _async_search(ddgs.videos, query, max_results=3)
|
||||||
@@ -242,49 +200,36 @@ async def video_search(room, bot, query):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not results:
|
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
|
return
|
||||||
|
|
||||||
items = ""
|
items = ""
|
||||||
for v in results:
|
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>"
|
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>"
|
items += f"<br>🔍 <a href='{search_url}'>View all videos</a>"
|
||||||
|
|
||||||
msg = f"""<details>
|
msg = collapsible_summary(f"🎬 Videos: {safe_query}", items)
|
||||||
<summary>🎬 Videos: {escape(query)}</summary>
|
|
||||||
{items}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def bang_search(room, bot, bang_query):
|
async def bang_search(room, bot, bang_query):
|
||||||
search_url = f"https://duckduckgo.com/?q={escape(bang_query)}"
|
safe_query = html_escape(bang_query)
|
||||||
content = f"🔗 <a href='{search_url}'>Search with {escape(bang_query)} on DuckDuckGo</a>"
|
search_url = f"https://duckduckgo.com/?q={html_escape(bang_query)}"
|
||||||
msg = f"""<details>
|
content = f"🔗 <a href='{search_url}'>Search with {safe_query} on DuckDuckGo</a>"
|
||||||
<summary>🎯 Bang: {escape(bang_query)}</summary>
|
msg = collapsible_summary(f"🎯 Bang: {safe_query}", content)
|
||||||
{content}
|
|
||||||
</details>"""
|
|
||||||
await bot.api.send_markdown_message(room.room_id, msg)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def definition(room, bot, word):
|
async def definition(room, bot, word):
|
||||||
await instant_answer(room, bot, f"define {word}")
|
await instant_answer(room, bot, f"define {word}")
|
||||||
|
|
||||||
|
|
||||||
async def calculator(room, bot, expr):
|
async def calculator(room, bot, expr):
|
||||||
await instant_answer(room, bot, expr)
|
await instant_answer(room, bot, expr)
|
||||||
|
|
||||||
|
|
||||||
async def weather(room, bot, location):
|
async def weather(room, bot, location):
|
||||||
await instant_answer(room, bot, f"weather {location}")
|
await instant_answer(room, bot, f"weather {location}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Help messages (no details wrapper – kept readable)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def bang_help(room, bot):
|
async def bang_help(room, bot):
|
||||||
msg = """
|
msg = """
|
||||||
<strong>🎯 DuckDuckGo Bangs</strong><br>
|
<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)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
|
|
||||||
|
|
||||||
async def send_help(room, bot):
|
async def send_help(room, bot):
|
||||||
help_msg = """
|
help_msg = """
|
||||||
<strong>🦆 DuckDuckGo Commands</strong><br>
|
<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)
|
await bot.api.send_markdown_message(room.room_id, help_msg)
|
||||||
|
|
||||||
|
__version__ = "2.1.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
__version__ = "2.1.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
__description__ = "DuckDuckGo search – collapsible results (ddgs library, no API key)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
|
|||||||
+64
-214
@@ -1,289 +1,139 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
This plugin provides DNSDumpster.com integration for domain reconnaissance and DNS mapping.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
|
DNSDUMPSTER_API_KEY = os.getenv("DNSDUMPSTER_KEY", "")
|
||||||
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
DNSDUMPSTER_API_BASE = "https://api.dnsdumpster.com"
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("dnsdumpster"):
|
||||||
logging.info("Received !dnsdumpster command")
|
logging.info("Received !dnsdumpster command")
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not DNSDUMPSTER_API_KEY:
|
if not DNSDUMPSTER_API_KEY:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room.room_id,
|
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
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if it's a test command or domain lookup
|
|
||||||
if args[0].lower() == "test":
|
if args[0].lower() == "test":
|
||||||
await test_dnsdumpster_connection(room, bot)
|
await test_dnsdumpster_connection(room, bot)
|
||||||
else:
|
else:
|
||||||
# Treat the first argument as the domain
|
|
||||||
domain = args[0].lower().strip()
|
domain = args[0].lower().strip()
|
||||||
await dnsdumpster_domain_lookup(room, bot, domain)
|
await dnsdumpster_domain_lookup(room, bot, domain)
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display DNSDumpster command usage."""
|
usage = """<strong>🔍 DNSDumpster Commands:</strong>
|
||||||
usage = """
|
|
||||||
<strong>🔍 DNSDumpster Commands:</strong>
|
|
||||||
|
|
||||||
<strong>!dnsdumpster <domain_name></strong> - Get comprehensive DNS reconnaissance for a domain
|
<strong>!dnsdumpster <domain_name></strong> - Get comprehensive DNS reconnaissance for a domain
|
||||||
<strong>!dnsdumpster test</strong> - Test API connection
|
<strong>!dnsdumpster test</strong> - Test API connection
|
||||||
|
|
||||||
<strong>Examples:</strong>
|
<strong>Examples:</strong>
|
||||||
• <code>!dnsdumpster google.com</code>
|
• <code>!dnsdumpster google.com</code>
|
||||||
• <code>!dnsdumpster github.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)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def test_dnsdumpster_connection(room, bot):
|
async def test_dnsdumpster_connection(room, bot):
|
||||||
"""Test DNSDumpster API connection."""
|
test_domain = "google.com"
|
||||||
try:
|
try:
|
||||||
test_domain = "google.com" # Changed from example.com to google.com
|
|
||||||
url = f"{DNSDUMPSTER_API_BASE}/domain/{test_domain}"
|
url = f"{DNSDUMPSTER_API_BASE}/domain/{test_domain}"
|
||||||
headers = {
|
headers = {"X-API-Key": DNSDUMPSTER_API_KEY}
|
||||||
"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
|
||||||
logging.info(f"Testing DNSDumpster API with domain: {test_domain}")
|
debug_info = f"<strong>🔧 DNSDumpster API Test</strong><br>Status Code: {status}<br>Test Domain: {test_domain}<br>"
|
||||||
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 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 += "<strong>❌ Unauthorized - Invalid API key</strong><br>"
|
|
||||||
elif response.status_code == 429:
|
|
||||||
debug_info += "<strong>⚠️ Rate Limit Exceeded - Wait 2 seconds</strong><br>"
|
|
||||||
else:
|
|
||||||
debug_info += f"<strong>❌ Error:</strong> {response.status_code} - {response.text[:200]}<br>"
|
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, debug_info)
|
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
debug_info += "<strong>✅ SUCCESS</strong><br>"
|
||||||
|
if data.get('a'):
|
||||||
|
debug_info += f"A Records Found: {len(data['a'])}<br>"
|
||||||
|
elif status == 401:
|
||||||
|
debug_info += "<strong>❌ Unauthorized - Invalid API key</strong><br>"
|
||||||
|
elif status == 429:
|
||||||
|
debug_info += "<strong>⚠️ Rate Limit Exceeded</strong><br>"
|
||||||
|
else:
|
||||||
|
debug_info += f"<strong>❌ Error:</strong> {status}<br>"
|
||||||
|
await bot.api.send_markdown_message(room.room_id, debug_info)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Test failed: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Test failed: {str(e)}")
|
||||||
|
|
||||||
async def dnsdumpster_domain_lookup(room, bot, domain):
|
async def dnsdumpster_domain_lookup(room, bot, domain):
|
||||||
"""Get comprehensive DNS reconnaissance for a domain."""
|
safe_domain = html_escape(domain)
|
||||||
try:
|
try:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {safe_domain}...")
|
||||||
url = f"{DNSDUMPSTER_API_BASE}/domain/{domain}"
|
url = f"{DNSDUMPSTER_API_BASE}/domain/{domain}"
|
||||||
headers = {
|
headers = {"X-API-Key": DNSDUMPSTER_API_KEY}
|
||||||
"X-API-Key": DNSDUMPSTER_API_KEY
|
async with aiohttp.ClientSession() as session:
|
||||||
}
|
async with session.get(url, headers=headers, timeout=30) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"API error: {response.status}")
|
||||||
|
return
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
logging.info(f"Fetching DNSDumpster data for domain: {domain}")
|
|
||||||
|
|
||||||
# Send initial processing message
|
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Processing DNS reconnaissance for {domain}...")
|
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
|
|
||||||
if response.status_code == 400:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Bad request - check domain format: {domain}")
|
|
||||||
return
|
|
||||||
elif response.status_code == 401:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Invalid DNSDumpster API key")
|
|
||||||
return
|
|
||||||
elif response.status_code == 403:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Access denied - check API key permissions")
|
|
||||||
return
|
|
||||||
elif response.status_code == 429:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Rate limit exceeded - wait 2 seconds between requests")
|
|
||||||
return
|
|
||||||
elif response.status_code != 200:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"DNSDumpster API error: {response.status_code} - {response.text[:100]}")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
logging.info(f"DNSDumpster response keys: {list(data.keys())}")
|
|
||||||
|
|
||||||
# Format the comprehensive DNS report
|
|
||||||
output = await format_dnsdumpster_report(domain, data)
|
output = await format_dnsdumpster_report(domain, data)
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent DNSDumpster data for {domain}")
|
logging.info(f"Sent DNSDumpster data for {domain}")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
except requests.exceptions.Timeout:
|
await bot.api.send_text_message(room.room_id, "Request timed out.")
|
||||||
await bot.api.send_text_message(room.room_id, "DNSDumpster API request timed out")
|
|
||||||
logging.error("DNSDumpster API timeout")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching DNSDumpster data: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
logging.error(f"Error in dnsdumpster_domain_lookup: {e}")
|
|
||||||
|
|
||||||
async def format_dnsdumpster_report(domain, data):
|
async def format_dnsdumpster_report(domain, data):
|
||||||
"""Format DNSDumpster JSON response into a readable report."""
|
safe_domain = html_escape(domain)
|
||||||
output = f"<strong>🔍 DNSDumpster Report: {domain}</strong><br><br>"
|
output = f"<strong>🔍 DNSDumpster Report: {safe_domain}</strong><br><br>"
|
||||||
|
|
||||||
# Summary statistics
|
|
||||||
if data.get('total_a_recs'):
|
if data.get('total_a_recs'):
|
||||||
output += f"<strong>📊 Summary</strong><br>"
|
output += f"<strong>📊 Summary</strong><br>Total A Records: {data['total_a_recs']}<br>"
|
||||||
output += f" • <strong>Total A Records:</strong> {data['total_a_recs']}<br>"
|
|
||||||
|
|
||||||
# A Records - Show ALL records
|
for record_type, label in [('a','A Records'),('ns','NS Records'),('mx','MX Records'),('cname','CNAME'),('txt','TXT')]:
|
||||||
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
|
|
||||||
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]:
|
if data.get(record_type) and data[record_type]:
|
||||||
output += f"<br><strong>🔧 {record_type.upper()} Records - {len(data[record_type])} found</strong><br>"
|
output += f"<br><strong>{label} ({len(data[record_type])} found)</strong><br>"
|
||||||
for record in data[record_type]: # Show ALL records
|
for rec in data[record_type]:
|
||||||
if isinstance(record, dict):
|
if record_type == 'txt':
|
||||||
# Format dictionary records nicely
|
txt = html_escape(str(rec))
|
||||||
record_str = ", ".join([f"{k}: {v}" for k, v in record.items()])
|
if len(txt) > 200:
|
||||||
if len(record_str) > 150:
|
txt = txt[:200] + "..."
|
||||||
record_str = record_str[:150] + "..."
|
output += f" • {txt}<br>"
|
||||||
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:
|
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>"
|
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
|
__version__ = "1.0.1"
|
||||||
output = f"<details><summary><strong>🔍 DNSDumpster Report: {domain} (Click to expand)</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "DNSDumpster domain reconnaissance"
|
__description__ = "DNSDumpster domain reconnaissance"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!dnsdumpster</strong> – Comprehensive DNS mapping via DNSDumpster</summary>
|
<summary><strong>!dnsdumpster</strong> – Comprehensive DNS mapping via DNSDumpster</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!dnsdumpster <domain></code> – Full recon (A, NS, MX, CNAME, TXT, etc.)</li>
|
<li><code>!dnsdumpster <domain></code> – Full recon</li>
|
||||||
<li><code>!dnsdumpster test</code> – Test API connection</li>
|
<li><code>!dnsdumpster test</code> – Test API connection</li>
|
||||||
</ul>
|
</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>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+50
-192
@@ -1,254 +1,112 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to search Exploit-DB for security exploits and vulnerabilities.
|
This plugin provides a command to search Exploit-DB for security exploits.
|
||||||
Uses the searchsploit-style approach with the files.csv database.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import simplematrixbotlib as botlib
|
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"
|
EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
|
||||||
|
|
||||||
|
|
||||||
def format_exploit(exploit, index, total):
|
def format_exploit(exploit, index, total):
|
||||||
"""
|
edb_id = html_escape(str(exploit.get('id', 'N/A')))
|
||||||
Format an exploit entry for display.
|
title = html_escape(exploit.get('description', 'No title'))
|
||||||
|
date = html_escape(exploit.get('date', 'Unknown'))
|
||||||
Args:
|
author = html_escape(exploit.get('author', 'Unknown'))
|
||||||
exploit (dict): The exploit data.
|
exploit_type = html_escape(exploit.get('type', 'Unknown'))
|
||||||
index (int): Current result index.
|
platform = html_escape(exploit.get('platform', 'Unknown'))
|
||||||
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
|
|
||||||
url = f"https://www.exploit-db.com/exploits/{edb_id}"
|
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>Title:</strong> {title}<br>
|
||||||
<strong>EDB-ID:</strong> {edb_id}<br>
|
<strong>EDB-ID:</strong> {edb_id}<br>
|
||||||
<strong>Type:</strong> {exploit_type} | <strong>Platform:</strong> {platform}<br>
|
<strong>Type:</strong> {exploit_type} | <strong>Platform:</strong> {platform}<br>
|
||||||
<strong>Author:</strong> {author} | <strong>Date:</strong> {date}<br>
|
<strong>Author:</strong> {author} | <strong>Date:</strong> {date}<br>
|
||||||
<strong>URL:</strong> <a href="{url}">{url}</a>"""
|
<strong>URL:</strong> <a href="{url}">{url}</a>"""
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
async def search_exploitdb_csv(query, max_results=5):
|
async def search_exploitdb_csv(query, max_results=5):
|
||||||
"""
|
headers = {'User-Agent': 'FunguyBot/1.0'}
|
||||||
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.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
logging.info(f"Downloading Exploit-DB CSV database...")
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
csv_data = await response.text()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error downloading CSV: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
headers = {
|
results = []
|
||||||
'User-Agent': 'FunguyBot/1.0',
|
try:
|
||||||
}
|
|
||||||
|
|
||||||
# Download the CSV file
|
|
||||||
response = requests.get(EXPLOITDB_CSV_URL, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
# Parse CSV
|
|
||||||
csv_data = response.text
|
|
||||||
csv_file = io.StringIO(csv_data)
|
csv_file = io.StringIO(csv_data)
|
||||||
reader = csv.DictReader(csv_file)
|
reader = csv.DictReader(csv_file)
|
||||||
|
|
||||||
# Search through CSV
|
|
||||||
results = []
|
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
|
|
||||||
logging.info(f"Searching CSV for: {query}")
|
|
||||||
|
|
||||||
for row in reader:
|
for row in reader:
|
||||||
# Search in description (title) and other fields
|
|
||||||
description = row.get('description', '').lower()
|
description = row.get('description', '').lower()
|
||||||
file_path = row.get('file', '').lower()
|
file_path = row.get('file', '').lower()
|
||||||
|
|
||||||
if query_lower in description or query_lower in file_path:
|
if query_lower in description or query_lower in file_path:
|
||||||
exploit = {
|
results.append({
|
||||||
'id': row.get('id', 'N/A'),
|
'id': row.get('id', 'N/A'),
|
||||||
'description': row.get('description', 'No title'),
|
'description': row.get('description', 'No title'),
|
||||||
'date': row.get('date_published', row.get('date', 'Unknown')),
|
'date': row.get('date_published', row.get('date', 'Unknown')),
|
||||||
'author': row.get('author', 'Unknown'),
|
'author': row.get('author', 'Unknown'),
|
||||||
'type': row.get('type', 'Unknown'),
|
'type': row.get('type', 'Unknown'),
|
||||||
'platform': row.get('platform', 'Unknown')
|
'platform': row.get('platform', 'Unknown')
|
||||||
}
|
})
|
||||||
results.append(exploit)
|
|
||||||
|
|
||||||
if len(results) >= max_results:
|
if len(results) >= max_results:
|
||||||
break
|
break
|
||||||
|
|
||||||
return results
|
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:
|
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
|
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):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("exploitdb"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("exploitdb"):
|
||||||
logging.info("Received !exploitdb command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
if not args:
|
||||||
if len(args) < 1:
|
await bot.api.send_text_message(room.room_id, "Usage: !exploitdb <search term> [max_results]")
|
||||||
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")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if last argument is a number (max results)
|
|
||||||
max_results = 5
|
max_results = 5
|
||||||
search_terms = args
|
search_terms = args
|
||||||
|
|
||||||
if args[-1].isdigit():
|
if args[-1].isdigit():
|
||||||
max_results = int(args[-1])
|
max_results = int(args[-1])
|
||||||
if max_results < 1:
|
if max_results < 1: max_results = 1
|
||||||
max_results = 1
|
elif max_results > 10: max_results = 10
|
||||||
elif max_results > 10:
|
|
||||||
max_results = 10
|
|
||||||
search_terms = args[:-1]
|
search_terms = args[:-1]
|
||||||
|
|
||||||
query = ' '.join(search_terms)
|
query = ' '.join(search_terms)
|
||||||
|
safe_query = html_escape(query)
|
||||||
|
|
||||||
try:
|
await bot.api.send_text_message(room.room_id, f"🔍 Searching Exploit-DB for: {safe_query}...")
|
||||||
# Send "searching" message
|
exploits = await search_exploitdb_csv(query, max_results)
|
||||||
await bot.api.send_text_message(
|
|
||||||
room.room_id,
|
|
||||||
f"🔍 Searching Exploit-DB for: {query}... (this may take a moment)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try CSV search first
|
if exploits is None:
|
||||||
exploits = await search_exploitdb_csv(query, max_results)
|
await bot.api.send_text_message(room.room_id, "❌ Failed to search Exploit-DB (network error).")
|
||||||
|
return
|
||||||
|
|
||||||
if exploits is None:
|
if not exploits:
|
||||||
# Fallback to providing search links
|
exploitdb_url = f"https://www.exploit-db.com/search?q={query}"
|
||||||
logging.warning("CSV search failed, providing search links instead")
|
google_url = f"https://www.google.com/search?q=site:exploit-db.com+{query}"
|
||||||
output = await search_exploitdb_google(query, max_results)
|
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, output)
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not exploits:
|
total = len(exploits)
|
||||||
# Also provide search links when no results
|
output = f"<strong>💣 Exploit-DB Search Results for: {safe_query}</strong><br><br>"
|
||||||
output = f"No exploits found in local search for: <strong>{query}</strong><br><br>"
|
for idx, exp in enumerate(exploits, 1):
|
||||||
output += await search_exploitdb_google(query, max_results)
|
output += format_exploit(exp, idx, total) + "<br><br>"
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
output += "<em>⚠️ Use responsibly</em>"
|
||||||
logging.info(f"No exploits found for: {query}")
|
|
||||||
return
|
|
||||||
|
|
||||||
total = len(exploits)
|
if total > 2:
|
||||||
logging.info(f"Found {total} exploit(s) for: {query}")
|
output = collapsible_summary(f"💣 Exploit-DB: {safe_query} ({total} results)", output)
|
||||||
|
|
||||||
# Format all results
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
output = f"<strong>💣 Exploit-DB Search Results for: {query}</strong><br><br>"
|
|
||||||
|
|
||||||
for idx, exploit in enumerate(exploits, 1):
|
__version__ = "1.0.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>"
|
|
||||||
|
|
||||||
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"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Exploit-DB search"
|
__description__ = "Exploit-DB search"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!exploitdb</strong> – Search Exploit Database</summary>
|
||||||
<details>
|
<p><code>!exploitdb <search term> [max_results]</code></p></details>"""
|
||||||
<summary><strong>!exploitdb</strong> – Search Exploit Database</summary>
|
|
||||||
<p><code>!exploitdb <search term> [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>
|
|
||||||
"""
|
|
||||||
|
|||||||
+19
-28
@@ -1,41 +1,32 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to get a random fortune message.
|
This plugin provides a command to get a random fortune message.
|
||||||
"""
|
"""
|
||||||
# plugins/fortune.py
|
import asyncio
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import logging
|
import logging
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("fortune"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("fortune"):
|
||||||
logging.info("Received !fortune command")
|
logging.info("Received !fortune command")
|
||||||
fortune_output = "🃏 " + subprocess.run(['/usr/games/fortune'], capture_output=True).stdout.decode('UTF-8')
|
try:
|
||||||
await bot.api.send_markdown_message(room.room_id, fortune_output)
|
proc = await asyncio.create_subprocess_exec(
|
||||||
logging.info("Sent fortune to the room")
|
'/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.")
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Random fortune message"
|
__description__ = "Random fortune message"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!fortune</strong> – Random fortune</summary>
|
||||||
<details>
|
<p>Runs the <code>/usr/games/fortune</code> utility.</p></details>"""
|
||||||
<summary><strong>!fortune</strong> – Random fortune</summary>
|
|
||||||
<p>Runs the <code>/usr/games/fortune</code> utility and posts a random quote.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+54
-120
@@ -1,18 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides IP geolocation functionality using free APIs.
|
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 logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import socket
|
import socket
|
||||||
import re
|
import re
|
||||||
|
from plugins.common import is_public_destination, html_escape, collapsible_summary
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
async def is_valid_ip(ip):
|
async def is_valid_ip(ip):
|
||||||
"""Check if the provided string is a valid IP address."""
|
|
||||||
try:
|
try:
|
||||||
socket.inet_pton(socket.AF_INET, ip)
|
socket.inet_pton(socket.AF_INET, ip)
|
||||||
return True
|
return True
|
||||||
@@ -24,165 +20,103 @@ async def is_valid_ip(ip):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_domain(domain):
|
def is_domain(domain):
|
||||||
"""Check if the provided string is a domain name."""
|
|
||||||
domain_pattern = re.compile(
|
domain_pattern = re.compile(
|
||||||
r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
|
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))
|
return bool(domain_pattern.match(domain))
|
||||||
|
|
||||||
async def resolve_domain(domain):
|
async def resolve_domain(domain):
|
||||||
"""Resolve a domain name to an IP address."""
|
|
||||||
try:
|
try:
|
||||||
return socket.gethostbyname(domain)
|
return socket.gethostbyname(domain)
|
||||||
except socket.gaierror:
|
except socket.gaierror:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def query_ip_api_com(ip):
|
async def query_ip_api_com(ip):
|
||||||
"""Query ip-api.com for geolocation information."""
|
|
||||||
url = f"http://ip-api.com/json/{ip}"
|
url = f"http://ip-api.com/json/{ip}"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url) as response:
|
async with session.get(url) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
return await response.json()
|
||||||
return data
|
|
||||||
else:
|
|
||||||
logging.error(f"ip-api.com returned status {response.status}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error querying ip-api.com: {e}")
|
logging.error(f"ip-api.com error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def query_ipapi_co(ip):
|
async def query_ipapi_co(ip):
|
||||||
"""Query ipapi.co for geolocation information (fallback)."""
|
|
||||||
url = f"https://ipapi.co/{ip}/json/"
|
url = f"https://ipapi.co/{ip}/json/"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url) as response:
|
async with session.get(url) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
return await response.json()
|
||||||
return data
|
|
||||||
else:
|
|
||||||
logging.error(f"ipapi.co returned status {response.status}")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error querying ipapi.co: {e}")
|
logging.error(f"ipapi.co error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def query_geolocation(ip):
|
async def query_geolocation(ip):
|
||||||
"""Query geolocation information using primary and fallback APIs."""
|
|
||||||
data = await query_ip_api_com(ip)
|
data = await query_ip_api_com(ip)
|
||||||
if not data or data.get('status') == 'fail':
|
if not data or data.get('status') == 'fail':
|
||||||
logging.info("Primary API failed, trying fallback API")
|
|
||||||
data = await query_ipapi_co(ip)
|
data = await query_ipapi_co(ip)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def format_geolocation_results(ip, data):
|
async def format_geolocation_results(ip, data):
|
||||||
"""Format geolocation results into a readable message."""
|
if not data or ('status' in data and data.get('status') == 'fail'):
|
||||||
if not data:
|
|
||||||
return f"🔍 No geolocation data found for {ip}."
|
return f"🔍 No geolocation data found for {ip}."
|
||||||
if 'status' in data and data.get('status') == 'fail':
|
country = data.get('country', 'N/A')
|
||||||
return f"🔍 No geolocation data found for {ip}."
|
country_code = data.get('countryCode', 'N/A')
|
||||||
if 'country' in data:
|
region = data.get('regionName', data.get('region', 'N/A'))
|
||||||
country = data.get('country', 'N/A')
|
city = data.get('city', 'N/A')
|
||||||
country_code = data.get('countryCode', 'N/A')
|
postal = data.get('zip', 'N/A')
|
||||||
region = data.get('regionName', data.get('region', 'N/A'))
|
latitude = data.get('lat', 'N/A')
|
||||||
city = data.get('city', 'N/A')
|
longitude = data.get('lon', 'N/A')
|
||||||
postal = data.get('zip', 'N/A')
|
timezone = data.get('timezone', 'N/A')
|
||||||
latitude = data.get('lat', 'N/A')
|
isp = data.get('isp', 'N/A')
|
||||||
longitude = data.get('lon', 'N/A')
|
org = data.get('org', 'N/A')
|
||||||
timezone = data.get('timezone', 'N/A')
|
asn = data.get('as', 'N/A')
|
||||||
isp = data.get('isp', 'N/A')
|
|
||||||
org = data.get('org', 'N/A')
|
content = (f"<strong>Country:</strong> {country} ({country_code})<br>"
|
||||||
asn = data.get('as', 'N/A')
|
f"<strong>Region:</strong> {region}<br>"
|
||||||
else:
|
f"<strong>City:</strong> {city}<br>"
|
||||||
country = data.get('country_name', data.get('country', 'N/A'))
|
f"<strong>Postal Code:</strong> {postal}<br>"
|
||||||
country_code = data.get('country_code', data.get('countryCode', 'N/A'))
|
f"<strong>Coordinates:</strong> {latitude}, {longitude}<br>"
|
||||||
region = data.get('region', 'N/A')
|
f"<strong>Timezone:</strong> {timezone}<br>"
|
||||||
city = data.get('city', 'N/A')
|
f"<strong>ISP/Organization:</strong> {isp}<br>"
|
||||||
postal = data.get('postal', 'N/A')
|
f"<strong>ASN:</strong> {asn}<br>")
|
||||||
latitude = data.get('latitude', 'N/A')
|
return collapsible_summary(f"🔍 Geolocation: {ip}", content)
|
||||||
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
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle the !geo command."""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("geo"):
|
||||||
args = match.args()
|
args = match.args()
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Usage: !geo <ip/domain>")
|
||||||
room.room_id,
|
|
||||||
"Usage: !geo <ip_address/domain>\nExample: !geo 8.8.8.8\nExample: !geo example.com"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
query = args[0].strip()
|
query = args[0].strip()
|
||||||
logging.info(f"Received !geo command for: {query}")
|
ip = query
|
||||||
try:
|
if is_domain(query):
|
||||||
ip = query
|
await bot.api.send_text_message(room.room_id, f"🔍 Resolving domain {html_escape(query)}...")
|
||||||
if is_domain(query):
|
ip = await resolve_domain(query)
|
||||||
await bot.api.send_text_message(
|
if not ip:
|
||||||
room.room_id,
|
await bot.api.send_text_message(room.room_id, f"Failed to resolve {html_escape(query)}.")
|
||||||
f"🔍 Resolving domain {query} to IP address..."
|
return
|
||||||
)
|
if not is_public_destination(ip):
|
||||||
ip = await resolve_domain(query)
|
await bot.api.send_text_message(room.room_id, "❌ Domain resolves to private IP.")
|
||||||
if not ip:
|
return
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, f"Resolved to {ip}")
|
||||||
f"Failed to resolve domain {query} to IP address.")
|
elif not await is_valid_ip(query):
|
||||||
return
|
await bot.api.send_text_message(room.room_id, f"Invalid IP/domain: {html_escape(query)}")
|
||||||
if not is_public_destination(ip):
|
return
|
||||||
await bot.api.send_text_message(room.room_id,
|
else:
|
||||||
"❌ That domain resolves to a private/internal IP, geo not allowed.")
|
if not is_public_destination(ip):
|
||||||
return
|
await bot.api.send_text_message(room.room_id, "❌ Private IP not allowed.")
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"Domain {query} resolved to IP {ip}")
|
|
||||||
elif not await is_valid_ip(query):
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"Invalid IP address or domain format: {query}")
|
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
if not is_public_destination(ip):
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
"❌ Geolocation of private IP addresses is not allowed.")
|
|
||||||
return
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"🔍 Looking up geolocation for {ip}...")
|
|
||||||
geo_data = await query_geolocation(ip)
|
|
||||||
result_message = await format_geolocation_results(ip, geo_data)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, result_message)
|
|
||||||
logging.info(f"Successfully sent geolocation results for {ip}")
|
|
||||||
except Exception as e:
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"An error occurred during geolocation lookup for {query}. Please try again later.")
|
|
||||||
logging.error(f"Error in geo plugin for {query}: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
geo_data = await query_geolocation(ip)
|
||||||
# Plugin Metadata
|
result = await format_geolocation_results(ip, geo_data)
|
||||||
# ---------------------------------------------------------------------------
|
await bot.api.send_markdown_message(room.room_id, result)
|
||||||
__version__ = "1.0.1"
|
|
||||||
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "IP geolocation lookup"
|
__description__ = "IP geolocation lookup"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
||||||
<details>
|
<ul><li><code>!geo <ip></code> or <code>!geo <domain></code></li></ul></details>"""
|
||||||
<summary><strong>!geo</strong> – IP / domain geolocation</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!geo <ip></code> – Locate an IP address</li>
|
|
||||||
<li><code>!geo <domain></code> – Resolves domain then locates</li>
|
|
||||||
</ul>
|
|
||||||
<p>Shows country, region, city, coordinates, ISP, ASN. Uses ip-api.com / ipapi.co.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+63
-125
@@ -3,27 +3,18 @@ This plugin provides comprehensive HTTP security header analysis.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
import ssl
|
import ssl
|
||||||
import socket
|
import socket
|
||||||
|
import datetime
|
||||||
from plugins.utils import is_public_destination
|
from plugins.common import is_public_destination, collapsible_summary, html_escape
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
"""
|
||||||
Function to handle !headers command for HTTP security header analysis.
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("headers"):
|
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):
|
async def analyze_headers(room, bot, url):
|
||||||
"""Perform comprehensive HTTP security header analysis."""
|
"""Perform comprehensive HTTP security header analysis."""
|
||||||
try:
|
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 = {
|
results = {
|
||||||
'url': url,
|
'url': url,
|
||||||
@@ -121,65 +112,60 @@ async def analyze_headers(room, bot, url):
|
|||||||
async def analyze_http_response(results, url):
|
async def analyze_http_response(results, url):
|
||||||
"""Analyze HTTP response and redirect chain."""
|
"""Analyze HTTP response and redirect chain."""
|
||||||
try:
|
try:
|
||||||
session = requests.Session()
|
async with aiohttp.ClientSession() as session:
|
||||||
session.max_redirects = 5
|
async with session.get(url, allow_redirects=True, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
||||||
|
results['final_url'] = str(response.url)
|
||||||
response = session.get(url, timeout=10, allow_redirects=True)
|
results['status_code'] = response.status
|
||||||
results['final_url'] = response.url
|
results['http_headers'] = dict(response.headers)
|
||||||
results['status_code'] = response.status_code
|
results['redirects_to_https'] = response.url.scheme == 'https'
|
||||||
results['http_headers'] = dict(response.headers)
|
# aiohttp doesn't give access to redirect history easily, so we'll mark if final URL differs
|
||||||
|
if str(response.url) != url:
|
||||||
# Check if redirects to HTTPS
|
results['redirect_chain'] = [{'url': url, 'status_code': 301}] # simplified
|
||||||
results['redirects_to_https'] = response.url.startswith('https://')
|
except aiohttp.ClientError as e:
|
||||||
|
|
||||||
# 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['http_error'] = str(e)
|
results['http_error'] = str(e)
|
||||||
|
|
||||||
async def analyze_https_response(results, url):
|
async def analyze_https_response(results, url):
|
||||||
"""Analyze HTTPS response headers."""
|
"""Analyze HTTPS response headers."""
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=10, allow_redirects=False)
|
async with aiohttp.ClientSession() as session:
|
||||||
results['https_headers'] = dict(response.headers)
|
async with session.get(url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
||||||
results['https_status'] = response.status_code
|
results['https_headers'] = dict(response.headers)
|
||||||
except requests.exceptions.RequestException as e:
|
results['https_status'] = response.status
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
results['https_error'] = str(e)
|
results['https_error'] = str(e)
|
||||||
|
|
||||||
async def analyze_ssl_certificate(results, domain):
|
async def analyze_ssl_certificate(results, domain):
|
||||||
"""Analyze SSL certificate information."""
|
"""Analyze SSL certificate information (run in thread to avoid event loop blocking)."""
|
||||||
try:
|
def _get_cert():
|
||||||
context = ssl.create_default_context()
|
try:
|
||||||
with socket.create_connection((domain, 443), timeout=10) as sock:
|
context = ssl.create_default_context()
|
||||||
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
with socket.create_connection((domain, 443), timeout=10) as sock:
|
||||||
cert = ssock.getpeercert()
|
with context.wrap_socket(sock, server_hostname=domain) as ssock:
|
||||||
|
cert = ssock.getpeercert()
|
||||||
|
return {
|
||||||
|
'subject': dict(x[0] for x in cert['subject']),
|
||||||
|
'issuer': dict(x[0] for x in cert['issuer']),
|
||||||
|
'not_before': cert['notBefore'],
|
||||||
|
'not_after': cert['notAfter'],
|
||||||
|
'san': cert.get('subjectAltName', []),
|
||||||
|
'version': cert.get('version'),
|
||||||
|
'serial_number': cert.get('serialNumber')
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
results['ssl_info'] = {
|
loop = asyncio.get_running_loop()
|
||||||
'subject': dict(x[0] for x in cert['subject']),
|
ssl_data = await loop.run_in_executor(None, _get_cert)
|
||||||
'issuer': dict(x[0] for x in cert['issuer']),
|
if isinstance(ssl_data, str):
|
||||||
'not_before': cert['notBefore'],
|
results['ssl_error'] = ssl_data
|
||||||
'not_after': cert['notAfter'],
|
else:
|
||||||
'san': cert.get('subjectAltName', []),
|
results['ssl_info'] = ssl_data
|
||||||
'version': cert.get('version'),
|
|
||||||
'serial_number': cert.get('serialNumber')
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
results['ssl_error'] = str(e)
|
|
||||||
|
|
||||||
async def calculate_security_score(results):
|
async def calculate_security_score(results):
|
||||||
"""Calculate overall security score based on headers and configuration."""
|
"""Calculate overall security score based on headers and configuration."""
|
||||||
score = 100
|
score = 100
|
||||||
missing_headers = []
|
missing_headers = []
|
||||||
|
|
||||||
# Critical security headers
|
|
||||||
critical_headers = [
|
critical_headers = [
|
||||||
'Strict-Transport-Security',
|
'Strict-Transport-Security',
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
@@ -239,7 +225,6 @@ async def generate_recommendations(results):
|
|||||||
recommendations = []
|
recommendations = []
|
||||||
headers = results.get('https_headers') or results.get('http_headers', {})
|
headers = results.get('https_headers') or results.get('http_headers', {})
|
||||||
|
|
||||||
# HSTS recommendations
|
|
||||||
if 'Strict-Transport-Security' not in headers:
|
if 'Strict-Transport-Security' not in headers:
|
||||||
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
|
recommendations.append("🔒 Implement HSTS header with max-age=31536000, includeSubDomains, and preload")
|
||||||
else:
|
else:
|
||||||
@@ -251,35 +236,21 @@ async def generate_recommendations(results):
|
|||||||
if 'preload' not in hsts:
|
if 'preload' not in hsts:
|
||||||
recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
|
recommendations.append("🔒 Consider adding preload directive to HSTS for browser preloading")
|
||||||
|
|
||||||
# CSP recommendations
|
|
||||||
if 'Content-Security-Policy' not in headers:
|
if 'Content-Security-Policy' not in headers:
|
||||||
recommendations.append("🛡️ Implement Content Security Policy to prevent XSS attacks")
|
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:
|
if 'X-Frame-Options' not in headers:
|
||||||
recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)")
|
recommendations.append("🚫 Add X-Frame-Options header to prevent clickjacking (DENY or SAMEORIGIN)")
|
||||||
|
|
||||||
# Content type options
|
|
||||||
if 'X-Content-Type-Options' not in headers:
|
if 'X-Content-Type-Options' not in headers:
|
||||||
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
|
recommendations.append("📄 Add X-Content-Type-Options: nosniff to prevent MIME type sniffing")
|
||||||
|
|
||||||
# Referrer policy
|
|
||||||
if 'Referrer-Policy' not in headers:
|
if 'Referrer-Policy' not in headers:
|
||||||
recommendations.append("🔗 Implement Referrer-Policy to control referrer information leakage")
|
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:
|
if 'Server' in headers or 'X-Powered-By' in headers:
|
||||||
recommendations.append("🕵️ Remove Server and X-Powered-By headers to avoid information disclosure")
|
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://'):
|
if not results.get('redirects_to_https') and not results['url'].startswith('https://'):
|
||||||
recommendations.append("🔐 Implement HTTP to HTTPS redirects")
|
recommendations.append("🔐 Implement HTTP to HTTPS redirects")
|
||||||
|
|
||||||
@@ -287,7 +258,8 @@ async def generate_recommendations(results):
|
|||||||
|
|
||||||
async def format_header_analysis(results):
|
async def format_header_analysis(results):
|
||||||
"""Format the header analysis results for display."""
|
"""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
|
# Security Score
|
||||||
score = results['security_score']
|
score = results['security_score']
|
||||||
@@ -296,13 +268,12 @@ async def format_header_analysis(results):
|
|||||||
|
|
||||||
# Basic Information
|
# Basic Information
|
||||||
output += "<strong>📊 Basic Information</strong><br>"
|
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>"
|
output += f" • <strong>Status Code:</strong> {results.get('status_code', 'N/A')}<br>"
|
||||||
if results.get('redirects_to_https'):
|
if results.get('redirects_to_https'):
|
||||||
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
|
output += f" • <strong>HTTPS Redirect:</strong> ✅ Enforced<br>"
|
||||||
else:
|
else:
|
||||||
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
|
output += f" • <strong>HTTPS Redirect:</strong> ❌ Not enforced<br>"
|
||||||
output += f" • <strong>Redirect Chain:</strong> {len(results.get('redirect_chain', []))} hops<br>"
|
|
||||||
output += "<br>"
|
output += "<br>"
|
||||||
|
|
||||||
# Security Headers Analysis
|
# Security Headers Analysis
|
||||||
@@ -310,10 +281,10 @@ async def format_header_analysis(results):
|
|||||||
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
|
output += "<strong>🛡️ Security Headers Analysis</strong><br>"
|
||||||
|
|
||||||
security_headers = {
|
security_headers = {
|
||||||
'Strict-Transport-Security': ('🔒', 'HSTS - HTTP Strict Transport Security'),
|
'Strict-Transport-Security': ('🔒', 'HSTS'),
|
||||||
'Content-Security-Policy': ('🛡️', 'CSP - Content Security Policy'),
|
'Content-Security-Policy': ('🛡️', 'CSP'),
|
||||||
'X-Frame-Options': ('🚫', 'Clickjacking Protection'),
|
'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)'),
|
'X-XSS-Protection': ('❌', 'XSS Protection (Deprecated)'),
|
||||||
'Referrer-Policy': ('🔗', 'Referrer Policy'),
|
'Referrer-Policy': ('🔗', 'Referrer Policy'),
|
||||||
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
'Feature-Policy': ('⚙️', 'Feature Policy'),
|
||||||
@@ -322,88 +293,55 @@ async def format_header_analysis(results):
|
|||||||
|
|
||||||
for header, (emoji, description) in security_headers.items():
|
for header, (emoji, description) in security_headers.items():
|
||||||
if header in headers:
|
if header in headers:
|
||||||
value = headers[header]
|
value = html_escape(str(headers[header]))[:100]
|
||||||
if len(value) > 100:
|
|
||||||
value = value[:100] + "..."
|
|
||||||
output += f" • {emoji} <strong>{header}:</strong> ✅ {value}<br>"
|
output += f" • {emoji} <strong>{header}:</strong> ✅ {value}<br>"
|
||||||
else:
|
else:
|
||||||
output += f" • {emoji} <strong>{header}:</strong> ❌ Missing<br>"
|
output += f" • {emoji} <strong>{header}:</strong> ❌ Missing<br>"
|
||||||
|
|
||||||
output += "<br>"
|
output += "<br>"
|
||||||
|
|
||||||
# Other Headers (Information Disclosure)
|
# Other Headers (Information Disclosure)
|
||||||
output += "<strong>📋 Other Headers</strong><br>"
|
output += "<strong>📋 Other Headers</strong><br>"
|
||||||
info_headers = ['Server', 'X-Powered-By', 'X-AspNet-Version']
|
for header in ['Server', 'X-Powered-By']:
|
||||||
for header in info_headers:
|
|
||||||
if header in headers:
|
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>"
|
output += "<br>"
|
||||||
|
|
||||||
# SSL Certificate Information (if available)
|
# 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>"
|
output += "<strong>🔐 SSL Certificate</strong><br>"
|
||||||
ssl_info = results['ssl_info']
|
ssl_info = results['ssl_info']
|
||||||
if ssl_info.get('subject'):
|
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'):
|
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'):
|
if ssl_info.get('not_after'):
|
||||||
output += f" • <strong>Expires:</strong> {ssl_info['not_after']}<br>"
|
output += f" • <strong>Expires:</strong> {html_escape(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 += "<br>"
|
output += "<br>"
|
||||||
|
|
||||||
# Recommendations
|
# Recommendations
|
||||||
if results.get('recommendations'):
|
if results.get('recommendations'):
|
||||||
output += "<strong>💡 Security Recommendations</strong><br>"
|
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>"
|
output += f" • {rec}<br>"
|
||||||
|
|
||||||
if len(results['recommendations']) > 8:
|
|
||||||
output += f" • ... and {len(results['recommendations']) - 8} more recommendations<br>"
|
|
||||||
output += "<br>"
|
output += "<br>"
|
||||||
|
|
||||||
# Missing Headers Summary
|
# Final rating
|
||||||
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']
|
|
||||||
if score >= 80:
|
if score >= 80:
|
||||||
rating = "🟢 Excellent"
|
rating = "🟢 Excellent"
|
||||||
description = "Strong security headers configuration"
|
|
||||||
elif score >= 60:
|
elif score >= 60:
|
||||||
rating = "🟡 Good"
|
rating = "🟡 Good"
|
||||||
description = "Moderate security, room for improvement"
|
|
||||||
elif score >= 40:
|
elif score >= 40:
|
||||||
rating = "🟠 Fair"
|
rating = "🟠 Fair"
|
||||||
description = "Basic security, significant improvements needed"
|
|
||||||
else:
|
else:
|
||||||
rating = "🔴 Poor"
|
rating = "🔴 Poor"
|
||||||
description = "Weak security headers configuration"
|
|
||||||
|
|
||||||
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
|
output += f"<strong>📈 Security Rating:</strong> {rating}<br>"
|
||||||
output += f"<strong>📝 Assessment:</strong> {description}<br>"
|
|
||||||
|
|
||||||
# Wrap in collapsible if content is large
|
# Wrap in collapsible details
|
||||||
if len(output) > 1000:
|
return collapsible_summary(f"🔒 Security Headers Analysis: {safe_url} (Score: {score}/100)", output)
|
||||||
output = f"<details><summary><strong>🔒 Security Headers Analysis: {results['url']}</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
__version__ = "1.0.2"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "HTTP security header analysis"
|
__description__ = "HTTP security header analysis (SSRF‑safe, async)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!headers</strong> – HTTP security header scanner</summary>
|
<summary><strong>!headers</strong> – HTTP security header scanner</summary>
|
||||||
|
|||||||
+35
-102
@@ -1,40 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for generating text using Infermatic AI API and sending it to a Matrix chat room.
|
Plugin for generating text using Infermatic AI API and sending it to a Matrix chat room.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import argparse
|
|
||||||
import json
|
import json
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from asyncio import Queue
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import re
|
import re
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
# Load environment variables from .env file in the parent directory
|
# No load_dotenv – handled centrally by funguy.py
|
||||||
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
|
|
||||||
INFERMATIC_API_KEY = os.getenv("INFERMATIC_API", "")
|
INFERMATIC_API_KEY = os.getenv("INFERMATIC_API", "")
|
||||||
DEFAULT_MODEL = os.getenv("INFERMATIC_MODEL", "Sao10K-L3.1-70B-Hanami-x1")
|
DEFAULT_MODEL = os.getenv("INFERMATIC_MODEL", "Sao10K-L3.1-70B-Hanami-x1")
|
||||||
INFERMATIC_API_BASE = "https://api.totalgpt.ai/v1"
|
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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle !text command: generate text using Infermatic AI API."""
|
"""Handle !text command: generate text using Infermatic AI API."""
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
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")):
|
if not (match.prefix() and match.command("text")):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if API key is configured
|
|
||||||
if not INFERMATIC_API_KEY:
|
if not INFERMATIC_API_KEY:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Infermatic API key not configured. Set INFERMATIC_API in .env.")
|
||||||
room.room_id,
|
|
||||||
"Infermatic API key not configured. Please set INFERMATIC_API environment variable."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse command arguments
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if it's a --list-models command
|
|
||||||
if args[0] == "--list-models":
|
if args[0] == "--list-models":
|
||||||
await list_models(room, bot)
|
await list_models(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Parse other arguments
|
|
||||||
try:
|
try:
|
||||||
# Extract options manually since argparse doesn't handle mixed positional/optional well
|
|
||||||
temperature = 0.9
|
temperature = 0.9
|
||||||
max_tokens = 512
|
max_tokens = 512
|
||||||
custom_model = None
|
custom_model = None
|
||||||
@@ -86,13 +55,11 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
prompt = ' '.join(prompt_parts).strip()
|
prompt = ' '.join(prompt_parts).strip()
|
||||||
|
|
||||||
if not prompt:
|
if not prompt:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
model = custom_model or DEFAULT_MODEL
|
model = custom_model or DEFAULT_MODEL
|
||||||
|
|
||||||
await generate_text(room, bot, prompt, model, temperature, max_tokens)
|
await generate_text(room, bot, prompt, model, temperature, max_tokens)
|
||||||
|
|
||||||
except ValueError as e:
|
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)}")
|
await bot.api.send_text_message(room.room_id, f"Error processing command: {str(e)}")
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display command usage information."""
|
|
||||||
usage = """
|
usage = """
|
||||||
<strong>📄 Infermatic Text Generation Usage:</strong>
|
<strong>📄 Infermatic Text Generation Usage:</strong>
|
||||||
|
|
||||||
@@ -119,75 +85,57 @@ async def show_usage(room, bot):
|
|||||||
<strong>Examples:</strong>
|
<strong>Examples:</strong>
|
||||||
• <code>!text write a python function to calculate fibonacci</code>
|
• <code>!text write a python function to calculate fibonacci</code>
|
||||||
• <code>!text --list-models</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)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def list_models(room, bot):
|
async def list_models(room, bot):
|
||||||
"""List all available models from Infermatic AI."""
|
|
||||||
try:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, "🔍 Fetching available models...")
|
await bot.api.send_text_message(room.room_id, "🔍 Fetching available models...")
|
||||||
|
|
||||||
url = f"{INFERMATIC_API_BASE}/models"
|
url = f"{INFERMATIC_API_BASE}/models"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, timeout=30) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
models = data.get('data', [])
|
models = data.get('data', [])
|
||||||
|
|
||||||
if not models:
|
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
|
return
|
||||||
|
|
||||||
# Format the model list
|
|
||||||
output = "<strong>🔧 Available Models:</strong><br><br>"
|
output = "<strong>🔧 Available Models:</strong><br><br>"
|
||||||
|
|
||||||
for model in models:
|
for model in models:
|
||||||
model_id = model.get('id', 'Unknown')
|
model_id = html_escape(model.get('id', 'Unknown'))
|
||||||
model_name = model.get('name', model_id)
|
model_name = html_escape(model.get('name', model_id))
|
||||||
context_length = model.get('context_length', 'Unknown')
|
context_length = model.get('context_length', 'Unknown')
|
||||||
pricing = model.get('pricing', {})
|
|
||||||
|
|
||||||
output += f"<strong>• {model_name}</strong><br>"
|
output += f"<strong>• {model_name}</strong><br>"
|
||||||
output += f" └─ ID: <code>{model_id}</code><br>"
|
output += f" └─ ID: <code>{model_id}</code><br>"
|
||||||
output += f" └─ Context: {context_length}<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} <prompt></code><br><br>"
|
output += f" └─ <strong>Usage:</strong> <code>!text --use-model {model_id} <prompt></code><br><br>"
|
||||||
|
|
||||||
# Wrap in collapsible details since list can be long
|
# Wrap in collapsible (from common)
|
||||||
output = f"<details><summary><strong>🔧 Available Models (Click to expand)</strong></summary>{output}</details>"
|
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 aiohttp.ClientError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ API error: {e}")
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Error fetching models: {str(e)}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Unexpected error: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
|
||||||
|
|
||||||
import re # add at the top of the file
|
|
||||||
|
|
||||||
async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
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:
|
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"
|
url = f"{INFERMATIC_API_BASE}/chat/completions"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
"Authorization": f"Bearer {INFERMATIC_API_KEY}",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [
|
"messages": [
|
||||||
@@ -197,49 +145,34 @@ async def generate_text(room, bot, prompt, model, temperature, max_tokens):
|
|||||||
"max_tokens": max_tokens
|
"max_tokens": max_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=headers, json=payload, timeout=120)
|
async with aiohttp.ClientSession() as session:
|
||||||
response.raise_for_status()
|
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()
|
generated_text = data.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
|
||||||
|
|
||||||
if not generated_text:
|
if not generated_text:
|
||||||
await bot.api.send_text_message(room.room_id, "No response generated.")
|
await bot.api.send_text_message(room.room_id, "No response generated.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# ---- Clean up blank lines that break list rendering ----
|
# Clean up blank lines that break list rendering
|
||||||
# Remove blank lines directly before a list item (number‐dot or hyphen).
|
|
||||||
generated_text = re.sub(r'\n\n(\d+\.)', r'\n\1', generated_text)
|
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)
|
generated_text = re.sub(r'\n\n(- )', r'\n\1', generated_text)
|
||||||
|
|
||||||
# Build a pure Markdown message (no HTML)
|
# Escape any stray HTML inside the generated text before embedding
|
||||||
output = f"**Model:** `{model}`\n\n**Prompt:** {prompt}\n\n**Response:**\n\n{generated_text}"
|
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)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "❌ Request timed out. The model is taking too long to respond.")
|
await bot.api.send_text_message(room.room_id, f"❌ API error: {e}")
|
||||||
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 Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"❌ Error generating text: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {e}")
|
||||||
finally:
|
|
||||||
if not command_queue.empty():
|
|
||||||
next_command = await command_queue.get()
|
|
||||||
await handle_command(*next_command)
|
|
||||||
|
|
||||||
|
__version__ = "1.0.3"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.2"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "AI text generation via Infermatic API (pure Markdown output)"
|
__description__ = "AI text generation via Infermatic API (async, safe)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!text</strong> – AI text generation (Infermatic)</summary>
|
<summary><strong>!text</strong> – AI text generation (Infermatic)</summary>
|
||||||
|
|||||||
+11
-47
@@ -1,93 +1,57 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for fetching jokes from the Official Joke API.
|
Plugin for fetching jokes from the Official Joke API.
|
||||||
"""
|
"""
|
||||||
# plugins/joke.py
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import simplematrixbotlib as botlib
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
|
||||||
# Handle !joke command
|
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("joke"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("joke"):
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Check if user wants a specific category
|
|
||||||
category = "general"
|
category = "general"
|
||||||
if args:
|
if args:
|
||||||
category = args[0].lower()
|
category = args[0].lower()
|
||||||
if category not in ["general", "programming"]:
|
if category not in ("general", "programming"):
|
||||||
# If invalid category, use general
|
|
||||||
category = "general"
|
category = "general"
|
||||||
|
|
||||||
logging.info(f"Fetching {category} joke")
|
logging.info(f"Fetching {category} joke")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch joke from API
|
|
||||||
if category == "programming":
|
if category == "programming":
|
||||||
url = "https://official-joke-api.appspot.com/jokes/programming/random"
|
url = "https://official-joke-api.appspot.com/jokes/programming/random"
|
||||||
else:
|
else:
|
||||||
url = "https://official-joke-api.appspot.com/random_joke"
|
url = "https://official-joke-api.appspot.com/random_joke"
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
if isinstance(data, list) and data:
|
||||||
# Handle different response formats
|
|
||||||
if isinstance(data, list) and len(data) > 0:
|
|
||||||
joke = data[0]
|
joke = data[0]
|
||||||
elif isinstance(data, dict):
|
elif isinstance(data, dict):
|
||||||
joke = data
|
joke = data
|
||||||
else:
|
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
|
return
|
||||||
|
|
||||||
# Extract joke parts
|
setup = joke.get("setup", "No setup")
|
||||||
setup = joke.get("setup", "No setup available")
|
punchline = joke.get("punchline", "No punchline")
|
||||||
punchline = joke.get("punchline", "No punchline available")
|
|
||||||
|
|
||||||
# Send the joke with a delay for better effect
|
|
||||||
await bot.api.send_text_message(room.room_id, setup)
|
await bot.api.send_text_message(room.room_id, setup)
|
||||||
# Add a small delay before the punchline for comedic timing
|
|
||||||
import asyncio
|
import asyncio
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
await bot.api.send_text_message(room.room_id, f"... {punchline}")
|
await bot.api.send_text_message(room.room_id, f"... {punchline}")
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Error fetching joke: {e}")
|
logging.error(f"Error fetching joke: {e}")
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching joke: {str(e)}")
|
await bot.api.send_text_message(room.room_id, f"Error fetching joke: {str(e)}")
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Get random jokes from the Official Joke API"
|
__description__ = "Get random jokes from the Official Joke API"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!joke</strong> – Random jokes</summary>
|
<summary><strong>!joke</strong> – Random jokes</summary>
|
||||||
<p>Get random jokes from the Official Joke API.<br>
|
<p><code>!joke</code> for general, <code>!joke programming</code> for programming jokes.</p>
|
||||||
Usage: <code>!joke</code> for a general joke<br>
|
|
||||||
Usage: <code>!joke programming</code> for a programming joke</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+13
-66
@@ -3,40 +3,19 @@ News Aggregator Plugin for Funguy Bot
|
|||||||
|
|
||||||
Fetches latest headlines from various news categories using GNews API.
|
Fetches latest headlines from various news categories using GNews API.
|
||||||
Free tier: 100 requests/day
|
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 logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Dict, Any, List
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
# Load environment variables
|
# API key loaded centrally
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Get API key from environment variable
|
|
||||||
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
||||||
|
|
||||||
# Number of articles to return per command
|
|
||||||
DEFAULT_ARTICLES = 5
|
DEFAULT_ARTICLES = 5
|
||||||
MAX_ARTICLES = 10
|
MAX_ARTICLES = 10
|
||||||
|
|
||||||
# Category mapping
|
|
||||||
CATEGORIES = {
|
CATEGORIES = {
|
||||||
"top": "general",
|
"top": "general",
|
||||||
"world": "world",
|
"world": "world",
|
||||||
@@ -49,30 +28,15 @@ CATEGORIES = {
|
|||||||
"crypto": "cryptocurrency"
|
"crypto": "cryptocurrency"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _format_news_article(article, index):
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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:
|
|
||||||
"""Format a single news article as an HTML list item."""
|
"""Format a single news article as an HTML list item."""
|
||||||
title = article.get("title", "No title")
|
title = html_escape(article.get("title", "No title"))
|
||||||
source = article.get("source", {}).get("name", "Unknown source")
|
source = html_escape((article.get("source") or {}).get("name", "Unknown"))
|
||||||
url = article.get("url", "#")
|
url = article.get("url", "#")
|
||||||
description = article.get("description", "No description available")
|
description = html_escape(article.get("description", "No description available"))
|
||||||
published = article.get("publishedAt", "")
|
|
||||||
|
|
||||||
# Truncate description if too long
|
|
||||||
if len(description) > 300:
|
if len(description) > 300:
|
||||||
description = description[:297] + "..."
|
description = description[:297] + "..."
|
||||||
|
published = article.get("publishedAt", "")
|
||||||
# Format date if available
|
|
||||||
date_str = ""
|
date_str = ""
|
||||||
if published:
|
if published:
|
||||||
try:
|
try:
|
||||||
@@ -81,7 +45,6 @@ def _format_news_article(article: Dict, index: int) -> str:
|
|||||||
date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}"
|
date_str = f" | 📅 {dt.strftime('%Y-%m-%d %H:%M')}"
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"<li>\n"
|
f"<li>\n"
|
||||||
f"<strong>{index}. {title}</strong><br/>\n"
|
f"<strong>{index}. {title}</strong><br/>\n"
|
||||||
@@ -91,17 +54,13 @@ def _format_news_article(article: Dict, index: int) -> str:
|
|||||||
f"</li>"
|
f"</li>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _fetch_news(category="general", query=None, limit=DEFAULT_ARTICLES):
|
||||||
async def _fetch_news(category: str = "general", query: str = None, limit: int = DEFAULT_ARTICLES) -> Optional[List[Dict]]:
|
|
||||||
"""Fetch news articles from GNews API."""
|
|
||||||
if not GNEWS_API_KEY:
|
if not GNEWS_API_KEY:
|
||||||
logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file")
|
logging.error("GNews API key not configured. Set GNEWS_API_KEY in .env file")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
base_url = "https://gnews.io/api/v4"
|
base_url = "https://gnews.io/api/v4"
|
||||||
|
|
||||||
if query:
|
if query:
|
||||||
# Search endpoint
|
|
||||||
url = f"{base_url}/search"
|
url = f"{base_url}/search"
|
||||||
params = {
|
params = {
|
||||||
"q": query,
|
"q": query,
|
||||||
@@ -111,7 +70,6 @@ async def _fetch_news(category: str = "general", query: str = None, limit: int =
|
|||||||
"country": "us"
|
"country": "us"
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Top headlines endpoint
|
|
||||||
url = f"{base_url}/top-headlines"
|
url = f"{base_url}/top-headlines"
|
||||||
params = {
|
params = {
|
||||||
"apikey": GNEWS_API_KEY,
|
"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}")
|
logging.error(f"Error fetching news: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Setup
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def setup(bot):
|
def setup(bot):
|
||||||
"""Initialize plugin with bot instance."""
|
"""Initialize plugin with bot instance."""
|
||||||
global GNEWS_API_KEY
|
global GNEWS_API_KEY
|
||||||
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
GNEWS_API_KEY = os.getenv("GNEWS_API_KEY")
|
||||||
|
|
||||||
if GNEWS_API_KEY:
|
if GNEWS_API_KEY:
|
||||||
logging.info("News plugin loaded with API key")
|
logging.info("News plugin loaded with API key")
|
||||||
else:
|
else:
|
||||||
logging.warning("News plugin loaded but GNEWS_API_KEY not set in .env file")
|
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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""Handle !news commands."""
|
"""Handle !news commands."""
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
@@ -201,9 +148,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
|
|
||||||
# Fetch news
|
# Fetch news
|
||||||
if query:
|
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)
|
articles = await _fetch_news(query=query, limit=limit)
|
||||||
title = f"Search Results: '{query}'"
|
title = f"Search Results: '{safe_title}'"
|
||||||
else:
|
else:
|
||||||
articles = await _fetch_news(category=category, limit=limit)
|
articles = await _fetch_news(category=category, limit=limit)
|
||||||
category_name = next((k for k, v in CATEGORIES.items() if v == category), category)
|
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>"
|
content += f"</ul>\n\n<em>Fetched {len(articles[:limit])} articles</em>"
|
||||||
|
|
||||||
# Format as collapsible and send
|
# 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)
|
await bot.api.send_markdown_message(room.room_id, response)
|
||||||
logging.info(f"Sent news to {room.room_id}: category={category}, query={query}")
|
logging.info(f"Sent news to {room.room_id}: category={category}, query={query}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+68
-85
@@ -1,46 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to get random SOCKS5 proxies.
|
This plugin provides a command to get random SOCKS5 proxies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import requests
|
import aiohttp
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ipaddress
|
from plugins.common import is_public_destination, html_escape
|
||||||
|
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
SOCKS5_LIST_URL = 'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/socks5.txt'
|
SOCKS5_LIST_URL = 'https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/socks5.txt'
|
||||||
MAX_TRIES = 64
|
MAX_TRIES = 64
|
||||||
PROXY_LIST_FILENAME = 'socks5.txt'
|
PROXY_LIST_FILENAME = 'socks5.txt'
|
||||||
PROXY_LIST_EXPIRATION = timedelta(hours=8)
|
PROXY_LIST_EXPIRATION = timedelta(hours=8)
|
||||||
MAX_THREADS = 128
|
MAX_THREADS = 64 # lowered to avoid resource exhaustion
|
||||||
PROXIES_DB_FILE = 'proxies.db'
|
PROXIES_DB_FILE = 'proxies.db'
|
||||||
MAX_PROXIES_IN_DB = 10
|
MAX_PROXIES_IN_DB = 10
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
||||||
|
|
||||||
def test_proxy(proxy):
|
def test_proxy(proxy):
|
||||||
"""Test a SOCKS5 proxy and return the outcome."""
|
"""Test a SOCKS5 proxy and return (success, proxy, latency)."""
|
||||||
try:
|
try:
|
||||||
ip, port = proxy.split(':')
|
ip, port = proxy.split(':')
|
||||||
logging.info(f"Testing SOCKS5 proxy: {ip}:{port}")
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
with socket.create_connection((ip, int(port)), timeout=12) as client:
|
with socket.create_connection((ip, int(port)), timeout=12) as client:
|
||||||
client.sendall(b'\x05\x01\x00')
|
client.sendall(b'\x05\x01\x00')
|
||||||
response = client.recv(2)
|
response = client.recv(2)
|
||||||
if response == b'\x05\x00':
|
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
|
return True, proxy, latency
|
||||||
else:
|
else:
|
||||||
return False, proxy, None
|
return False, proxy, None
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return False, proxy, None
|
return False, proxy, None
|
||||||
|
|
||||||
async def download_proxy_list():
|
async def download_proxy_list():
|
||||||
@@ -48,13 +43,15 @@ async def download_proxy_list():
|
|||||||
if not os.path.exists(PROXY_LIST_FILENAME) or \
|
if not os.path.exists(PROXY_LIST_FILENAME) or \
|
||||||
datetime.now() - datetime.fromtimestamp(os.path.getctime(PROXY_LIST_FILENAME)) > PROXY_LIST_EXPIRATION:
|
datetime.now() - datetime.fromtimestamp(os.path.getctime(PROXY_LIST_FILENAME)) > PROXY_LIST_EXPIRATION:
|
||||||
logging.info("Downloading SOCKS5 proxy list")
|
logging.info("Downloading SOCKS5 proxy list")
|
||||||
response = requests.get(SOCKS5_LIST_URL, timeout=5)
|
async with aiohttp.ClientSession() as session:
|
||||||
with open(PROXY_LIST_FILENAME, 'w') as f:
|
async with session.get(SOCKS5_LIST_URL, timeout=20) as response:
|
||||||
f.write(response.text)
|
response.raise_for_status()
|
||||||
logging.info("Proxy list downloaded successfully")
|
text = await response.text()
|
||||||
|
with open(PROXY_LIST_FILENAME, 'w') as f:
|
||||||
|
f.write(text)
|
||||||
|
logging.info("Proxy list downloaded")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logging.info("Proxy list already exists and is up-to-date")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error downloading proxy list: {e}")
|
logging.error(f"Error downloading proxy list: {e}")
|
||||||
@@ -64,48 +61,39 @@ def check_db_for_proxy():
|
|||||||
try:
|
try:
|
||||||
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
|
||||||
CREATE TABLE IF NOT EXISTS proxies (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
proxy TEXT,
|
||||||
proxy TEXT,
|
latency INTEGER,
|
||||||
latency INTEGER,
|
status TEXT)""")
|
||||||
status TEXT
|
cursor.execute("SELECT proxy, latency FROM proxies WHERE status='working' AND latency<3000 ORDER BY RANDOM() LIMIT 1")
|
||||||
)
|
row = cursor.fetchone()
|
||||||
""")
|
if row:
|
||||||
cursor.execute("SELECT proxy, latency FROM proxies WHERE status = 'working' AND latency < 3000 ORDER BY RANDOM() LIMIT 1")
|
proxy, latency = row
|
||||||
result = cursor.fetchone()
|
|
||||||
if result:
|
|
||||||
proxy, latency = result
|
|
||||||
success, _, _ = test_proxy(proxy)
|
success, _, _ = test_proxy(proxy)
|
||||||
if success:
|
if success:
|
||||||
return proxy, latency
|
return proxy, latency
|
||||||
else:
|
else:
|
||||||
cursor.execute("DELETE FROM proxies WHERE proxy = ?", (proxy,))
|
cursor.execute("DELETE FROM proxies WHERE proxy=?", (proxy,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logging.info(f"Removed non-working proxy from the database: {proxy}")
|
return None, None
|
||||||
return None, None
|
|
||||||
else:
|
|
||||||
return None, None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error checking proxies database: {e}")
|
logging.error(f"DB error: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def save_proxy_to_db(proxy, latency):
|
def save_proxy_to_db(proxy, latency):
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
with sqlite3.connect(PROXIES_DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""CREATE TABLE IF NOT EXISTS proxies (
|
||||||
CREATE TABLE IF NOT EXISTS proxies (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
proxy TEXT,
|
||||||
proxy TEXT,
|
latency INTEGER,
|
||||||
latency INTEGER,
|
status TEXT)""")
|
||||||
status TEXT
|
cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?,?,'working')", (proxy, latency))
|
||||||
)
|
|
||||||
""")
|
|
||||||
cursor.execute("INSERT INTO proxies (proxy, latency, status) VALUES (?, ?, 'working')", (proxy, latency))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception as e:
|
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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
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")
|
logging.info("Received !proxy command")
|
||||||
working_proxy, latency = check_db_for_proxy()
|
working_proxy, latency = check_db_for_proxy()
|
||||||
if working_proxy:
|
if working_proxy:
|
||||||
|
safe_proxy = html_escape(working_proxy)
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
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
|
return
|
||||||
else:
|
|
||||||
if not await download_proxy_list():
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with open(PROXY_LIST_FILENAME, 'r') as f:
|
|
||||||
socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()]
|
|
||||||
# Filter out private/internal proxies before testing
|
|
||||||
socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])]
|
|
||||||
random.shuffle(socks5_proxies)
|
|
||||||
tested_proxies = 0
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
|
|
||||||
futures = []
|
|
||||||
for proxy in socks5_proxies[:MAX_TRIES]:
|
|
||||||
futures.append(executor.submit(test_proxy, proxy))
|
|
||||||
for future in concurrent.futures.as_completed(futures):
|
|
||||||
success, proxy, latency = future.result()
|
|
||||||
if success:
|
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
|
||||||
f"✅ Anonymous SOCKS5 Proxy: **{proxy}** - Latency: **{latency} ms**")
|
|
||||||
save_proxy_to_db(proxy, latency)
|
|
||||||
tested_proxies += 1
|
|
||||||
if tested_proxies >= MAX_PROXIES_IN_DB:
|
|
||||||
break
|
|
||||||
working_proxy, latency = check_db_for_proxy()
|
|
||||||
if working_proxy:
|
|
||||||
await bot.api.send_markdown_message(room.room_id,
|
|
||||||
f"✅ Using cached working SOCKS5 Proxy: **{working_proxy}** - Latency: **{latency} ms**")
|
|
||||||
else:
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error handling !proxy command: {e}")
|
|
||||||
await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
if not await download_proxy_list():
|
||||||
# Plugin Metadata
|
await bot.api.send_markdown_message(room.room_id, "Error downloading proxy list")
|
||||||
# ---------------------------------------------------------------------------
|
return
|
||||||
__version__ = "1.0.1"
|
|
||||||
|
try:
|
||||||
|
with open(PROXY_LIST_FILENAME, 'r') as f:
|
||||||
|
socks5_proxies = [line.replace("socks5://", "") for line in f.read().splitlines()]
|
||||||
|
socks5_proxies = [p for p in socks5_proxies if is_public_destination(p.split(':')[0])]
|
||||||
|
random.shuffle(socks5_proxies)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
tested = 0
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
|
||||||
|
futures = [loop.run_in_executor(executor, test_proxy, proxy) for proxy in socks5_proxies[:MAX_TRIES]]
|
||||||
|
for future in asyncio.as_completed(futures):
|
||||||
|
success, proxy, latency = await future
|
||||||
|
if success:
|
||||||
|
safe_proxy = html_escape(proxy)
|
||||||
|
await bot.api.send_markdown_message(room.room_id,
|
||||||
|
f"✅ Anonymous SOCKS5 Proxy: **{safe_proxy}** - Latency: **{latency} ms**")
|
||||||
|
save_proxy_to_db(proxy, latency)
|
||||||
|
tested += 1
|
||||||
|
if tested >= MAX_PROXIES_IN_DB:
|
||||||
|
break
|
||||||
|
if tested == 0:
|
||||||
|
await bot.api.send_markdown_message(room.room_id, "❌ No working anonymous SOCKS5 proxy found")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error handling !proxy command: {e}")
|
||||||
|
await bot.api.send_markdown_message(room.room_id, "❌ Error handling !proxy command")
|
||||||
|
|
||||||
|
__version__ = "1.0.2"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Working SOCKS5 proxy finder (SSRF‑safe)"
|
__description__ = "Working SOCKS5 proxy finder (SSRF‑safe, async)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!proxy</strong> – Random working SOCKS5 proxy</summary>
|
<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>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+25
-99
@@ -1,26 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Goodreads Quote Scraper – Playwright (headless Chromium)
|
Goodreads Quote Scraper – Playwright (headless Chromium)
|
||||||
No external APIs, no keys; scrapes directly from goodreads.com
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from urllib.parse import urlencode
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
logger = logging.getLogger("quote")
|
|
||||||
|
|
||||||
GR_POPULAR = "https://www.goodreads.com/quotes"
|
GR_POPULAR = "https://www.goodreads.com/quotes"
|
||||||
GR_SEARCH = "https://www.goodreads.com/quotes/search"
|
GR_SEARCH = "https://www.goodreads.com/quotes/search"
|
||||||
QUOTES_PER_PAGE = 30
|
QUOTES_PER_PAGE = 30
|
||||||
MAX_SEARCH_PAGES = 3
|
MAX_SEARCH_PAGES = 3
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Playwright browser (shared, launched once)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
_browser = None
|
_browser = None
|
||||||
_playwright = None
|
_playwright = None
|
||||||
|
|
||||||
@@ -30,64 +23,32 @@ async def _get_browser():
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
_playwright = await async_playwright().start()
|
_playwright = await async_playwright().start()
|
||||||
_browser = await _playwright.chromium.launch(headless=True)
|
_browser = await _playwright.chromium.launch(headless=True)
|
||||||
logger.info("Playwright browser started")
|
logging.info("Playwright browser started")
|
||||||
return _browser
|
return _browser
|
||||||
|
|
||||||
async def _close_browser():
|
def _extract_quotes(html: str) -> list:
|
||||||
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."""
|
|
||||||
soup = BeautifulSoup(html, "lxml")
|
soup = BeautifulSoup(html, "lxml")
|
||||||
quotes = []
|
quotes = []
|
||||||
|
|
||||||
for div in soup.find_all("div", class_="quoteText"):
|
for div in soup.find_all("div", class_="quoteText"):
|
||||||
full_text = div.get_text(" ", strip=True)
|
full_text = div.get_text(" ", strip=True)
|
||||||
# Try curly quotes
|
|
||||||
m = re.search(r"“(.+?)”", full_text)
|
m = re.search(r"“(.+?)”", full_text)
|
||||||
if not m:
|
if not m:
|
||||||
m = re.search(r"(.+?)\s*―", full_text)
|
m = re.search(r"(.+?)\s*―", full_text)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
content = m.group(1).strip()
|
content = m.group(1).strip()
|
||||||
|
|
||||||
author_span = div.find("span", class_="authorOrTitle")
|
author_span = div.find("span", class_="authorOrTitle")
|
||||||
author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown"
|
author = author_span.get_text(strip=True).rstrip(",") if author_span else "Unknown"
|
||||||
quotes.append({"content": content, "author": author})
|
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
|
return quotes
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Page fetching
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def _scrape(url: str, params: dict = None) -> str:
|
async def _scrape(url: str, params: dict = None) -> str:
|
||||||
browser = await _get_browser()
|
browser = await _get_browser()
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(user_agent="Mozilla/5.0 ...")
|
||||||
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"
|
|
||||||
)
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
try:
|
try:
|
||||||
if params:
|
if params:
|
||||||
|
from urllib.parse import urlencode
|
||||||
full_url = f"{url}?{urlencode(params)}"
|
full_url = f"{url}?{urlencode(params)}"
|
||||||
else:
|
else:
|
||||||
full_url = url
|
full_url = url
|
||||||
@@ -95,17 +56,17 @@ async def _scrape(url: str, params: dict = None) -> str:
|
|||||||
html = await page.content()
|
html = await page.content()
|
||||||
return html
|
return html
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load {full_url}: {e}")
|
logging.error(f"Scrape error: {e}")
|
||||||
return ""
|
return ""
|
||||||
finally:
|
finally:
|
||||||
await page.close()
|
await page.close()
|
||||||
await context.close()
|
await context.close()
|
||||||
|
|
||||||
async def get_random_popular() -> list[dict]:
|
async def get_random_popular() -> list:
|
||||||
html = await _scrape(GR_POPULAR)
|
html = await _scrape(GR_POPULAR)
|
||||||
return _extract_quotes(html)
|
return _extract_quotes(html)
|
||||||
|
|
||||||
async def get_author_quotes(author: str) -> list[dict]:
|
async def get_author_quotes(author: str) -> list:
|
||||||
all_quotes = []
|
all_quotes = []
|
||||||
for page in range(1, MAX_SEARCH_PAGES + 1):
|
for page in range(1, MAX_SEARCH_PAGES + 1):
|
||||||
html = await _scrape(GR_SEARCH, {"q": author, "commit": "Search", "page": page})
|
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
|
break
|
||||||
return all_quotes
|
return all_quotes
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def format_quote(q):
|
||||||
# Formatting
|
safe_content = html_escape(q["content"])
|
||||||
# ---------------------------------------------------------------------------
|
safe_author = html_escape(q["author"])
|
||||||
def format_quote(q: dict) -> str:
|
return f'"{safe_content}"\n\n— {safe_author}'
|
||||||
return f'"{q["content"]}"\n\n— {q["author"]}'
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Command handler
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")):
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("quote")):
|
||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
# Help
|
|
||||||
if args and args[0].lower() in ("help", "-h", "--help"):
|
if args and args[0].lower() in ("help", "-h", "--help"):
|
||||||
help_html = (
|
help_html = collapsible_summary("📖 !quote help",
|
||||||
"<details><summary><strong>📖 !quote help</strong></summary>"
|
"<ul><li><code>!quote</code> – random popular quote</li>"
|
||||||
"<ul>"
|
"<li><code>!quote <author></code> – quote by author</li></ul>")
|
||||||
"<li><code>!quote</code> – random popular quote from Goodreads</li>"
|
|
||||||
"<li><code>!quote <author></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>"
|
|
||||||
)
|
|
||||||
await bot.api.send_markdown_message(room.room_id, help_html)
|
await bot.api.send_markdown_message(room.room_id, help_html)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args:
|
if args:
|
||||||
author = " ".join(args).strip()
|
author = " ".join(args).strip()
|
||||||
await bot.api.send_text_message(
|
safe_author = html_escape(author)
|
||||||
room.room_id, f"🔍 Searching Goodreads for quotes by **{author}**…"
|
await bot.api.send_text_message(room.room_id, f"🔍 Searching Goodreads for quotes by **{safe_author}**…")
|
||||||
)
|
|
||||||
quotes = await get_author_quotes(author)
|
quotes = await get_author_quotes(author)
|
||||||
if not quotes:
|
if not quotes:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ No quotes found for '{safe_author}'.")
|
||||||
room.room_id,
|
|
||||||
f"❌ No quotes found for '**{author}**'. Try a different spelling."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
chosen = random.choice(quotes)
|
chosen = random.choice(quotes)
|
||||||
else:
|
else:
|
||||||
@@ -172,28 +113,13 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
chosen = random.choice(quotes)
|
chosen = random.choice(quotes)
|
||||||
|
|
||||||
await bot.api.send_markdown_message(room.room_id, format_quote(chosen))
|
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:
|
except Exception as e:
|
||||||
logger.exception("Unexpected error in quote plugin")
|
logging.exception("Unexpected error in quote plugin")
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ Scraping error: {e}")
|
||||||
room.room_id, f"❌ Scraping error: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
__version__ = "1.0.2"
|
||||||
# Plugin metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Goodreads quotes via Playwright (headless browser)"
|
__description__ = "Goodreads quotes via Playwright (headless)"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!quote</strong> – Quotes from Goodreads</summary>
|
||||||
<details>
|
<p><code>!quote</code> random, <code>!quote <author></code>.</p></details>"""
|
||||||
<summary><strong>!quote</strong> – Quotes from Goodreads (scraped with Playwright)</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!quote</code> – random popular quote</li>
|
|
||||||
<li><code>!quote <author></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>
|
|
||||||
"""
|
|
||||||
|
|||||||
+83
-102
@@ -4,15 +4,9 @@ This plugin provides Shodan.io integration for security research and reconnaissa
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dotenv import load_dotenv
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
|
SHODAN_API_KEY = os.getenv("SHODAN_KEY", "")
|
||||||
SHODAN_API_BASE = "https://api.shodan.io"
|
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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
"""
|
||||||
Function to handle Shodan commands.
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"):
|
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):
|
async def shodan_ip_lookup(room, bot, ip):
|
||||||
"""Look up information about a specific IP address."""
|
"""Look up information about a specific IP address."""
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/shodan/host/{ip}"
|
url = f"{SHODAN_API_BASE}/shodan/host/{ip}?key={SHODAN_API_KEY}"
|
||||||
params = {"key": SHODAN_API_KEY}
|
|
||||||
|
|
||||||
logging.info(f"Fetching Shodan IP info for: {ip}")
|
logging.info(f"Fetching Shodan IP info for: {ip}")
|
||||||
response = requests.get(url, params=params, timeout=15)
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, timeout=15) as response:
|
||||||
|
if response.status == 404:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"No information found for IP: {html_escape(ip)}")
|
||||||
|
return
|
||||||
|
elif response.status == 401:
|
||||||
|
await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
|
||||||
|
return
|
||||||
|
elif response.status != 200:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status}")
|
||||||
|
return
|
||||||
|
|
||||||
if response.status_code == 404:
|
data = await response.json()
|
||||||
await bot.api.send_text_message(room.room_id, f"No information found for IP: {ip}")
|
|
||||||
return
|
|
||||||
elif response.status_code == 401:
|
|
||||||
await bot.api.send_text_message(room.room_id, "Invalid Shodan API key")
|
|
||||||
return
|
|
||||||
elif response.status_code != 200:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status_code}")
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
# Format the response
|
# 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'):
|
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'):
|
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'):
|
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'):
|
if data.get('ports'):
|
||||||
output += f"<strong>🔌 Open Ports:</strong> {', '.join(map(str, data['ports']))}<br>"
|
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', '')
|
version = service.get('version', '')
|
||||||
banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '')
|
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:
|
if banner:
|
||||||
output += f" <em>{banner}</em><br>"
|
output += f" <em>{html_escape(banner)}</em><br>"
|
||||||
|
|
||||||
if len(data['data']) > 5:
|
if len(data['data']) > 5:
|
||||||
output += f" • ... and {len(data['data']) - 5} more services<br>"
|
output += f" • ... and {len(data['data']) - 5} more services<br>"
|
||||||
|
|
||||||
# Wrap in collapsible if output is large
|
# Wrap in collapsible if output is large
|
||||||
if len(output) > 500:
|
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)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan IP info for {ip}")
|
logging.info(f"Sent Shodan IP info for {ip}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {e}")
|
||||||
logging.error("Shodan API timeout")
|
logging.error(f"Shodan API error: {e}")
|
||||||
except Exception as 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}")
|
logging.error(f"Error in shodan_ip_lookup: {e}")
|
||||||
|
|
||||||
async def shodan_search(room, bot, query):
|
async def shodan_search(room, bot, query):
|
||||||
@@ -176,24 +158,22 @@ async def shodan_search(room, bot, query):
|
|||||||
params = {
|
params = {
|
||||||
"key": SHODAN_API_KEY,
|
"key": SHODAN_API_KEY,
|
||||||
"query": query,
|
"query": query,
|
||||||
"minify": True,
|
"minify": "true",
|
||||||
"limit": 5 # Limit results to avoid huge responses
|
"limit": 5
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.info(f"Searching Shodan for: {query}")
|
logging.info(f"Searching Shodan for: {query}")
|
||||||
response = requests.get(url, params=params, timeout=15)
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=15) as response:
|
||||||
if response.status_code != 200:
|
if response.status != 200:
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
await handle_shodan_error(room, bot, response.status)
|
||||||
return
|
return
|
||||||
|
data = await response.json()
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if not data.get('matches'):
|
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
|
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>"
|
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br><br>"
|
||||||
|
|
||||||
for match in data['matches'][:5]: # Show first 5 results
|
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')
|
org = match.get('org', 'Unknown')
|
||||||
product = match.get('product', 'Unknown')
|
product = match.get('product', 'Unknown')
|
||||||
|
|
||||||
output += f"<strong>🌐 {ip}:{port}</strong><br>"
|
output += f"<strong>🌐 {html_escape(ip)}:{port}</strong><br>"
|
||||||
output += f" • <strong>Organization:</strong> {org}<br>"
|
output += f" • <strong>Organization:</strong> {html_escape(org)}<br>"
|
||||||
output += f" • <strong>Service:</strong> {product}<br>"
|
output += f" • <strong>Service:</strong> {html_escape(product)}<br>"
|
||||||
|
|
||||||
if match.get('location'):
|
if match.get('location'):
|
||||||
loc = match['location']
|
loc = match['location']
|
||||||
if loc.get('city') and loc.get('country_name'):
|
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>"
|
output += "<br>"
|
||||||
|
|
||||||
@@ -219,44 +199,41 @@ async def shodan_search(room, bot, query):
|
|||||||
await bot.api.send_markdown_message(room.room_id, output)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan search results for: {query}")
|
logging.info(f"Sent Shodan search results for: {query}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {e}")
|
||||||
logging.error("Shodan API timeout")
|
logging.error(f"Shodan API error: {e}")
|
||||||
except Exception as 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}")
|
logging.error(f"Error in shodan_search: {e}")
|
||||||
|
|
||||||
async def shodan_host(room, bot, host):
|
async def shodan_host(room, bot, host):
|
||||||
"""Get host information (domain or IP)."""
|
"""Get host information (domain or IP)."""
|
||||||
try:
|
try:
|
||||||
url = f"{SHODAN_API_BASE}/dns/domain/{host}"
|
url = f"{SHODAN_API_BASE}/dns/domain/{host}?key={SHODAN_API_KEY}"
|
||||||
params = {"key": SHODAN_API_KEY}
|
|
||||||
|
|
||||||
logging.info(f"Fetching Shodan host info for: {host}")
|
logging.info(f"Fetching Shodan host info for: {host}")
|
||||||
response = requests.get(url, params=params, timeout=15)
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, timeout=15) as response:
|
||||||
|
if response.status == 404:
|
||||||
|
# Try IP lookup instead
|
||||||
|
await shodan_ip_lookup(room, bot, host)
|
||||||
|
return
|
||||||
|
elif response.status != 200:
|
||||||
|
await handle_shodan_error(room, bot, response.status)
|
||||||
|
return
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
if response.status_code == 404:
|
output = f"<strong>🔍 Shodan Host: {html_escape(host)}</strong><br><br>"
|
||||||
# Try IP lookup instead
|
|
||||||
await shodan_ip_lookup(room, bot, host)
|
|
||||||
return
|
|
||||||
elif response.status_code != 200:
|
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🔍 Shodan Host: {host}</strong><br><br>"
|
|
||||||
|
|
||||||
if data.get('subdomains'):
|
if data.get('subdomains'):
|
||||||
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
|
output += f"<strong>🌐 Subdomains ({len(data['subdomains'])}):</strong><br>"
|
||||||
for subdomain in sorted(data['subdomains'])[:10]: # Show first 10
|
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:
|
if len(data['subdomains']) > 10:
|
||||||
output += f" • ... and {len(data['subdomains']) - 10} more<br>"
|
output += f" • ... and {len(data['subdomains']) - 10} more<br>"
|
||||||
|
|
||||||
if data.get('tags'):
|
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'):
|
if data.get('data'):
|
||||||
output += f"<br><strong>📊 Records Found:</strong> {len(data['data'])}<br>"
|
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)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan host info for: {host}")
|
logging.info(f"Sent Shodan host info for: {host}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"Error fetching host info: {e}")
|
||||||
logging.error("Shodan API timeout")
|
logging.error(f"Shodan API error: {e}")
|
||||||
except Exception as 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}")
|
logging.error(f"Error in shodan_host: {e}")
|
||||||
|
|
||||||
async def shodan_count(room, bot, query):
|
async def shodan_count(room, bot, query):
|
||||||
@@ -279,39 +256,37 @@ async def shodan_count(room, bot, query):
|
|||||||
"key": SHODAN_API_KEY,
|
"key": SHODAN_API_KEY,
|
||||||
"query": query
|
"query": query
|
||||||
}
|
}
|
||||||
|
|
||||||
logging.info(f"Counting Shodan results for: {query}")
|
logging.info(f"Counting Shodan results for: {query}")
|
||||||
response = requests.get(url, params=params, timeout=15)
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=15) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
await handle_shodan_error(room, bot, response.status)
|
||||||
|
return
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
if response.status_code != 200:
|
output = f"<strong>🔍 Shodan Count: '{html_escape(query)}'</strong><br><br>"
|
||||||
await handle_shodan_error(room, bot, response.status_code)
|
|
||||||
return
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
output = f"<strong>🔍 Shodan Count: '{query}'</strong><br><br>"
|
|
||||||
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>"
|
output += f"<strong>Total Results:</strong> {data.get('total', 0):,}<br>"
|
||||||
|
|
||||||
# Show top countries if available
|
# Show top countries if available
|
||||||
if data.get('facets') and 'country' in data['facets']:
|
if data.get('facets') and 'country' in data['facets']:
|
||||||
output += "<br><strong>🌍 Top Countries:</strong><br>"
|
output += "<br><strong>🌍 Top Countries:</strong><br>"
|
||||||
for country in data['facets']['country'][:5]:
|
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
|
# Show top organizations if available
|
||||||
if data.get('facets') and 'org' in data['facets']:
|
if data.get('facets') and 'org' in data['facets']:
|
||||||
output += "<br><strong>🏢 Top Organizations:</strong><br>"
|
output += "<br><strong>🏢 Top Organizations:</strong><br>"
|
||||||
for org in data['facets']['org'][:5]:
|
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)
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
logging.info(f"Sent Shodan count for: {query}")
|
logging.info(f"Sent Shodan count for: {query}")
|
||||||
|
|
||||||
except requests.exceptions.Timeout:
|
except aiohttp.ClientError as e:
|
||||||
await bot.api.send_text_message(room.room_id, "Shodan API request timed out")
|
await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {e}")
|
||||||
logging.error("Shodan API timeout")
|
logging.error(f"Shodan API error: {e}")
|
||||||
except Exception as 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}")
|
logging.error(f"Error in shodan_count: {e}")
|
||||||
|
|
||||||
async def handle_shodan_error(room, bot, status_code):
|
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",
|
500: "Shodan API server error",
|
||||||
503: "Shodan API temporarily unavailable"
|
503: "Shodan API temporarily unavailable"
|
||||||
}
|
}
|
||||||
|
|
||||||
message = error_messages.get(status_code, f"Shodan API error: {status_code}")
|
message = error_messages.get(status_code, f"Shodan API error: {status_code}")
|
||||||
await bot.api.send_text_message(room.room_id, message)
|
await bot.api.send_text_message(room.room_id, message)
|
||||||
logging.error(f"Shodan API error: {status_code}")
|
logging.error(f"Shodan API error: {status_code}")
|
||||||
@@ -333,7 +307,7 @@ async def handle_shodan_error(room, bot, status_code):
|
|||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.0.1"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Shodan.io reconnaissance"
|
__description__ = "Shodan.io reconnaissance"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
@@ -345,6 +319,13 @@ __help__ = """
|
|||||||
<li><code>!shodan host <domain></code> – Host & subdomain enumeration</li>
|
<li><code>!shodan host <domain></code> – Host & subdomain enumeration</li>
|
||||||
<li><code>!shodan count <query></code> – Result counts</li>
|
<li><code>!shodan count <query></code> – Result counts</li>
|
||||||
</ul>
|
</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>
|
<p>Requires <strong>SHODAN_KEY</strong> env var.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+256
-425
@@ -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 logging
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import OpenSSL
|
import OpenSSL
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
|
||||||
import simplematrixbotlib as botlib
|
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 = {
|
TLS_VERSIONS = {
|
||||||
'TLSv1.2': ssl.PROTOCOL_TLSv1_2,
|
'TLSv1.2': ssl.PROTOCOL_TLSv1_2,
|
||||||
'TLSv1.3': ssl.PROTOCOL_TLS
|
'TLSv1.3': ssl.PROTOCOL_TLS
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to add older protocols if available (they're removed in modern Python)
|
|
||||||
try:
|
try:
|
||||||
TLS_VERSIONS['TLSv1.1'] = ssl.PROTOCOL_TLSv1_1
|
TLS_VERSIONS['TLSv1.1'] = ssl.PROTOCOL_TLSv1_1
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
TLS_VERSIONS['TLSv1'] = ssl.PROTOCOL_TLSv1
|
TLS_VERSIONS['TLSv1'] = ssl.PROTOCOL_TLSv1
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Cipher suites by strength and category
|
|
||||||
CIPHER_CATEGORIES = {
|
CIPHER_CATEGORIES = {
|
||||||
'STRONG': [
|
'STRONG': [
|
||||||
'TLS_AES_256_GCM_SHA384',
|
'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256',
|
||||||
'TLS_CHACHA20_POLY1305_SHA256',
|
'TLS_AES_128_GCM_SHA256', 'ECDHE-RSA-AES256-GCM-SHA384',
|
||||||
'TLS_AES_128_GCM_SHA256',
|
'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-CHACHA20-POLY1305',
|
||||||
'ECDHE-RSA-AES256-GCM-SHA384',
|
'ECDHE-ECDSA-CHACHA20-POLY1305', 'DHE-RSA-AES256-GCM-SHA384'
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
|
||||||
'ECDHE-RSA-CHACHA20-POLY1305',
|
|
||||||
'ECDHE-ECDSA-CHACHA20-POLY1305',
|
|
||||||
'DHE-RSA-AES256-GCM-SHA384'
|
|
||||||
],
|
],
|
||||||
'WEAK': [
|
'WEAK': ['RC4', 'DES', '3DES', 'MD5', 'EXPORT', 'NULL', 'ANON', 'ADH', 'CBC'],
|
||||||
'RC4',
|
|
||||||
'DES',
|
|
||||||
'3DES',
|
|
||||||
'MD5',
|
|
||||||
'EXPORT',
|
|
||||||
'NULL',
|
|
||||||
'ANON',
|
|
||||||
'ADH',
|
|
||||||
'CBC'
|
|
||||||
],
|
|
||||||
'OBSOLETE': [
|
|
||||||
'SSLv2',
|
|
||||||
'SSLv3'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
"""
|
||||||
Function to handle !sslscan command for comprehensive SSL/TLS analysis.
|
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
|
|
||||||
"""
|
"""
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("sslscan"):
|
||||||
logging.info("Received !sslscan command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
if len(args) < 1:
|
if len(args) < 1:
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
@@ -86,7 +50,6 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
target = args[0].strip()
|
target = args[0].strip()
|
||||||
port = 443
|
port = 443
|
||||||
|
|
||||||
# Parse port if provided
|
|
||||||
if ':' in target:
|
if ':' in target:
|
||||||
parts = target.split(':')
|
parts = target.split(':')
|
||||||
target = parts[0]
|
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")
|
await bot.api.send_text_message(room.room_id, "Invalid port number")
|
||||||
return
|
return
|
||||||
|
|
||||||
# SSRF protection: refuse internal hosts
|
|
||||||
if not is_public_destination(target):
|
if not is_public_destination(target):
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "❌ Scanning of private/internal addresses is not allowed.")
|
||||||
room.room_id,
|
|
||||||
"❌ Scanning of private/internal addresses is not allowed."
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await perform_ssl_scan(room, bot, target, port)
|
await perform_ssl_scan(room, bot, target, port)
|
||||||
|
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
"""Display sslscan command usage."""
|
"""Display sslscan command usage."""
|
||||||
usage = """
|
usage = """
|
||||||
@@ -116,7 +76,6 @@ async def show_usage(room, bot):
|
|||||||
<strong>Examples:</strong>
|
<strong>Examples:</strong>
|
||||||
• <code>!sslscan example.com</code>
|
• <code>!sslscan example.com</code>
|
||||||
• <code>!sslscan github.com:443</code>
|
• <code>!sslscan github.com:443</code>
|
||||||
• <code>!sslscan localhost:8443</code>
|
|
||||||
|
|
||||||
<strong>Tests Performed:</strong>
|
<strong>Tests Performed:</strong>
|
||||||
• SSL/TLS protocol support and versions
|
• SSL/TLS protocol support and versions
|
||||||
@@ -129,488 +88,360 @@ async def show_usage(room, bot):
|
|||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
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:
|
try:
|
||||||
await bot.api.send_text_message(room.room_id, f"🔍 Starting comprehensive SSL/TLS scan for {target}:{port}...")
|
with socket.create_connection((target, port), timeout=10):
|
||||||
|
return True
|
||||||
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
|
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_certificate_info(scan_results, target, port):
|
|
||||||
"""Get comprehensive certificate information."""
|
|
||||||
try:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=10) as sock:
|
def _get_certificate_info(target, port):
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
"""Retrieve detailed certificate info."""
|
||||||
cert_bin = ssock.getpeercert(binary_form=True)
|
context = ssl.create_default_context()
|
||||||
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin)
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
# Basic certificate info
|
with socket.create_connection((target, port), timeout=10) as sock:
|
||||||
subject = cert.get_subject()
|
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
||||||
issuer = cert.get_issuer()
|
cert_bin = ssock.getpeercert(binary_form=True)
|
||||||
|
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert_bin)
|
||||||
|
|
||||||
scan_results['certificate'] = {
|
subject = cert.get_subject()
|
||||||
'subject': {
|
issuer = cert.get_issuer()
|
||||||
'common_name': subject.CN,
|
|
||||||
'organization': subject.O,
|
|
||||||
'organizational_unit': subject.OU,
|
|
||||||
'country': subject.C,
|
|
||||||
'state': subject.ST,
|
|
||||||
'locality': subject.L
|
|
||||||
},
|
|
||||||
'issuer': {
|
|
||||||
'common_name': issuer.CN,
|
|
||||||
'organization': issuer.O,
|
|
||||||
'organizational_unit': issuer.OU
|
|
||||||
},
|
|
||||||
'serial_number': cert.get_serial_number(),
|
|
||||||
'version': cert.get_version(),
|
|
||||||
'not_before': cert.get_notBefore().decode('utf-8'),
|
|
||||||
'not_after': cert.get_notAfter().decode('utf-8'),
|
|
||||||
'signature_algorithm': cert.get_signature_algorithm().decode('utf-8'),
|
|
||||||
'extensions': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse extensions
|
not_before = cert.get_notBefore().decode('utf-8')
|
||||||
for i in range(cert.get_extension_count()):
|
not_after = cert.get_notAfter().decode('utf-8')
|
||||||
ext = cert.get_extension(i)
|
sig_alg = cert.get_signature_algorithm().decode('utf-8')
|
||||||
scan_results['certificate']['extensions'].append({
|
|
||||||
'name': ext.get_short_name().decode('utf-8'),
|
|
||||||
'value': str(ext)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Calculate days until expiration
|
not_after_dt = datetime.datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
|
||||||
not_after = datetime.datetime.strptime(scan_results['certificate']['not_after'], '%Y%m%d%H%M%SZ')
|
days_remaining = (not_after_dt - datetime.datetime.utcnow()).days
|
||||||
days_until_expiry = (not_after - datetime.datetime.utcnow()).days
|
|
||||||
scan_results['certificate']['days_until_expiry'] = days_until_expiry
|
|
||||||
|
|
||||||
except Exception as e:
|
# Extensions summary
|
||||||
scan_results['certificate_error'] = str(e)
|
extensions = []
|
||||||
|
for i in range(cert.get_extension_count()):
|
||||||
|
ext = cert.get_extension(i)
|
||||||
|
extensions.append({
|
||||||
|
'name': ext.get_short_name().decode('utf-8'),
|
||||||
|
'value': str(ext)
|
||||||
|
})
|
||||||
|
|
||||||
async def test_protocol_support(scan_results, target, port):
|
return {
|
||||||
|
'subject': {
|
||||||
|
'common_name': subject.CN,
|
||||||
|
'organization': subject.O,
|
||||||
|
'organizational_unit': subject.OU,
|
||||||
|
'country': subject.C,
|
||||||
|
'state': subject.ST,
|
||||||
|
'locality': subject.L
|
||||||
|
},
|
||||||
|
'issuer': {
|
||||||
|
'common_name': issuer.CN,
|
||||||
|
'organization': issuer.O,
|
||||||
|
'organizational_unit': issuer.OU
|
||||||
|
},
|
||||||
|
'serial_number': cert.get_serial_number(),
|
||||||
|
'version': cert.get_version(),
|
||||||
|
'not_before': not_before,
|
||||||
|
'not_after': not_after,
|
||||||
|
'signature_algorithm': sig_alg,
|
||||||
|
'days_until_expiry': days_remaining,
|
||||||
|
'extensions': extensions
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _test_protocols(target, port):
|
||||||
"""Test support for various SSL/TLS protocols."""
|
"""Test support for various SSL/TLS protocols."""
|
||||||
protocols = {
|
protocols = {}
|
||||||
'SSLv2': False,
|
for proto_name in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
|
||||||
'SSLv3': False,
|
if proto_name not in TLS_VERSIONS:
|
||||||
'TLSv1': False,
|
protocols[proto_name] = False
|
||||||
'TLSv1.1': False,
|
continue
|
||||||
'TLSv1.2': False,
|
|
||||||
'TLSv1.3': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test available protocols
|
|
||||||
for protocol_name in protocols.keys():
|
|
||||||
try:
|
try:
|
||||||
if protocol_name in TLS_VERSIONS:
|
ctx = ssl.SSLContext(TLS_VERSIONS[proto_name])
|
||||||
context = ssl.SSLContext(TLS_VERSIONS[protocol_name])
|
ctx.check_hostname = False
|
||||||
else:
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
# For protocols not available in this Python version, assume False
|
|
||||||
protocols[protocol_name] = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=5) as sock:
|
with socket.create_connection((target, port), timeout=5) as sock:
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
with ctx.wrap_socket(sock, server_hostname=target):
|
||||||
protocols[protocol_name] = True
|
protocols[proto_name] = True
|
||||||
# Get negotiated protocol
|
|
||||||
if hasattr(ssock, 'version'):
|
|
||||||
scan_results['negotiated_protocol'] = ssock.version()
|
|
||||||
except:
|
except:
|
||||||
protocols[protocol_name] = False
|
protocols[proto_name] = False
|
||||||
|
return protocols
|
||||||
|
|
||||||
scan_results['protocols'] = protocols
|
|
||||||
|
|
||||||
async def test_cipher_suites(scan_results, target, port):
|
def _test_cipher_suites(target, port):
|
||||||
"""Test supported cipher suites."""
|
"""Return list of supported cipher suite names."""
|
||||||
try:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
# Get default cipher suites
|
|
||||||
context.set_ciphers('ALL:COMPLEMENTOFALL')
|
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=10) as sock:
|
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
|
||||||
cipher = ssock.cipher()
|
|
||||||
scan_results['ciphers'] = {
|
|
||||||
'negotiated_cipher': cipher[0] if cipher else 'Unknown',
|
|
||||||
'supported_ciphers': await get_supported_ciphers(target, port),
|
|
||||||
'weak_ciphers': [],
|
|
||||||
'strong_ciphers': []
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
scan_results['cipher_error'] = str(e)
|
|
||||||
|
|
||||||
async def get_supported_ciphers(target, port):
|
|
||||||
"""Get list of supported cipher suites."""
|
|
||||||
supported_ciphers = []
|
|
||||||
|
|
||||||
# Test common cipher suites
|
|
||||||
test_ciphers = [
|
test_ciphers = [
|
||||||
'ECDHE-RSA-AES256-GCM-SHA384',
|
'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-GCM-SHA384',
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384',
|
'ECDHE-RSA-AES256-SHA384', 'ECDHE-ECDSA-AES256-SHA384',
|
||||||
'ECDHE-RSA-AES256-SHA384',
|
'ECDHE-RSA-AES256-SHA', 'ECDHE-ECDSA-AES256-SHA',
|
||||||
'ECDHE-ECDSA-AES256-SHA384',
|
'AES256-GCM-SHA384', 'AES256-SHA256', 'AES256-SHA',
|
||||||
'ECDHE-RSA-AES256-SHA',
|
'CAMELLIA256-SHA', 'PSK-AES256-CBC-SHA',
|
||||||
'ECDHE-ECDSA-AES256-SHA',
|
'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES128-GCM-SHA256',
|
||||||
'AES256-GCM-SHA384',
|
'ECDHE-RSA-AES128-SHA256', 'ECDHE-ECDSA-AES128-SHA256',
|
||||||
'AES256-SHA256',
|
'ECDHE-RSA-AES128-SHA', 'ECDHE-ECDSA-AES128-SHA',
|
||||||
'AES256-SHA',
|
'AES128-GCM-SHA256', 'AES128-SHA256', 'AES128-SHA',
|
||||||
'CAMELLIA256-SHA',
|
'CAMELLIA128-SHA', 'PSK-AES128-CBC-SHA',
|
||||||
'PSK-AES256-CBC-SHA',
|
'DES-CBC3-SHA', 'RC4-SHA', 'RC4-MD5'
|
||||||
'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:
|
for cipher in test_ciphers:
|
||||||
try:
|
try:
|
||||||
context = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
context.check_hostname = False
|
ctx.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
context.set_ciphers(cipher)
|
ctx.set_ciphers(cipher)
|
||||||
|
|
||||||
with socket.create_connection((target, port), timeout=5) as sock:
|
with socket.create_connection((target, port), timeout=5) as sock:
|
||||||
with context.wrap_socket(sock, server_hostname=target) as ssock:
|
with ctx.wrap_socket(sock, server_hostname=target):
|
||||||
if ssock.cipher():
|
supported.append(cipher)
|
||||||
supported_ciphers.append(cipher)
|
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
return supported
|
||||||
|
|
||||||
return supported_ciphers
|
|
||||||
|
|
||||||
async def check_vulnerabilities(scan_results):
|
# ----- analysis helpers (same logic as original) -----
|
||||||
"""Check for common SSL/TLS vulnerabilities."""
|
def _check_vulnerabilities(protocols, cert_info, supported_ciphers):
|
||||||
vulnerabilities = []
|
vulns = []
|
||||||
|
|
||||||
# Check for weak protocols
|
if protocols.get('SSLv2'):
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
vulns.append({
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'SSLv2 Support',
|
'name': 'SSLv2 Support',
|
||||||
'severity': 'CRITICAL',
|
'severity': 'CRITICAL',
|
||||||
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
|
'description': 'SSLv2 is obsolete and contains critical vulnerabilities',
|
||||||
'cve': 'Multiple CVEs'
|
'cve': 'Multiple CVEs'
|
||||||
})
|
})
|
||||||
|
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
if protocols.get('SSLv3'):
|
||||||
vulnerabilities.append({
|
vulns.append({
|
||||||
'name': 'SSLv3 Support',
|
'name': 'SSLv3 Support',
|
||||||
'severity': 'HIGH',
|
'severity': 'HIGH',
|
||||||
'description': 'SSLv3 is vulnerable to POODLE attack',
|
'description': 'SSLv3 is vulnerable to POODLE attack',
|
||||||
'cve': 'CVE-2014-3566'
|
'cve': 'CVE-2014-3566'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check certificate expiration
|
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
|
||||||
cert = scan_results.get('certificate', {})
|
vulns.append({
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'Certificate Expiring Soon',
|
'name': 'Certificate Expiring Soon',
|
||||||
'severity': 'MEDIUM',
|
'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'
|
'cve': 'N/A'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check for weak ciphers
|
weak_ciphers = [c for c in supported_ciphers
|
||||||
supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', [])
|
if any(weak in c.upper() for weak in CIPHER_CATEGORIES['WEAK'])]
|
||||||
weak_ciphers_found = []
|
if weak_ciphers:
|
||||||
|
vulns.append({
|
||||||
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({
|
|
||||||
'name': 'Weak Cipher Suites',
|
'name': 'Weak Cipher Suites',
|
||||||
'severity': 'HIGH',
|
'severity': 'HIGH',
|
||||||
'description': f'Weak ciphers supported: {", ".join(weak_ciphers_found[:3])}',
|
'description': f'Weak ciphers supported: {", ".join(weak_ciphers[:3])}',
|
||||||
'cve': 'Multiple CVEs'
|
'cve': 'Multiple CVEs'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check for missing modern protocols
|
if not protocols.get('TLSv1.2', False):
|
||||||
if not scan_results['protocols'].get('TLSv1.2', False):
|
vulns.append({
|
||||||
vulnerabilities.append({
|
|
||||||
'name': 'TLS 1.2 Not Supported',
|
'name': 'TLS 1.2 Not Supported',
|
||||||
'severity': 'HIGH',
|
'severity': 'HIGH',
|
||||||
'description': 'TLS 1.2 is required for modern security',
|
'description': 'TLS 1.2 is required for modern security',
|
||||||
'cve': 'N/A'
|
'cve': 'N/A'
|
||||||
})
|
})
|
||||||
|
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
if not protocols.get('TLSv1.3', False):
|
||||||
vulnerabilities.append({
|
vulns.append({
|
||||||
'name': 'TLS 1.3 Not Supported',
|
'name': 'TLS 1.3 Not Supported',
|
||||||
'severity': 'MEDIUM',
|
'severity': 'MEDIUM',
|
||||||
'description': 'TLS 1.3 provides improved security and performance',
|
'description': 'TLS 1.3 provides improved security and performance',
|
||||||
'cve': 'N/A'
|
'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
|
score = 100
|
||||||
|
|
||||||
# Protocol penalties
|
if protocols.get('SSLv2'): score -= 30
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
if protocols.get('SSLv3'): score -= 20
|
||||||
score -= 30
|
if not protocols.get('TLSv1.2'): score -= 15
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
if not protocols.get('TLSv1.3'): score -= 10
|
||||||
score -= 20
|
|
||||||
if not scan_results['protocols'].get('TLSv1.2', False):
|
|
||||||
score -= 15
|
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
|
||||||
score -= 10
|
|
||||||
|
|
||||||
# Certificate penalties
|
if cert_info and cert_info.get('days_until_expiry', 0) < 30: score -= 10
|
||||||
cert = scan_results.get('certificate', {})
|
if cert_info and cert_info.get('days_until_expiry', 0) < 7: score -= 20
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
score -= 10
|
|
||||||
if cert.get('days_until_expiry', 0) < 7:
|
|
||||||
score -= 20
|
|
||||||
|
|
||||||
# Cipher penalties
|
weak_cipher_count = sum(1 for c in supported_ciphers
|
||||||
supported_ciphers = scan_results.get('ciphers', {}).get('supported_ciphers', [])
|
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK']))
|
||||||
weak_cipher_count = sum(1 for cipher in supported_ciphers
|
|
||||||
if any(weak in cipher.upper() for weak in CIPHER_CATEGORIES['WEAK']))
|
|
||||||
score -= min(weak_cipher_count * 5, 25)
|
score -= min(weak_cipher_count * 5, 25)
|
||||||
|
|
||||||
# Vulnerability penalties
|
for vuln in vulnerabilities:
|
||||||
for vuln in scan_results.get('vulnerabilities', []):
|
if vuln['severity'] == 'CRITICAL': score -= 20
|
||||||
if vuln['severity'] == 'CRITICAL':
|
elif vuln['severity'] == 'HIGH': score -= 15
|
||||||
score -= 20
|
elif vuln['severity'] == 'MEDIUM': score -= 10
|
||||||
elif vuln['severity'] == 'HIGH':
|
elif vuln['severity'] == 'LOW': score -= 5
|
||||||
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
|
def _generate_recommendations(protocols, cert_info, supported_ciphers, score):
|
||||||
if scan_results['protocols'].get('SSLv2', False):
|
recs = []
|
||||||
recommendations.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
|
if protocols.get('SSLv2'): recs.append("🔴 IMMEDIATELY disable SSLv2 - critically vulnerable")
|
||||||
if scan_results['protocols'].get('SSLv3', False):
|
if protocols.get('SSLv3'): recs.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
|
||||||
recommendations.append("🔴 Disable SSLv3 - vulnerable to POODLE attack")
|
if not protocols.get('TLSv1.3'): recs.append("🟡 Enable TLSv1.3 for best security and performance")
|
||||||
if not scan_results['protocols'].get('TLSv1.3', False):
|
|
||||||
recommendations.append("🟡 Enable TLSv1.3 for best security and performance")
|
|
||||||
|
|
||||||
# Certificate recommendations
|
if cert_info and cert_info.get('days_until_expiry', 0) < 30:
|
||||||
cert = scan_results.get('certificate', {})
|
recs.append("🟡 Renew SSL certificate - expiring soon")
|
||||||
if cert.get('days_until_expiry', 0) < 30:
|
|
||||||
recommendations.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
|
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:
|
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 score < 80:
|
||||||
if scan_results['security_score'] < 80:
|
recs.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
|
||||||
recommendations.append("🛡️ Implement modern TLS configuration following Mozilla guidelines")
|
|
||||||
|
|
||||||
if not any('ECDHE' in c for c in supported_ciphers):
|
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
|
recs.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features")
|
||||||
recommendations.append("ℹ️ Note: SSLv2/SSLv3 testing limited by Python security features")
|
return recs
|
||||||
|
|
||||||
scan_results['recommendations'] = recommendations
|
|
||||||
|
|
||||||
async def format_ssl_scan_results(scan_results):
|
def _format_cert_date(date_str):
|
||||||
"""Format comprehensive SSL scan results."""
|
try:
|
||||||
output = f"<strong>🔐 SSL/TLS Security Scan: {scan_results['target']}:{scan_results['port']}</strong><br><br>"
|
dt = datetime.datetime.strptime(date_str, '%Y%m%d%H%M%SZ')
|
||||||
|
return dt.strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
except:
|
||||||
|
return date_str
|
||||||
|
|
||||||
# Security Score
|
|
||||||
score = scan_results['security_score']
|
|
||||||
if score >= 90:
|
|
||||||
score_emoji, rating = "🟢", "Excellent"
|
|
||||||
elif score >= 80:
|
|
||||||
score_emoji, rating = "🟡", "Good"
|
|
||||||
elif score >= 60:
|
|
||||||
score_emoji, rating = "🟠", "Fair"
|
|
||||||
else:
|
|
||||||
score_emoji, rating = "🔴", "Poor"
|
|
||||||
|
|
||||||
output += f"<strong>{score_emoji} Security Score: {score}/100 ({rating})</strong><br><br>"
|
# ----- 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
|
# Certificate Information
|
||||||
cert = scan_results.get('certificate', {})
|
if cert_info:
|
||||||
if cert:
|
body += "<strong>📜 Certificate Information</strong><br>"
|
||||||
output += "<strong>📜 Certificate Information</strong><br>"
|
body += f" • <strong>Subject:</strong> {html_escape(cert_info['subject'].get('common_name', 'N/A'))}<br>"
|
||||||
output += f" • <strong>Subject:</strong> {cert.get('subject', {}).get('common_name', 'N/A')}<br>"
|
body += f" • <strong>Issuer:</strong> {html_escape(cert_info['issuer'].get('common_name', 'N/A'))}<br>"
|
||||||
output += f" • <strong>Issuer:</strong> {cert.get('issuer', {}).get('common_name', 'N/A')}<br>"
|
body += f" • <strong>Valid From:</strong> {_format_cert_date(cert_info['not_before'])}<br>"
|
||||||
output += f" • <strong>Valid From:</strong> {format_cert_date(cert.get('not_before', ''))}<br>"
|
body += f" • <strong>Valid Until:</strong> {_format_cert_date(cert_info['not_after'])}<br>"
|
||||||
output += f" • <strong>Valid Until:</strong> {format_cert_date(cert.get('not_after', ''))}<br>"
|
days = cert_info.get('days_until_expiry', 'N/A')
|
||||||
output += f" • <strong>Expires In:</strong> {cert.get('days_until_expiry', 'N/A')} days<br>"
|
body += f" • <strong>Expires In:</strong> {days} days<br>"
|
||||||
output += f" • <strong>Signature Algorithm:</strong> {cert.get('signature_algorithm', 'N/A')}<br>"
|
body += f" • <strong>Signature Algorithm:</strong> {html_escape(cert_info['signature_algorithm'])}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
# Protocol Support
|
# Protocol Support
|
||||||
output += "<strong>🔌 Protocol Support</strong><br>"
|
body += "<strong>🔌 Protocol Support</strong><br>"
|
||||||
protocols = scan_results.get('protocols', {})
|
for proto in ['SSLv2', 'SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']:
|
||||||
for proto, supported in protocols.items():
|
supported = protocols.get(proto, False)
|
||||||
# Handle protocols that can't be tested in this Python version
|
if proto in ['SSLv2', 'SSLv3'] and supported:
|
||||||
if proto in ['SSLv2', 'SSLv3'] and proto not in TLS_VERSIONS:
|
emoji = "🔴"
|
||||||
emoji = "⚫"
|
elif proto == 'TLSv1.3' and supported:
|
||||||
status = "Cannot test (Python security)"
|
emoji = "✅"
|
||||||
else:
|
else:
|
||||||
emoji = "✅" if supported 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>"
|
||||||
|
|
||||||
# Highlight insecure protocols
|
# Cipher Suites
|
||||||
if proto in ['SSLv2', 'SSLv3'] and supported:
|
body += "<strong>🔐 Cipher Suites</strong><br>"
|
||||||
emoji = "🔴"
|
body += f" • <strong>Total Supported:</strong> {len(supported_ciphers)}<br>"
|
||||||
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>"
|
weak_ciphers = [c for c in supported_ciphers
|
||||||
output += "<br>"
|
if any(w in c.upper() for w in CIPHER_CATEGORIES['WEAK'])]
|
||||||
|
if weak_ciphers:
|
||||||
# Cipher Information
|
body += f" • <strong>Weak Ciphers:</strong> {len(weak_ciphers)} found<br>"
|
||||||
ciphers = scan_results.get('ciphers', {})
|
for cipher in weak_ciphers[:3]:
|
||||||
if ciphers.get('supported_ciphers'):
|
body += f" └─ 🔴 {html_escape(cipher)}<br>"
|
||||||
output += "<strong>🔐 Cipher Suites</strong><br>"
|
strong_ciphers = [c for c in supported_ciphers
|
||||||
output += f" • <strong>Negotiated:</strong> {ciphers.get('negotiated_cipher', 'Unknown')}<br>"
|
if any(s in c.upper() for s in CIPHER_CATEGORIES['STRONG'])]
|
||||||
output += f" • <strong>Total Supported:</strong> {len(ciphers['supported_ciphers'])}<br>"
|
if strong_ciphers:
|
||||||
|
body += f" • <strong>Strong Ciphers:</strong> {len(strong_ciphers)} found<br>"
|
||||||
# Show weak ciphers if any
|
body += "<br>"
|
||||||
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
|
||||||
vulnerabilities = scan_results.get('vulnerabilities', [])
|
|
||||||
if vulnerabilities:
|
if vulnerabilities:
|
||||||
output += "<strong>⚠️ Security Vulnerabilities</strong><br>"
|
body += "<strong>⚠️ Security Vulnerabilities</strong><br>"
|
||||||
for vuln in vulnerabilities[:5]: # Show top 5
|
for vuln in vulnerabilities[:5]:
|
||||||
severity_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
|
sev_emoji = "🔴" if vuln['severity'] == 'CRITICAL' else "🟠" if vuln['severity'] == 'HIGH' else "🟡"
|
||||||
output += f" • {severity_emoji} <strong>{vuln['name']}</strong> ({vuln['severity']})<br>"
|
body += f" • {sev_emoji} <strong>{html_escape(vuln['name'])}</strong> ({vuln['severity']})<br>"
|
||||||
output += f" └─ {vuln['description']}<br>"
|
body += f" └─ {html_escape(vuln['description'])}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
# Recommendations
|
# Recommendations
|
||||||
recommendations = scan_results.get('recommendations', [])
|
|
||||||
if recommendations:
|
if recommendations:
|
||||||
output += "<strong>💡 Security Recommendations</strong><br>"
|
body += "<strong>💡 Security Recommendations</strong><br>"
|
||||||
for rec in recommendations[:8]:
|
for rec in recommendations[:8]:
|
||||||
output += f" • {rec}<br>"
|
body += f" • {rec}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
# Quick Assessment
|
# Quick Assessment
|
||||||
output += "<strong>📊 Quick Assessment</strong><br>"
|
body += "<strong>📊 Quick Assessment</strong><br>"
|
||||||
if score >= 90:
|
if score >= 90:
|
||||||
output += " • ✅ Excellent TLS configuration<br>"
|
body += " • ✅ Excellent TLS configuration<br>"
|
||||||
output += " • ✅ Modern protocols and ciphers<br>"
|
body += " • ✅ Modern protocols and ciphers<br>"
|
||||||
output += " • ✅ Good certificate management<br>"
|
body += " • ✅ Good certificate management<br>"
|
||||||
elif score >= 70:
|
elif score >= 70:
|
||||||
output += " • ⚠️ Good configuration with minor issues<br>"
|
body += " • ⚠️ Good configuration with minor issues<br>"
|
||||||
output += " • 🔧 Some improvements recommended<br>"
|
body += " • 🔧 Some improvements recommended<br>"
|
||||||
else:
|
else:
|
||||||
output += " • 🚨 Significant security issues found<br>"
|
body += " • 🚨 Significant security issues found<br>"
|
||||||
output += " • 🔴 Immediate action required<br>"
|
body += " • 🔴 Immediate action required<br>"
|
||||||
|
|
||||||
# Add note about testing limitations
|
body += "<br><em>ℹ️ Note: Some protocol tests limited by Python security features</em>"
|
||||||
output += "<br><em>ℹ️ Note: Some protocol tests limited by Python security features</em>"
|
|
||||||
|
|
||||||
# Always wrap in collapsible due to comprehensive output
|
return collapsible_summary(f"🔐 SSL/TLS Scan: {safe_target}:{port} (Score: {score}/100)", body)
|
||||||
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."""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.2"
|
||||||
__version__ = "1.0.1"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "SSL/TLS security scanner (SSRF‑safe)"
|
__description__ = "SSL/TLS security scanner (SSRF‑safe, async)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sslscan</strong> – SSL/TLS analysis</summary>
|
<summary><strong>!sslscan</strong> – SSL/TLS analysis</summary>
|
||||||
|
|||||||
+23
-97
@@ -2,92 +2,33 @@
|
|||||||
"""
|
"""
|
||||||
Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room.
|
Plugin for generating images using self-hosted Stable Diffusion and sending them to a Matrix chat room.
|
||||||
|
|
||||||
Now supports a `--seed` parameter to control deterministic generation.
|
Now fully asynchronous (uses aiohttp). All original parameters and help text are preserved.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import requests
|
import aiohttp
|
||||||
import base64
|
import base64
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
import os
|
||||||
from asyncio import Queue
|
|
||||||
import argparse
|
import argparse
|
||||||
import simplematrixbotlib as botlib
|
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):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if not (match.prefix() and match.command("sd")):
|
if not (match.prefix() and match.command("sd")):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if API is available
|
# Check if API is reachable
|
||||||
try:
|
try:
|
||||||
health_check = requests.get("http://127.0.0.1:7860/docs", timeout=3)
|
async with aiohttp.ClientSession() as session:
|
||||||
if health_check.status_code != 200:
|
async with session.get("http://127.0.0.1:7860/docs", timeout=3) as resp:
|
||||||
await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!")
|
if resp.status != 200:
|
||||||
return
|
await bot.api.send_text_message(room.room_id, "Stable Diffusion API is not running!")
|
||||||
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
await bot.api.send_text_message(room.room_id, "Could not reach Stable Diffusion API!")
|
await bot.api.send_text_message(room.room_id, "Could not reach Stable Diffusion API!")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Parse command-line arguments
|
|
||||||
parser = argparse.ArgumentParser(description='Generate images using self-hosted Stable Diffusion')
|
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('--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('--cfg', type=int, default=2, help='CFG scale, default=2')
|
||||||
@@ -120,32 +61,26 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"width": args.w,
|
"width": args.w,
|
||||||
"height": args.h,
|
"height": args.h,
|
||||||
}
|
}
|
||||||
# Add seed only if explicitly provided
|
|
||||||
if args.seed is not None:
|
if args.seed is not None:
|
||||||
payload["seed"] = args.seed
|
payload["seed"] = args.seed
|
||||||
|
|
||||||
url = "http://127.0.0.1:7860/sdapi/v1/txt2img"
|
async with aiohttp.ClientSession() as session:
|
||||||
response = requests.post(url=url, json=payload, timeout=600)
|
async with session.post("http://127.0.0.1:7860/sdapi/v1/txt2img", json=payload, timeout=600) as response:
|
||||||
r = response.json()
|
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:
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
|
||||||
filename = temp_file.name
|
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)
|
await bot.api.send_image_message(room_id=room.room_id, image_filepath=filename)
|
||||||
|
|
||||||
# Optional: send info about generated image
|
# Optional info message (commented out to avoid spam, but can be enabled)
|
||||||
neg_prompt_clean = neg_prompt.replace(" ", "")
|
# neg_prompt_clean = neg_prompt.replace(" ", "")
|
||||||
seed_info = f"<br><strong>Seed:</strong> {args.seed}" if args.seed is not None else ""
|
# seed_info = f"<br><strong>Seed:</strong> {args.seed}" if args.seed is not None else ""
|
||||||
info_msg = f"""<details><summary>🔍 Image Info</summary>
|
# info_msg = f"<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br>...</details>"
|
||||||
<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}{seed_info}<br>
|
|
||||||
<strong>Negative Prompt:</strong> {neg_prompt_clean}</details>"""
|
|
||||||
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
||||||
|
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
@@ -156,18 +91,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
|
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(room.room_id, f"Error processing the command: {str(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():
|
def print_help():
|
||||||
"""
|
"""
|
||||||
Generates help text for the 'sd' command.
|
Generates the full help text for the 'sd' command, including LORA list.
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Help text for the 'sd' command.
|
|
||||||
"""
|
"""
|
||||||
return """
|
return """
|
||||||
<p>Generate images using self-hosted Stable Diffusion</p>
|
<p>Generate images using self-hosted Stable Diffusion</p>
|
||||||
@@ -205,14 +132,12 @@ def print_help():
|
|||||||
</ul>
|
</ul>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.2"
|
||||||
__version__ = "1.1.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Stable Diffusion image generation (supports --seed)"
|
__description__ = "Stable Diffusion image generation (async, LORA support)"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sd</strong> – Generate images via Stable Diffusion</summary>
|
<summary><strong>!sd</strong> – Generate images via Stable Diffusion</summary>
|
||||||
@@ -225,6 +150,7 @@ __help__ = """
|
|||||||
<li><code>--sampler SAMPLER</code> – Sampler name (default DPM++ SDE)</li>
|
<li><code>--sampler SAMPLER</code> – Sampler name (default DPM++ SDE)</li>
|
||||||
<li><code>--seed SEED</code> – Deterministic seed (optional)</li>
|
<li><code>--seed SEED</code> – Deterministic seed (optional)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p>LORAs: <code><lora:filename:weight></code></p>
|
||||||
<p>Requires a locally running Stable Diffusion API.</p>
|
<p>Requires a locally running Stable Diffusion API.</p>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+267
-368
@@ -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 logging
|
||||||
import platform
|
import platform
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
import psutil
|
import psutil
|
||||||
import socket
|
import socket
|
||||||
import datetime
|
import datetime
|
||||||
import simplematrixbotlib as botlib
|
|
||||||
import subprocess
|
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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
"""
|
"""
|
||||||
Function to handle !sysinfo command for system information.
|
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
|
|
||||||
"""
|
"""
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("sysinfo"):
|
||||||
logging.info("Received !sysinfo command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
if args and args[0].lower() == 'help':
|
||||||
if len(args) > 0 and args[0].lower() == 'help':
|
|
||||||
await show_usage(room, bot)
|
await show_usage(room, bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
await get_system_info(room, bot)
|
await get_system_info(room, bot)
|
||||||
|
|
||||||
async def show_usage(room, bot):
|
async def show_usage(room, bot):
|
||||||
@@ -57,396 +45,307 @@ async def show_usage(room, bot):
|
|||||||
"""
|
"""
|
||||||
await bot.api.send_markdown_message(room.room_id, usage)
|
await bot.api.send_markdown_message(room.room_id, usage)
|
||||||
|
|
||||||
async def get_system_info(room, bot):
|
# ----- Async wrappers for blocking functions -----
|
||||||
"""Collect and display comprehensive system information."""
|
async def _run_blocking(func, *args, **kwargs):
|
||||||
try:
|
loop = asyncio.get_running_loop()
|
||||||
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
|
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
||||||
|
|
||||||
sysinfo = {
|
# ----- Individual data collectors (all sync, run in thread) -----
|
||||||
'system': await get_system_info_basic(),
|
def _system_overview():
|
||||||
'cpu': await get_cpu_info(),
|
return {
|
||||||
'memory': await get_memory_info(),
|
'hostname': socket.gethostname(),
|
||||||
'storage': await get_storage_info(),
|
'os': platform.system(),
|
||||||
'network': await get_network_info(),
|
'os_release': platform.release(),
|
||||||
'processes': await get_process_info(),
|
'os_version': platform.version(),
|
||||||
'docker': await get_docker_info(),
|
'architecture': platform.architecture()[0],
|
||||||
'sensors': await get_sensor_info(),
|
'machine': platform.machine(),
|
||||||
'gpu': await get_gpu_info()
|
'processor': platform.processor(),
|
||||||
|
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
'uptime': str(datetime.timedelta(seconds=int((datetime.datetime.now() - datetime.datetime.fromtimestamp(psutil.boot_time())).total_seconds()))),
|
||||||
|
'users': len(psutil.users())
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cpu_info():
|
||||||
|
cpu_times = psutil.cpu_times_percent(interval=1)
|
||||||
|
cpu_freq = psutil.cpu_freq()
|
||||||
|
load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else (0,0,0)
|
||||||
|
return {
|
||||||
|
'physical_cores': psutil.cpu_count(logical=False),
|
||||||
|
'total_cores': psutil.cpu_count(logical=True),
|
||||||
|
'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A",
|
||||||
|
'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A",
|
||||||
|
'usage_percent': psutil.cpu_percent(interval=1),
|
||||||
|
'user_time': cpu_times.user,
|
||||||
|
'system_time': cpu_times.system,
|
||||||
|
'idle_time': cpu_times.idle,
|
||||||
|
'load_avg': ", ".join(f"{l:.2f}" for l in load_avg)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _memory_info():
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
swap = psutil.swap_memory()
|
||||||
|
return {
|
||||||
|
'total': f"{mem.total / (1024**3):.2f} GB",
|
||||||
|
'available': f"{mem.available / (1024**3):.2f} GB",
|
||||||
|
'used': f"{mem.used / (1024**3):.2f} GB",
|
||||||
|
'usage_percent': mem.percent,
|
||||||
|
'swap_total': f"{swap.total / (1024**3):.2f} GB",
|
||||||
|
'swap_used': f"{swap.used / (1024**3):.2f} GB",
|
||||||
|
'swap_free': f"{swap.free / (1024**3):.2f} GB",
|
||||||
|
'swap_percent': swap.percent
|
||||||
|
}
|
||||||
|
|
||||||
|
def _storage_info():
|
||||||
|
partitions = psutil.disk_partitions()
|
||||||
|
storage_list = []
|
||||||
|
for part in partitions:
|
||||||
|
try:
|
||||||
|
usage = psutil.disk_usage(part.mountpoint)
|
||||||
|
storage_list.append({
|
||||||
|
'device': part.device,
|
||||||
|
'mountpoint': part.mountpoint,
|
||||||
|
'fstype': part.fstype,
|
||||||
|
'total': f"{usage.total / (1024**3):.2f} GB",
|
||||||
|
'used': f"{usage.used / (1024**3):.2f} GB",
|
||||||
|
'free': f"{usage.free / (1024**3):.2f} GB",
|
||||||
|
'percent': usage.percent
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
disk_io = psutil.disk_io_counters()
|
||||||
|
io_info = {
|
||||||
|
'read_count': disk_io.read_count if disk_io else 0,
|
||||||
|
'write_count': disk_io.write_count if disk_io else 0,
|
||||||
|
'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB",
|
||||||
|
'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB"
|
||||||
|
}
|
||||||
|
return {'partitions': storage_list, 'io_stats': io_info}
|
||||||
|
|
||||||
|
def _network_info():
|
||||||
|
interfaces = psutil.net_if_addrs()
|
||||||
|
io_counters = psutil.net_io_counters(pernic=True)
|
||||||
|
net_list = []
|
||||||
|
for iface, addrs in interfaces.items():
|
||||||
|
if iface == 'lo':
|
||||||
|
continue
|
||||||
|
info = {
|
||||||
|
'interface': iface,
|
||||||
|
'ipv4': next((a.address for a in addrs if a.family == socket.AF_INET), 'N/A'),
|
||||||
|
'ipv6': next((a.address for a in addrs if a.family == socket.AF_INET6), 'N/A'),
|
||||||
|
'mac': next((a.address for a in addrs if a.family == psutil.AF_LINK), 'N/A'),
|
||||||
}
|
}
|
||||||
|
io = io_counters.get(iface)
|
||||||
|
if io:
|
||||||
|
info['bytes_sent'] = f"{io.bytes_sent / (1024**2):.2f} MB"
|
||||||
|
info['bytes_recv'] = f"{io.bytes_recv / (1024**2):.2f} MB"
|
||||||
|
else:
|
||||||
|
info['bytes_sent'] = 'N/A'
|
||||||
|
info['bytes_recv'] = 'N/A'
|
||||||
|
net_list.append(info)
|
||||||
|
return net_list
|
||||||
|
|
||||||
output = await format_system_info(sysinfo)
|
def _process_info():
|
||||||
await bot.api.send_markdown_message(room.room_id, output)
|
procs = []
|
||||||
|
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
||||||
|
try:
|
||||||
|
procs.append(proc.info)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
top_cpu = sorted(procs, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
|
||||||
|
return {'total_processes': len(procs), 'top_cpu': top_cpu}
|
||||||
|
|
||||||
logging.info("Sent system information")
|
def _docker_info():
|
||||||
|
|
||||||
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:
|
try:
|
||||||
return {
|
|
||||||
'hostname': socket.gethostname(),
|
|
||||||
'os': platform.system(),
|
|
||||||
'os_release': platform.release(),
|
|
||||||
'os_version': platform.version(),
|
|
||||||
'architecture': platform.architecture()[0],
|
|
||||||
'machine': platform.machine(),
|
|
||||||
'processor': platform.processor(),
|
|
||||||
'boot_time': datetime.datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
'uptime': str(datetime.timedelta(seconds=psutil.boot_time() - datetime.datetime.now().timestamp())).split('.')[0],
|
|
||||||
'users': len(psutil.users())
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_cpu_info():
|
|
||||||
"""Get CPU information and usage."""
|
|
||||||
try:
|
|
||||||
cpu_times = psutil.cpu_times_percent(interval=1)
|
|
||||||
cpu_freq = psutil.cpu_freq()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'physical_cores': psutil.cpu_count(logical=False),
|
|
||||||
'total_cores': psutil.cpu_count(logical=True),
|
|
||||||
'max_frequency': f"{cpu_freq.max:.1f} MHz" if cpu_freq else "N/A",
|
|
||||||
'current_frequency': f"{cpu_freq.current:.1f} MHz" if cpu_freq else "N/A",
|
|
||||||
'usage_percent': psutil.cpu_percent(interval=1),
|
|
||||||
'user_time': cpu_times.user,
|
|
||||||
'system_time': cpu_times.system,
|
|
||||||
'idle_time': cpu_times.idle,
|
|
||||||
'load_avg': os.getloadavg() if hasattr(os, 'getloadavg') else "N/A"
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_memory_info():
|
|
||||||
"""Get memory and swap information."""
|
|
||||||
try:
|
|
||||||
memory = psutil.virtual_memory()
|
|
||||||
swap = psutil.swap_memory()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total': f"{memory.total / (1024**3):.2f} GB",
|
|
||||||
'available': f"{memory.available / (1024**3):.2f} GB",
|
|
||||||
'used': f"{memory.used / (1024**3):.2f} GB",
|
|
||||||
'usage_percent': memory.percent,
|
|
||||||
'swap_total': f"{swap.total / (1024**3):.2f} GB",
|
|
||||||
'swap_used': f"{swap.used / (1024**3):.2f} GB",
|
|
||||||
'swap_free': f"{swap.free / (1024**3):.2f} GB",
|
|
||||||
'swap_percent': swap.percent
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_storage_info():
|
|
||||||
"""Get storage device information."""
|
|
||||||
try:
|
|
||||||
partitions = psutil.disk_partitions()
|
|
||||||
storage_info = []
|
|
||||||
|
|
||||||
for partition in partitions:
|
|
||||||
try:
|
|
||||||
usage = psutil.disk_usage(partition.mountpoint)
|
|
||||||
storage_info.append({
|
|
||||||
'device': partition.device,
|
|
||||||
'mountpoint': partition.mountpoint,
|
|
||||||
'fstype': partition.fstype,
|
|
||||||
'total': f"{usage.total / (1024**3):.2f} GB",
|
|
||||||
'used': f"{usage.used / (1024**3):.2f} GB",
|
|
||||||
'free': f"{usage.free / (1024**3):.2f} GB",
|
|
||||||
'percent': usage.percent
|
|
||||||
})
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get disk I/O statistics
|
|
||||||
disk_io = psutil.disk_io_counters()
|
|
||||||
io_info = {
|
|
||||||
'read_count': disk_io.read_count if disk_io else 0,
|
|
||||||
'write_count': disk_io.write_count if disk_io else 0,
|
|
||||||
'read_bytes': f"{disk_io.read_bytes / (1024**3):.2f} GB" if disk_io else "0 GB",
|
|
||||||
'write_bytes': f"{disk_io.write_bytes / (1024**3):.2f} GB" if disk_io else "0 GB"
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'partitions': storage_info,
|
|
||||||
'io_stats': io_info
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_network_info():
|
|
||||||
"""Get network interface information."""
|
|
||||||
try:
|
|
||||||
interfaces = psutil.net_if_addrs()
|
|
||||||
io_counters = psutil.net_io_counters(pernic=True)
|
|
||||||
|
|
||||||
network_info = []
|
|
||||||
for interface, addrs in interfaces.items():
|
|
||||||
if interface not in ['lo']: # Skip loopback
|
|
||||||
interface_io = io_counters.get(interface, None)
|
|
||||||
network_info.append({
|
|
||||||
'interface': interface,
|
|
||||||
'ipv4': next((addr.address for addr in addrs if addr.family == socket.AF_INET), 'N/A'),
|
|
||||||
'ipv6': next((addr.address for addr in addrs if addr.family == socket.AF_INET6), 'N/A'),
|
|
||||||
'mac': next((addr.address for addr in addrs if addr.family == psutil.AF_LINK), 'N/A'),
|
|
||||||
'bytes_sent': f"{interface_io.bytes_sent / (1024**2):.2f} MB" if interface_io else "N/A",
|
|
||||||
'bytes_recv': f"{interface_io.bytes_recv / (1024**2):.2f} MB" if interface_io else "N/A"
|
|
||||||
})
|
|
||||||
|
|
||||||
return network_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_process_info():
|
|
||||||
"""Get process and system load information."""
|
|
||||||
try:
|
|
||||||
processes = []
|
|
||||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']):
|
|
||||||
try:
|
|
||||||
processes.append(proc.info)
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort by CPU usage and get top 5
|
|
||||||
top_processes = sorted(processes, key=lambda x: x['cpu_percent'] or 0, reverse=True)[:5]
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_processes': len(processes),
|
|
||||||
'top_cpu': top_processes
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_docker_info():
|
|
||||||
"""Get Docker container information if available."""
|
|
||||||
try:
|
|
||||||
# Check if docker is available
|
|
||||||
result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
result = subprocess.run(['docker', '--version'], capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return {'available': False}
|
return {'available': False}
|
||||||
|
|
||||||
# Get running containers
|
|
||||||
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
|
result = subprocess.run(['docker', 'ps', '--format', '{{.Names}}|{{.Status}}|{{.Ports}}'],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
|
|
||||||
containers = []
|
containers = []
|
||||||
for line in result.stdout.strip().split('\n'):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if line:
|
if line:
|
||||||
parts = line.split('|')
|
parts = line.split('|')
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
containers.append({
|
containers.append({'name': parts[0], 'status': parts[1], 'ports': parts[2] if len(parts)>2 else 'N/A'})
|
||||||
|
return {'available': True, 'containers': containers, 'total_running': len(containers)}
|
||||||
|
except:
|
||||||
|
return {'available': False}
|
||||||
|
|
||||||
|
def _sensor_info():
|
||||||
|
temps = psutil.sensors_temperatures()
|
||||||
|
fans = psutil.sensors_fans()
|
||||||
|
battery = psutil.sensors_battery()
|
||||||
|
sensor = {'temperatures': {}, 'fans': {}, 'battery': {}}
|
||||||
|
if temps:
|
||||||
|
for name, entries in temps.items():
|
||||||
|
sensor['temperatures'][name] = [f"{e.current}°C" for e in entries[:2]]
|
||||||
|
if fans:
|
||||||
|
for name, entries in fans.items():
|
||||||
|
sensor['fans'][name] = [f"{e.current} RPM" for e in entries[:2]]
|
||||||
|
if battery:
|
||||||
|
sensor['battery'] = {
|
||||||
|
'percent': battery.percent,
|
||||||
|
'power_plugged': battery.power_plugged,
|
||||||
|
'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown"
|
||||||
|
}
|
||||||
|
return sensor
|
||||||
|
|
||||||
|
def _gpu_info():
|
||||||
|
gpu_data = {}
|
||||||
|
# NVIDIA
|
||||||
|
try:
|
||||||
|
res = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu',
|
||||||
|
'--format=csv,noheader,nounits'], capture_output=True, text=True)
|
||||||
|
if res.returncode == 0:
|
||||||
|
nvidia = []
|
||||||
|
for line in res.stdout.strip().split('\n'):
|
||||||
|
parts = [p.strip() for p in line.split(',')]
|
||||||
|
if len(parts) >= 6:
|
||||||
|
nvidia.append({
|
||||||
'name': parts[0],
|
'name': parts[0],
|
||||||
'status': parts[1],
|
'memory_total': f"{parts[1]} MB",
|
||||||
'ports': parts[2] if len(parts) > 2 else 'N/A'
|
'memory_used': f"{parts[2]} MB",
|
||||||
|
'memory_free': f"{parts[3]} MB",
|
||||||
|
'temperature': f"{parts[4]}°C",
|
||||||
|
'utilization': f"{parts[5]}%"
|
||||||
})
|
})
|
||||||
|
if nvidia:
|
||||||
return {
|
gpu_data['nvidia'] = nvidia
|
||||||
'available': True,
|
except:
|
||||||
'containers': containers,
|
pass
|
||||||
'total_running': len(containers)
|
# lspci fallback
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {'available': False, 'error': str(e)}
|
|
||||||
|
|
||||||
async def get_sensor_info():
|
|
||||||
"""Get hardware sensor information."""
|
|
||||||
try:
|
try:
|
||||||
temperatures = psutil.sensors_temperatures()
|
res = subprocess.run(['lspci'], capture_output=True, text=True)
|
||||||
fans = psutil.sensors_fans()
|
if res.returncode == 0:
|
||||||
battery = psutil.sensors_battery()
|
gpu_lines = [l for l in res.stdout.split('\n') if 'VGA' in l or '3D' in l]
|
||||||
|
if gpu_lines:
|
||||||
|
gpu_data['detected'] = gpu_lines[:3]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return gpu_data
|
||||||
|
|
||||||
sensor_info = {
|
# ----- Main info gatherer -----
|
||||||
'temperatures': {},
|
async def get_system_info(room, bot):
|
||||||
'fans': {},
|
await bot.api.send_text_message(room.room_id, "🔍 Gathering system information...")
|
||||||
'battery': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Temperature sensors
|
# Run all blocking collectors concurrently
|
||||||
if temperatures:
|
system = await _run_blocking(_system_overview)
|
||||||
for name, entries in temperatures.items():
|
cpu = await _run_blocking(_cpu_info)
|
||||||
sensor_info['temperatures'][name] = [
|
memory = await _run_blocking(_memory_info)
|
||||||
f"{entry.current}°C" for entry in entries[:2] # Show first 2 sensors per type
|
storage = await _run_blocking(_storage_info)
|
||||||
]
|
network = await _run_blocking(_network_info)
|
||||||
|
processes = await _run_blocking(_process_info)
|
||||||
|
docker = await _run_blocking(_docker_info)
|
||||||
|
sensors = await _run_blocking(_sensor_info)
|
||||||
|
gpu = await _run_blocking(_gpu_info)
|
||||||
|
|
||||||
# Fan speeds
|
# Build output HTML
|
||||||
if fans:
|
output = await format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu)
|
||||||
for name, entries in fans.items():
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
sensor_info['fans'][name] = [
|
logging.info("Sent system information")
|
||||||
f"{entry.current} RPM" for entry in entries[:2]
|
|
||||||
]
|
|
||||||
|
|
||||||
# Battery information
|
async def format_system_info(system, cpu, memory, storage, network, processes, docker, sensors, gpu):
|
||||||
if battery:
|
hostname = html_escape(system.get('hostname', 'Unknown'))
|
||||||
sensor_info['battery'] = {
|
body = "<strong>💻 System Information</strong><br><br>"
|
||||||
'percent': battery.percent,
|
|
||||||
'power_plugged': battery.power_plugged,
|
|
||||||
'time_left': f"{battery.secsleft // 3600}h {(battery.secsleft % 3600) // 60}m" if battery.secsleft != psutil.POWER_TIME_UNLIMITED else "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
return sensor_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def get_gpu_info():
|
|
||||||
"""Get GPU information using various methods."""
|
|
||||||
try:
|
|
||||||
gpu_info = {}
|
|
||||||
|
|
||||||
# Try nvidia-smi first
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['nvidia-smi', '--query-gpu=name,memory.total,memory.used,memory.free,temperature.gpu,utilization.gpu',
|
|
||||||
'--format=csv,noheader,nounits'], capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
nvidia_gpus = []
|
|
||||||
for line in result.stdout.strip().split('\n'):
|
|
||||||
if line:
|
|
||||||
parts = [part.strip() for part in line.split(',')]
|
|
||||||
if len(parts) >= 6:
|
|
||||||
nvidia_gpus.append({
|
|
||||||
'name': parts[0],
|
|
||||||
'memory_total': f"{parts[1]} MB",
|
|
||||||
'memory_used': f"{parts[2]} MB",
|
|
||||||
'memory_free': f"{parts[3]} MB",
|
|
||||||
'temperature': f"{parts[4]}°C",
|
|
||||||
'utilization': f"{parts[5]}%"
|
|
||||||
})
|
|
||||||
gpu_info['nvidia'] = nvidia_gpus
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Try lspci for generic GPU detection
|
|
||||||
try:
|
|
||||||
result = subprocess.run(['lspci'], capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
gpu_lines = [line for line in result.stdout.split('\n') if 'VGA' in line or '3D' in line]
|
|
||||||
gpu_info['detected'] = gpu_lines[:3] # Show first 3 GPUs
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return gpu_info
|
|
||||||
except Exception as e:
|
|
||||||
return {'error': str(e)}
|
|
||||||
|
|
||||||
async def format_system_info(sysinfo):
|
|
||||||
"""Format system information for display."""
|
|
||||||
output = "<strong>💻 System Information</strong><br><br>"
|
|
||||||
|
|
||||||
# System Overview
|
# System Overview
|
||||||
system = sysinfo.get('system', {})
|
body += "<strong>🖥️ System Overview</strong><br>"
|
||||||
output += "<strong>🖥️ System Overview</strong><br>"
|
body += f" • <strong>Hostname:</strong> {hostname}<br>"
|
||||||
output += f" • <strong>Hostname:</strong> {system.get('hostname', 'N/A')}<br>"
|
body += f" • <strong>OS:</strong> {html_escape(system['os'])} {html_escape(system['os_release'])}<br>"
|
||||||
output += f" • <strong>OS:</strong> {system.get('os', 'N/A')} {system.get('os_release', '')}<br>"
|
body += f" • <strong>Architecture:</strong> {html_escape(system['architecture'])}<br>"
|
||||||
output += f" • <strong>Architecture:</strong> {system.get('architecture', 'N/A')}<br>"
|
body += f" • <strong>Uptime:</strong> {html_escape(system['uptime'])}<br>"
|
||||||
output += f" • <strong>Uptime:</strong> {system.get('uptime', 'N/A')}<br>"
|
body += f" • <strong>Boot Time:</strong> {html_escape(system['boot_time'])}<br>"
|
||||||
output += f" • <strong>Boot Time:</strong> {system.get('boot_time', 'N/A')}<br>"
|
body += f" • <strong>Users:</strong> {system['users']}<br><br>"
|
||||||
output += f" • <strong>Users:</strong> {system.get('users', 'N/A')}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# CPU Information
|
# CPU
|
||||||
cpu = sysinfo.get('cpu', {})
|
body += "<strong>⚡ CPU Information</strong><br>"
|
||||||
if 'error' not in cpu:
|
body += f" • <strong>Cores:</strong> {cpu['physical_cores']} physical, {cpu['total_cores']} logical<br>"
|
||||||
output += "<strong>⚡ CPU Information</strong><br>"
|
body += f" • <strong>Frequency:</strong> {html_escape(cpu['current_frequency'])} (max: {html_escape(cpu['max_frequency'])})<br>"
|
||||||
output += f" • <strong>Cores:</strong> {cpu.get('physical_cores', 'N/A')} physical, {cpu.get('total_cores', 'N/A')} logical<br>"
|
body += f" • <strong>Usage:</strong> {cpu['usage_percent']}%<br>"
|
||||||
output += f" • <strong>Frequency:</strong> {cpu.get('current_frequency', 'N/A')} (max: {cpu.get('max_frequency', 'N/A')})<br>"
|
body += f" • <strong>Load Average:</strong> {html_escape(cpu['load_avg'])}<br><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>"
|
|
||||||
|
|
||||||
# Memory Information
|
# Memory
|
||||||
memory = sysinfo.get('memory', {})
|
body += "<strong>🧠 Memory Information</strong><br>"
|
||||||
if 'error' not in memory:
|
body += f" • <strong>Total:</strong> {html_escape(memory['total'])}<br>"
|
||||||
output += "<strong>🧠 Memory Information</strong><br>"
|
body += f" • <strong>Used:</strong> {html_escape(memory['used'])} ({memory['usage_percent']}%)<br>"
|
||||||
output += f" • <strong>Total:</strong> {memory.get('total', 'N/A')}<br>"
|
body += f" • <strong>Available:</strong> {html_escape(memory['available'])}<br>"
|
||||||
output += f" • <strong>Used:</strong> {memory.get('used', 'N/A')} ({memory.get('usage_percent', 'N/A')}%)<br>"
|
body += f" • <strong>Swap:</strong> {html_escape(memory['swap_used'])} / {html_escape(memory['swap_total'])} ({memory['swap_percent']}%)<br><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>"
|
|
||||||
|
|
||||||
# Storage Information
|
# Storage
|
||||||
storage = sysinfo.get('storage', {})
|
if storage and 'error' not in storage:
|
||||||
if 'error' not in storage:
|
body += "<strong>💾 Storage Information</strong><br>"
|
||||||
output += "<strong>💾 Storage Information</strong><br>"
|
for p in storage['partitions'][:3]:
|
||||||
partitions = storage.get('partitions', [])
|
body += f" • <strong>{html_escape(p['device'])}:</strong> {p['used']} / {p['total']} ({p['percent']}%)<br>"
|
||||||
for partition in partitions[:3]: # Show first 3 partitions
|
# IO stats if wanted
|
||||||
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>"
|
io = storage.get('io_stats')
|
||||||
output += "<br>"
|
if io:
|
||||||
|
body += f" • <strong>Disk I/O:</strong> read {io['read_bytes']}, write {io['write_bytes']}<br>"
|
||||||
|
body += "<br>"
|
||||||
|
|
||||||
# GPU Information
|
# GPU
|
||||||
gpu = sysinfo.get('gpu', {})
|
if gpu:
|
||||||
if gpu.get('nvidia'):
|
if 'nvidia' in gpu:
|
||||||
output += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
|
body += "<strong>🎮 GPU Information (NVIDIA)</strong><br>"
|
||||||
for gpu_info in gpu['nvidia']:
|
for g 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>"
|
body += f" • <strong>{html_escape(g['name'])}:</strong> {g['utilization']} usage, {g['temperature']}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
elif gpu.get('detected'):
|
elif 'detected' in gpu:
|
||||||
output += "<strong>🎮 GPU Information</strong><br>"
|
body += "<strong>🎮 GPU Information</strong><br>"
|
||||||
for gpu_line in gpu['detected'][:2]:
|
for line in gpu['detected'][:2]:
|
||||||
output += f" • {gpu_line}<br>"
|
body += f" • {html_escape(line)}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
# Network Information
|
# Network
|
||||||
network = sysinfo.get('network', [])
|
if network:
|
||||||
if network and 'error' not in network:
|
body += "<strong>🌐 Network Information</strong><br>"
|
||||||
output += "<strong>🌐 Network Information</strong><br>"
|
for iface in network[:2]:
|
||||||
for interface in network[:2]: # Show first 2 interfaces
|
body += f" • <strong>{html_escape(iface['interface'])}:</strong> {html_escape(iface['ipv4'])}<br>"
|
||||||
output += f" • <strong>{interface.get('interface', 'N/A')}:</strong> {interface.get('ipv4', 'N/A')}<br>"
|
body += "<br>"
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Process Information
|
# Top Processes
|
||||||
processes = sysinfo.get('processes', {})
|
if processes:
|
||||||
if 'error' not in processes:
|
body += "<strong>🔄 Top Processes (by CPU)</strong><br>"
|
||||||
output += "<strong>🔄 Top Processes (by CPU)</strong><br>"
|
for proc in processes['top_cpu'][:3]:
|
||||||
for proc in processes.get('top_cpu', [])[:3]:
|
name = html_escape(proc.get('name', 'N/A'))
|
||||||
output += f" • <strong>{proc.get('name', 'N/A')}:</strong> {proc.get('cpu_percent', 0):.1f}% CPU, {proc.get('memory_percent', 0):.1f}% RAM<br>"
|
cpu_p = proc.get('cpu_percent', 0) or 0
|
||||||
output += f" • <strong>Total Processes:</strong> {processes.get('total_processes', 'N/A')}<br>"
|
mem_p = proc.get('memory_percent', 0) or 0
|
||||||
output += "<br>"
|
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
|
||||||
docker = sysinfo.get('docker', {})
|
if docker and docker.get('available'):
|
||||||
if docker.get('available'):
|
body += "<strong>🐳 Docker Containers</strong><br>"
|
||||||
output += "<strong>🐳 Docker Containers</strong><br>"
|
for c in docker['containers'][:3]:
|
||||||
for container in docker.get('containers', [])[:3]:
|
body += f" • <strong>{html_escape(c['name'])}:</strong> {html_escape(c['status'])}<br>"
|
||||||
output += f" • <strong>{container.get('name', 'N/A')}:</strong> {container.get('status', 'N/A')}<br>"
|
body += f" • <strong>Total Running:</strong> {docker['total_running']}<br><br>"
|
||||||
output += f" • <strong>Total Running:</strong> {docker.get('total_running', 'N/A')}<br>"
|
|
||||||
output += "<br>"
|
|
||||||
|
|
||||||
# Sensor Information
|
# Sensors
|
||||||
sensors = sysinfo.get('sensors', {})
|
if sensors and 'error' not in sensors:
|
||||||
if 'error' not in sensors:
|
|
||||||
if sensors.get('temperatures'):
|
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]:
|
for sensor, temps in list(sensors['temperatures'].items())[:2]:
|
||||||
output += f" • <strong>{sensor}:</strong> {', '.join(temps[:2])}<br>"
|
body += f" • <strong>{html_escape(sensor)}:</strong> {', '.join(temps[:2])}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
if sensors.get('battery'):
|
if sensors.get('battery'):
|
||||||
battery = sensors['battery']
|
bat = sensors['battery']
|
||||||
output += "<strong>🔋 Battery Information</strong><br>"
|
body += "<strong>🔋 Battery Information</strong><br>"
|
||||||
output += f" • <strong>Charge:</strong> {battery.get('percent', 'N/A')}%<br>"
|
body += f" • <strong>Charge:</strong> {bat['percent']}%<br>"
|
||||||
output += f" • <strong>Plugged In:</strong> {'Yes' if battery.get('power_plugged') else 'No'}<br>"
|
body += f" • <strong>Plugged In:</strong> {'Yes' if bat['power_plugged'] else 'No'}<br>"
|
||||||
if battery.get('time_left'):
|
if bat.get('time_left'):
|
||||||
output += f" • <strong>Time Left:</strong> {battery.get('time_left', 'N/A')}<br>"
|
body += f" • <strong>Time Left:</strong> {bat['time_left']}<br>"
|
||||||
output += "<br>"
|
body += "<br>"
|
||||||
|
|
||||||
# Add timestamp
|
# Timestamp
|
||||||
output += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
|
body += f"<em>Last updated: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</em>"
|
||||||
|
|
||||||
# Wrap in collapsible due to comprehensive output
|
return collapsible_summary(f"💻 System Information - {hostname}", body)
|
||||||
output = f"<details><summary><strong>💻 System Information - {system.get('hostname', 'Unknown')}</strong></summary>{output}</details>"
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.0.1"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "System information and monitoring"
|
__description__ = "Comprehensive system information and monitoring"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!sysinfo</strong> – System information</summary>
|
<summary><strong>!sysinfo</strong> – System information</summary>
|
||||||
|
|||||||
+45
-165
@@ -1,212 +1,92 @@
|
|||||||
"""
|
"""
|
||||||
This plugin provides a command to fetch definitions from Urban Dictionary.
|
Urban Dictionary definitions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import requests
|
import aiohttp
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
import html
|
import html
|
||||||
|
from plugins.common import html_escape
|
||||||
|
|
||||||
URBAN_API_URL = "https://api.urbandictionary.com/v0/define"
|
URBAN_API_URL = "https://api.urbandictionary.com/v0/define"
|
||||||
RANDOM_API_URL = "https://api.urbandictionary.com/v0/random"
|
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):
|
def format_definition(term, definition, example, author, thumbs_up, thumbs_down, permalink, index=None, total=None):
|
||||||
"""
|
safe_term = html_escape(term)
|
||||||
Format an Urban Dictionary definition for display.
|
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:
|
header = f"<strong>📖 Urban Dictionary: {safe_term}</strong>"
|
||||||
term (str): The term being defined.
|
if index and total:
|
||||||
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" (Definition {index}/{total})"
|
header += f" (Definition {index}/{total})"
|
||||||
|
|
||||||
message = f"""{header}
|
msg = f"""{header}
|
||||||
<strong>Definition:</strong>
|
<strong>Definition:</strong><br>{definition}<br>"""
|
||||||
{definition}
|
if example.strip():
|
||||||
"""
|
msg += f"""<strong>Example:</strong><br><em>{example}</em><br>"""
|
||||||
if example and example.strip():
|
msg += f"""<strong>Author:</strong> {safe_author} | 👍 {thumbs_up} 👎 {thumbs_down}<br>
|
||||||
message += f"""
|
<a href="{permalink}">View on Urban Dictionary</a>"""
|
||||||
<strong>Example:</strong>
|
return msg
|
||||||
<em>{example}</em>
|
|
||||||
"""
|
|
||||||
message += f"""
|
|
||||||
<strong>Author:</strong> {author} | 👍 {thumbs_up} 👎 {thumbs_down}
|
|
||||||
<a href="{permalink}">View on Urban Dictionary</a>
|
|
||||||
"""
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("ud"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("ud"):
|
||||||
logging.info("Received !ud command")
|
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Case 1: No arguments - get random definition
|
|
||||||
if len(args) == 0:
|
if len(args) == 0:
|
||||||
logging.info("Fetching random Urban Dictionary definition")
|
# random
|
||||||
response = requests.get(RANDOM_API_URL, timeout=10)
|
async with aiohttp.ClientSession() as session:
|
||||||
response.raise_for_status()
|
async with session.get(RANDOM_API_URL, timeout=10) as resp:
|
||||||
data = response.json()
|
resp.raise_for_status()
|
||||||
|
data = await resp.json()
|
||||||
if not data.get('list'):
|
if not data.get('list'):
|
||||||
await bot.api.send_text_message(room.room_id, "No random definition found.")
|
await bot.api.send_text_message(room.room_id, "No random definition found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get first random entry
|
|
||||||
entry = data['list'][0]
|
entry = data['list'][0]
|
||||||
formatted = format_definition(
|
msg = format_definition(entry['word'], entry['definition'], entry.get('example',''),
|
||||||
term=entry['word'],
|
entry['author'], entry['thumbs_up'], entry['thumbs_down'],
|
||||||
definition=entry['definition'],
|
entry['permalink'])
|
||||||
example=entry.get('example', ''),
|
await bot.api.send_markdown_message(room.room_id, msg)
|
||||||
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']}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Case 2: One or more arguments - search for term
|
# Search
|
||||||
# Check if last argument is a number (definition index)
|
|
||||||
index = None
|
index = None
|
||||||
search_term = ' '.join(args)
|
search_term = ' '.join(args)
|
||||||
|
|
||||||
if args[-1].isdigit():
|
if args[-1].isdigit():
|
||||||
index = int(args[-1])
|
index = int(args[-1])
|
||||||
search_term = ' '.join(args[:-1])
|
search_term = ' '.join(args[:-1])
|
||||||
|
|
||||||
if not search_term:
|
if not search_term:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "Usage: !ud [term] [index]")
|
||||||
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'"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info(f"Searching Urban Dictionary for: {search_term}")
|
async with aiohttp.ClientSession() as session:
|
||||||
params = {'term': search_term}
|
async with session.get(URBAN_API_URL, params={'term': search_term}, timeout=10) as resp:
|
||||||
response = requests.get(URBAN_API_URL, params=params, timeout=10)
|
resp.raise_for_status()
|
||||||
response.raise_for_status()
|
data = await resp.json()
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
definitions = data.get('list', [])
|
definitions = data.get('list', [])
|
||||||
|
|
||||||
if not definitions:
|
if not definitions:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"No definition for '{html_escape(search_term)}'")
|
||||||
room.room_id,
|
|
||||||
f"No definition found for '{search_term}'"
|
|
||||||
)
|
|
||||||
logging.info(f"No definition found for: {search_term}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
total = len(definitions)
|
total = len(definitions)
|
||||||
|
|
||||||
# If no index specified, use first definition
|
|
||||||
if index is None:
|
if index is None:
|
||||||
index = 1
|
index = 1
|
||||||
|
|
||||||
# Validate index
|
|
||||||
if index < 1 or index > total:
|
if index < 1 or index > total:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Index out of range (1-{total})")
|
||||||
room.room_id,
|
|
||||||
f"Invalid index. '{search_term}' has {total} definition(s). Use !ud {search_term} [1-{total}]"
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get the requested definition (convert to 0-based index)
|
|
||||||
entry = definitions[index - 1]
|
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(
|
except aiohttp.ClientError as e:
|
||||||
term=entry['word'],
|
await bot.api.send_text_message(room.room_id, f"Error: {e}")
|
||||||
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 Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"Error: {str(e)}")
|
||||||
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)
|
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Urban Dictionary definitions"
|
__description__ = "Urban Dictionary definitions (async)"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!ud</strong> – Urban Dictionary</summary>
|
||||||
<details>
|
<ul><li><code>!ud</code> random, <code>!ud <term></code> top, <code>!ud <term> <index></code></li></ul></details>"""
|
||||||
<summary><strong>!ud</strong> – Urban Dictionary</summary>
|
|
||||||
<ul>
|
|
||||||
<li><code>!ud</code> – Random definition</li>
|
|
||||||
<li><code>!ud <term></code> – Top definition</li>
|
|
||||||
<li><code>!ud <term> <index></code> – Nth definition</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
+67
-37
@@ -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 tempfile
|
||||||
import random
|
import random
|
||||||
|
import os
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
|
||||||
# Define the XKCD API URL
|
XKCD_LATEST_URL = "https://xkcd.com/info.0.json"
|
||||||
XKCD_API_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):
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
if match.prefix() and match.command("xkcd"):
|
if not (match.prefix() and match.command("xkcd")):
|
||||||
# Fetch the latest comic number from XKCD API
|
return
|
||||||
try:
|
|
||||||
response = requests.get(XKCD_API_URL, timeout=10)
|
args = match.args()
|
||||||
response.raise_for_status() # Raise an exception for non-200 status codes
|
|
||||||
latest_comic_num = response.json()["num"]
|
try:
|
||||||
# Choose a random comic number
|
async with aiohttp.ClientSession() as session:
|
||||||
random_comic_num = random.randint(1, latest_comic_num)
|
# Get latest comic number
|
||||||
# Fetch the random comic data
|
async with session.get(XKCD_LATEST_URL, timeout=10) as resp:
|
||||||
random_comic_url = f"https://xkcd.com/{random_comic_num}/info.0.json"
|
resp.raise_for_status()
|
||||||
comic_response = requests.get(random_comic_url, timeout=10)
|
latest_data = await resp.json()
|
||||||
comic_response.raise_for_status()
|
latest_num = latest_data["num"]
|
||||||
comic_data = comic_response.json()
|
|
||||||
|
# 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"]
|
image_url = comic_data["img"]
|
||||||
|
title = comic_data.get("safe_title", comic_data.get("title", "xkcd"))
|
||||||
|
alt = comic_data.get("alt", "")
|
||||||
|
|
||||||
# Download the image
|
# Download the image
|
||||||
image_response = requests.get(image_url, timeout=10)
|
async with session.get(image_url, timeout=10) as img_resp:
|
||||||
image_response.raise_for_status()
|
img_resp.raise_for_status()
|
||||||
|
image_data = await img_resp.read()
|
||||||
|
|
||||||
# Use secure temporary file
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
tmp.write(image_data)
|
||||||
image_path = temp_file.name
|
img_path = tmp.name
|
||||||
temp_file.write(image_response.content)
|
|
||||||
|
|
||||||
# Send the image to the room
|
# Send image
|
||||||
await bot.api.send_image_message(room_id=room.room_id, image_filepath=image_path)
|
await bot.api.send_image_message(room_id=room.room_id, image_filepath=img_path)
|
||||||
|
|
||||||
# Clean up temp file
|
# Send comic info as text (optional but helpful)
|
||||||
import os
|
info = f"**#{comic_num} – {title}**"
|
||||||
os.remove(image_path)
|
if alt:
|
||||||
except Exception as e:
|
info += f"\n*{alt}*"
|
||||||
await bot.api.send_text_message(room.room_id, f"Error fetching XKCD comic: {str(e)}")
|
await bot.api.send_markdown_message(room.room_id, info)
|
||||||
|
|
||||||
|
os.remove(img_path)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
except aiohttp.ClientError as e:
|
||||||
# Plugin Metadata
|
await bot.api.send_text_message(room.room_id, f"❌ Network error fetching xkcd: {e}")
|
||||||
# ---------------------------------------------------------------------------
|
except Exception as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"❌ Error: {str(e)}")
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Random XKCD comic"
|
__description__ = "Fetch random or specific xkcd comics"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>!xkcd</strong> – Random XKCD comic</summary>
|
<summary><strong>!xkcd</strong> – xkcd comics</summary>
|
||||||
<p>Posts a random XKCD comic image.</p>
|
<ul>
|
||||||
|
<li><code>!xkcd</code> – random comic</li>
|
||||||
|
<li><code>!xkcd <number></code> – specific comic (e.g. <code>!xkcd 538</code>)</li>
|
||||||
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+23
-62
@@ -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 logging
|
||||||
|
import asyncio
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from youtube_search import YoutubeSearch
|
from youtube_search import YoutubeSearch
|
||||||
|
from plugins.common import html_escape, collapsible_summary
|
||||||
|
|
||||||
async def handle_command(room, message, bot, PREFIX, config):
|
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)
|
match = botlib.MessageMatch(room, message, bot, PREFIX)
|
||||||
if match.is_not_from_this_bot() and match.prefix() and match.command("yt"):
|
if match.is_not_from_this_bot() and match.prefix() and match.command("yt"):
|
||||||
args = match.args()
|
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>")
|
await bot.api.send_text_message(room.room_id, "Usage: !yt <search terms>")
|
||||||
else:
|
else:
|
||||||
search_terms = " ".join(args)
|
search_terms = " ".join(args)
|
||||||
logging.info(f"Performing YouTube search for: {search_terms}")
|
logging.info(f"YouTube search for: {search_terms}")
|
||||||
results = YoutubeSearch(search_terms, max_results=3).to_dict()
|
results = await asyncio.to_thread(YoutubeSearch, search_terms, max_results=3)
|
||||||
|
results = results.to_dict()
|
||||||
if results:
|
if results:
|
||||||
output = generate_output(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:
|
else:
|
||||||
await bot.api.send_text_message(room.room_id, "No results found.")
|
await bot.api.send_text_message(room.room_id, "No results found.")
|
||||||
|
|
||||||
def generate_output(results):
|
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 = ""
|
output = ""
|
||||||
for video in results:
|
for video in results:
|
||||||
output += f'<a href="https://www.youtube.com/watch?v={video["id"]}">'
|
vid_id = html_escape(video["id"])
|
||||||
output += f'<img src="{video["thumbnails"][0]}"></img><br>'
|
title = html_escape(video["title"])
|
||||||
output += f'<strong>{video["title"]}</strong><br>'
|
thumb = video["thumbnails"][0]
|
||||||
output += f'Length: {video["duration"]} | Views: {video["views"]}<br>'
|
duration = html_escape(str(video["duration"]))
|
||||||
if video["long_desc"]:
|
views = html_escape(str(video["views"]))
|
||||||
output += f'Description: {video["long_desc"]}<br>'
|
output += f'<a href="https://www.youtube.com/watch?v={vid_id}">'
|
||||||
output += "</a><br>"
|
output += f'<img src="{thumb}"></img><br>'
|
||||||
|
output += f'<strong>{title}</strong><br>'
|
||||||
|
output += f'Length: {duration} | Views: {views}<br></a><br>'
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
__version__ = "1.0.1"
|
||||||
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"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "YouTube video search"
|
__description__ = "YouTube video search (async)"
|
||||||
__help__ = """
|
__help__ = """<details><summary><strong>!yt</strong> – Search YouTube</summary>
|
||||||
<details>
|
<p><code>!yt <search terms></code></p></details>"""
|
||||||
<summary><strong>!yt</strong> – Search YouTube</summary>
|
|
||||||
<p><code>!yt <search terms></code> – Returns top 3 results with thumbnails and descriptions.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user