Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f822d6a450 | |||
| 52a9621d50 |
@@ -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__ = """
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Shared utilities for FunguyBot plugins.
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Networks considered unsafe for outbound connections
|
||||||
|
_PRIVATE_RANGES = [
|
||||||
|
ipaddress.ip_network('10.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('172.16.0.0/12'),
|
||||||
|
ipaddress.ip_network('192.168.0.0/16'),
|
||||||
|
ipaddress.ip_network('127.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('169.254.0.0/16'),
|
||||||
|
ipaddress.ip_network('0.0.0.0/8'),
|
||||||
|
ipaddress.ip_network('::1/128'),
|
||||||
|
ipaddress.ip_network('fc00::/7'),
|
||||||
|
ipaddress.ip_network('fe80::/10'),
|
||||||
|
ipaddress.ip_network('::/128'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def html_escape(text: str) -> str:
|
||||||
|
"""Escape HTML special characters for safe embedding in messages."""
|
||||||
|
return html.escape(str(text), quote=False)
|
||||||
|
|
||||||
|
def collapsible_summary(title: str, body: str, expanded: bool = False) -> str:
|
||||||
|
"""Wrap content in a collapsible HTML details block."""
|
||||||
|
open_attr = ' open' if expanded else ''
|
||||||
|
return f"<details{open_attr}>\n<summary><strong>{title}</strong></summary>\n{body}\n</details>"
|
||||||
|
|
||||||
|
def is_public_destination(target: str) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if `target` (hostname or IP) does NOT resolve to any
|
||||||
|
private, loopback, or link‑local address.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(target)
|
||||||
|
if any(addr in net for net in _PRIVATE_RANGES):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
addrinfo = socket.getaddrinfo(target, None)
|
||||||
|
for _, _, _, _, sockaddr in addrinfo:
|
||||||
|
ip = sockaddr[0]
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
if any(addr in net for net in _PRIVATE_RANGES):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cannot resolve {target}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
"""No-op handler so the bot doesn't crash when loading this module as a plugin."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def send_html_message(bot, room_id, html_body, markdown_fallback):
|
||||||
|
"""Send an HTML-formatted message with a Markdown fallback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: simplematrixbotlib.Bot instance
|
||||||
|
room_id: Matrix room ID
|
||||||
|
html_body: HTML string (table, etc.)
|
||||||
|
markdown_fallback: Markdown/plain text for clients that don't render HTML
|
||||||
|
"""
|
||||||
|
content = {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"body": markdown_fallback,
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"formatted_body": html_body
|
||||||
|
}
|
||||||
|
await bot.async_client.room_send(
|
||||||
|
room_id=room_id,
|
||||||
|
message_type="m.room.message",
|
||||||
|
content=content
|
||||||
|
)
|
||||||
+33
-93
@@ -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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1203
File diff suppressed because it is too large
Load Diff
+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>
|
||||||
|
|||||||
+10
-46
@@ -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>
|
||||||
|
|||||||
+36
-90
@@ -1,97 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
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 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')
|
||||||
parser.add_argument('--h', type=int, default=512, help='Height of the image, default=512')
|
parser.add_argument('--h', type=int, default=512, help='Height of the image, default=512')
|
||||||
parser.add_argument('--w', type=int, default=512, help='Width of the image, default=512')
|
parser.add_argument('--w', type=int, default=512, help='Width of the image, default=512')
|
||||||
parser.add_argument('--neg', type=str, nargs='+', default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'], help='Negative prompt')
|
parser.add_argument('--neg', type=str, nargs='+',
|
||||||
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE'], help='Sampler name, default=DPM++ SDE')
|
default=['((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))'],
|
||||||
|
help='Negative prompt')
|
||||||
|
parser.add_argument('--sampler', type=str, nargs='*', default=['DPM++', 'SDE Karras'],
|
||||||
|
help='Sampler name, default=DPM++ SDE')
|
||||||
|
parser.add_argument('--seed', type=int, default=None,
|
||||||
|
help='Seed for deterministic generation (omit for random)')
|
||||||
parser.add_argument('prompt', type=str, nargs='*', help='Prompt for the image')
|
parser.add_argument('prompt', type=str, nargs='*', help='Prompt for the image')
|
||||||
|
|
||||||
args = parser.parse_args(message.body.split()[1:]) # skip command prefix
|
args = parser.parse_args(message.body.split()[1:]) # skip command prefix
|
||||||
@@ -112,22 +61,26 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
"width": args.w,
|
"width": args.w,
|
||||||
"height": args.h,
|
"height": args.h,
|
||||||
}
|
}
|
||||||
|
if args.seed is not None:
|
||||||
|
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(" ", "")
|
||||||
info_msg = f"""<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br><strong>Steps:</strong> {args.steps}<br><strong>Dimensions:</strong> {args.h}x{args.w}<br><strong>Sampler:</strong> {sampler_name}<br><strong>CFG Scale:</strong> {args.cfg}<br><strong>Negative Prompt:</strong> {neg_prompt_clean}</details>"""
|
# seed_info = f"<br><strong>Seed:</strong> {args.seed}" if args.seed is not None else ""
|
||||||
|
# info_msg = f"<details><summary>🔍 Image Info</summary><strong>Prompt:</strong> {prompt[:100]}<br>...</details>"
|
||||||
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
# await bot.api.send_markdown_message(room.room_id, info_msg)
|
||||||
|
|
||||||
# Clean up temp file
|
# Clean up temp file
|
||||||
@@ -138,18 +91,10 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
await bot.api.send_markdown_message(room.room_id, "<details><summary>Stable Diffusion Help</summary>" + print_help() + "</details>")
|
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>
|
||||||
@@ -167,6 +112,7 @@ def print_help():
|
|||||||
<li>--w W - Width of the image, default=512</li>
|
<li>--w W - Width of the image, default=512</li>
|
||||||
<li>--neg NEG - Negative prompt, default=((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))</li>
|
<li>--neg NEG - Negative prompt, default=((((ugly)))), (((duplicate))), ((morbid)), ((mutilated)), out of frame, extra fingers, mutated hands, ((poorly drawn hands)), ((poorly drawn face)), (((mutation))), (((deformed))), ((ugly)), blurry, ((bad anatomy)), (((bad proportions))), ((extra limbs)), cloned face, (((disfigured))), out of frame, ugly, extra limbs, (bad anatomy), gross proportions, (malformed limbs), ((missing arms)), ((missing legs)), (((extra arms))), (((extra legs))), mutated hands, (fused fingers), (too many fingers), (((long neck)))</li>
|
||||||
<li>--sampler SAMPLER - Sampler name, default=DPM++ SDE</li>
|
<li>--sampler SAMPLER - Sampler name, default=DPM++ SDE</li>
|
||||||
|
<li>--seed SEED - Seed for deterministic generation (omit for random)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>LORA List:</p>
|
<p>LORA List:</p>
|
||||||
@@ -186,14 +132,12 @@ def print_help():
|
|||||||
</ul>
|
</ul>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
__version__ = "1.1.2"
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Stable Diffusion image generation"
|
__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>
|
||||||
@@ -204,7 +148,9 @@ __help__ = """
|
|||||||
<li><code>--h H --w W</code> – Image dimensions (default 512)</li>
|
<li><code>--h H --w W</code> – Image dimensions (default 512)</li>
|
||||||
<li><code>--neg <negative prompt></code></li>
|
<li><code>--neg <negative prompt></code></li>
|
||||||
<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>
|
||||||
</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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
plugins/subnet.py – Subnet calculator and network splitting plugin for Funguy Bot.
|
||||||
|
|
||||||
|
Provides the following commands:
|
||||||
|
!subnet info <CIDR> – Show detailed info about a network
|
||||||
|
!subnet split <CIDR> --prefix <N> – Split network into smaller subnets (new prefix length)
|
||||||
|
!subnet split <CIDR> --diff <N> – Split network into equal subnets (prefixlen delta)
|
||||||
|
!subnet adjacent <CIDR> <count> – Show given network and next <count> adjacent ones
|
||||||
|
!subnet help – Display this help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
!subnet info 192.168.4.0/26
|
||||||
|
!subnet split 192.168.4.0/24 --prefix 26
|
||||||
|
!subnet split 10.0.0.0/16 --diff 2
|
||||||
|
!subnet adjacent 192.168.4.0/26 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import sys
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
# ------------------------------- helper functions --------------------------------
|
||||||
|
|
||||||
|
def _fmt_subnet_info(net: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> str:
|
||||||
|
"""Return a human‑readable string with all relevant subnet details."""
|
||||||
|
nw = net.network_address
|
||||||
|
bc = net.broadcast_address if hasattr(net, "broadcast_address") else None
|
||||||
|
total = net.num_addresses
|
||||||
|
|
||||||
|
if net.version == 4:
|
||||||
|
if net.prefixlen == 32:
|
||||||
|
usable_count = 1
|
||||||
|
first = last = nw
|
||||||
|
elif net.prefixlen == 31:
|
||||||
|
usable_count = 2
|
||||||
|
first = nw
|
||||||
|
last = bc
|
||||||
|
else:
|
||||||
|
usable_count = max(0, total - 2)
|
||||||
|
first = nw + 1 if usable_count > 0 else None
|
||||||
|
last = bc - 1 if usable_count > 0 else None
|
||||||
|
else:
|
||||||
|
hosts_iter = net.hosts()
|
||||||
|
try:
|
||||||
|
first = next(hosts_iter)
|
||||||
|
last = net.network_address + (total - 1)
|
||||||
|
usable_count = total
|
||||||
|
except StopIteration:
|
||||||
|
first = last = None
|
||||||
|
usable_count = 0
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"CIDR: {net.with_prefixlen}",
|
||||||
|
f"Network: {nw}",
|
||||||
|
f"Broadcast: {bc if bc is not None else 'N/A'}",
|
||||||
|
f"Netmask: {net.netmask if hasattr(net, 'netmask') else 'N/A'}",
|
||||||
|
f"Wildcard Mask: {net.hostmask if hasattr(net, 'hostmask') else 'N/A'}",
|
||||||
|
f"Total IPs: {total}",
|
||||||
|
f"Usable Hosts: {usable_count}",
|
||||||
|
]
|
||||||
|
if first is not None and last is not None:
|
||||||
|
lines.append(f"First Usable: {first}")
|
||||||
|
lines.append(f"Last Usable: {last}")
|
||||||
|
lines.append(f"Usable Range: {first} - {last}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_by_prefix(net, new_prefix: int) -> str:
|
||||||
|
if new_prefix < net.prefixlen:
|
||||||
|
return f"[!] New prefix /{new_prefix} is smaller than current prefix /{net.prefixlen}. Cannot split."
|
||||||
|
out = [f"# Splitting {net.with_prefixlen} into /{new_prefix} subnets:"]
|
||||||
|
for i, sub in enumerate(net.subnets(new_prefix=new_prefix)):
|
||||||
|
out.append(f"\n-- Subnet #{i+1} --")
|
||||||
|
out.append(_fmt_subnet_info(sub))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_by_diff(net, diff: int) -> str:
|
||||||
|
new_prefix = net.prefixlen + diff
|
||||||
|
return _split_by_prefix(net, new_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
def _adjacent_networks(net, count: int) -> str:
|
||||||
|
out = [f"# Adjacent networks of size /{net.prefixlen} (starting at {net.with_prefixlen}):"]
|
||||||
|
current = net
|
||||||
|
for i in range(count + 1):
|
||||||
|
out.append(f"\n-- Adjacent #{i} --")
|
||||||
|
out.append(_fmt_subnet_info(current))
|
||||||
|
try:
|
||||||
|
next_net_addr = current.network_address + current.num_addresses
|
||||||
|
current = ipaddress.ip_network(f"{next_net_addr}/{current.prefixlen}", strict=True)
|
||||||
|
except ValueError:
|
||||||
|
out.append("[!] Reached address space limit.")
|
||||||
|
break
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------- bot plugin entry -------------------------------
|
||||||
|
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
import simplematrixbotlib as botlib
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
|
||||||
|
if not (match.is_not_from_this_bot() and match.prefix() and match.command("subnet")):
|
||||||
|
return
|
||||||
|
|
||||||
|
args = match.args()
|
||||||
|
if not args:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Usage: !subnet <info|split|adjacent> ...\n"
|
||||||
|
" !subnet help – show full help"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
subcmd = args[0].lower()
|
||||||
|
|
||||||
|
# --- help ---
|
||||||
|
if subcmd in ("help", "-h", "--help"):
|
||||||
|
# Send nicely formatted HTML in a details tag via markdown
|
||||||
|
html = "<details><summary><strong>!subnet</strong> – Subnet calculator and exploration</summary>\n"
|
||||||
|
html += "<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>\n"
|
||||||
|
html += "<h4>Commands</h4>\n"
|
||||||
|
html += "<ul>\n"
|
||||||
|
html += "<li><b>info</b> – Show detailed info for a network<br>\n"
|
||||||
|
html += "<code>!subnet info <CIDR></code><br>\n"
|
||||||
|
html += "Example: <code>!subnet info 192.168.1.0/24</code></li>\n"
|
||||||
|
html += "<li><b>split</b> – Split a network into smaller subnets<br>\n"
|
||||||
|
html += "<code>!subnet split <CIDR> --prefix <new_prefix></code><br>\n"
|
||||||
|
html += "Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code><br>\n"
|
||||||
|
html += "<i>Alternatively, use --diff to split by prefix delta:</i><br>\n"
|
||||||
|
html += "<code>!subnet split <CIDR> --diff <delta></code><br>\n"
|
||||||
|
html += "Example: <code>!subnet split 10.0.0.0/16 --diff 2</code> (creates 4 subnets)</li>\n"
|
||||||
|
html += "<li><b>adjacent</b> – Show the current network and adjacent ones<br>\n"
|
||||||
|
html += "<code>!subnet adjacent <CIDR> <count></code><br>\n"
|
||||||
|
html += "Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>\n"
|
||||||
|
html += "</ul>\n"
|
||||||
|
html += "<h4>Notes</h4>\n"
|
||||||
|
html += "<ul>\n"
|
||||||
|
html += "<li>IPv4 /31 and /32 networks show both addresses as usable (RFC 3021).</li>\n"
|
||||||
|
html += "<li>IPv6 networks list all addresses as hosts (no broadcast).</li>\n"
|
||||||
|
html += "</ul>\n"
|
||||||
|
html += "</details>"
|
||||||
|
await bot.api.send_markdown_message(room.room_id, html)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- info (or a CIDR passed directly) ---
|
||||||
|
if subcmd == "info" or "/" in subcmd:
|
||||||
|
cidr = args[1] if subcmd == "info" else subcmd
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
await bot.api.send_text_message(room.room_id, _fmt_subnet_info(net))
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- split ---
|
||||||
|
if subcmd == "split":
|
||||||
|
if len(args) < 2:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Usage: !subnet split <CIDR> --prefix <new_prefix> OR --diff <delta>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
cidr = args[1]
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "--prefix" in args:
|
||||||
|
try:
|
||||||
|
idx = args.index("--prefix")
|
||||||
|
new_prefix = int(args[idx + 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Usage: !subnet split <CIDR> --prefix <number>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = _split_by_prefix(net, new_prefix)
|
||||||
|
elif "--diff" in args:
|
||||||
|
try:
|
||||||
|
idx = args.index("--diff")
|
||||||
|
diff = int(args[idx + 1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Usage: !subnet split <CIDR> --diff <delta>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = _split_by_diff(net, diff)
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"You must provide either --prefix <N> or --diff <N> for split."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await bot.api.send_text_message(room.room_id, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- adjacent ---
|
||||||
|
if subcmd == "adjacent":
|
||||||
|
if len(args) < 3:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Usage: !subnet adjacent <CIDR> <count>"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
cidr = args[1]
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(cidr, strict=False)
|
||||||
|
except ValueError as e:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"[!] Invalid CIDR: {e}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
count = int(args[2])
|
||||||
|
except ValueError:
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
"Count must be an integer."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = _adjacent_networks(net, count)
|
||||||
|
await bot.api.send_text_message(room.room_id, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unknown subcommand
|
||||||
|
await bot.api.send_text_message(
|
||||||
|
room.room_id,
|
||||||
|
f"Unknown subcommand '{subcmd}'. Use !subnet help to see available commands."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Plugin metadata
|
||||||
|
__version__ = "1.0.1"
|
||||||
|
__author__ = "Funguy Bot"
|
||||||
|
__description__ = "Subnet calculator, splitter, and adjacent network enumerator"
|
||||||
|
__help__ = """
|
||||||
|
<details>
|
||||||
|
<summary><strong>!subnet</strong> – Subnet calculator and exploration</summary>
|
||||||
|
<p>Calculate subnet details, split networks, or enumerate adjacent subnets.</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>!subnet info <CIDR></code> – Show detailed info for a network<br>
|
||||||
|
Example: <code>!subnet info 192.168.1.0/24</code></li>
|
||||||
|
<li><code>!subnet split <CIDR> --prefix <new_prefix></code> – Split into smaller subnets<br>
|
||||||
|
Example: <code>!subnet split 192.168.1.0/24 --prefix 26</code></li>
|
||||||
|
<li><code>!subnet split <CIDR> --diff <delta></code> – Split by prefix delta<br>
|
||||||
|
Example: <code>!subnet split 10.0.0.0/16 --diff 2</code></li>
|
||||||
|
<li><code>!subnet adjacent <CIDR> <count></code> – Show adjacent networks<br>
|
||||||
|
Example: <code>!subnet adjacent 192.168.4.0/26 3</code></li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -24,3 +24,10 @@ ddgs
|
|||||||
playwright
|
playwright
|
||||||
lxml
|
lxml
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
cryptography
|
||||||
|
bcrypt
|
||||||
|
argon2-cffi
|
||||||
|
yara-python
|
||||||
|
asn1crypto
|
||||||
|
PyYAML
|
||||||
|
lxml
|
||||||
|
|||||||
Reference in New Issue
Block a user