various plugin refactors and fixes
This commit is contained in:
@@ -104,16 +104,18 @@ systemctl start funguybot
|
|||||||
|
|
||||||
The bot includes the following plugins:
|
The bot includes the following plugins:
|
||||||
|
|
||||||
- **admin.py**: Full room moderation – multi‑word name support
|
- **admin.py**: Full room moderation – multi-word name support
|
||||||
- **arxiv.py**: arXiv academic paper search (with rate limiting and error reporting)
|
- **arxiv.py**: arXiv academic paper search
|
||||||
- **bitcoin.py**: Current Bitcoin price
|
- **bitcoin.py**: Current Bitcoin price
|
||||||
|
- **common.py**: Shared utilities for FunguyBot plugins.
|
||||||
- **config.py**: Admin-only configuration commands (preserves disabled plugins)
|
- **config.py**: Admin-only configuration commands (preserves disabled plugins)
|
||||||
- **cron.py**: In‑process cron scheduler (room‑aware, no system crontab)
|
- **cron.py**: In-process cron scheduler (room-aware, no system crontab)
|
||||||
- **date.py**: Show current date and time
|
- **date.py**: Show current date and time
|
||||||
- **ddg.py**: DuckDuckGo search – collapsible results (ddgs library, no API key)
|
- **ddg.py**: DuckDuckGo search plugin
|
||||||
- **dictionary.py** Dictionary plugin to fetch word definitions from dict
|
- **dictionary.py**: Look up word definitions using system dictionary
|
||||||
- **dns.py**: DNS reconnaissance
|
- **dns.py**: DNS reconnaissance (SSRF-safe)
|
||||||
- **dnsdumpster.py**: DNSDumpster domain reconnaissance
|
- **dnsdumpster.py**: DNSDumpster domain reconnaissance
|
||||||
|
- **encode.py**: Comprehensive CyberChef-like encoding and analysis toolkit
|
||||||
- **exploitdb.py**: Exploit-DB search
|
- **exploitdb.py**: Exploit-DB search
|
||||||
- **fortune.py**: Random fortune message
|
- **fortune.py**: Random fortune message
|
||||||
- **geo.py**: IP geolocation lookup
|
- **geo.py**: IP geolocation lookup
|
||||||
@@ -124,27 +126,29 @@ The bot includes the following plugins:
|
|||||||
- **imdb.py**: IMDb lookup via OMDb API
|
- **imdb.py**: IMDb lookup via OMDb API
|
||||||
- **infermatic-text.py**: AI text generation via Infermatic API
|
- **infermatic-text.py**: AI text generation via Infermatic API
|
||||||
- **isup.py**: Check if a site is up
|
- **isup.py**: Check if a site is up
|
||||||
- **joke.py**: Get a random joke from the joke APIs
|
- **joke.py**: Get random jokes from the Official Joke API
|
||||||
- **karma.py**: Room karma tracking system (display names only, no Matrix IDs)
|
- **karma.py**: Room karma tracking system (display names only, no Matrix IDs)
|
||||||
- **lastfm.py**: Last.fm integration
|
- **lastfm.py**: Last.fm music stats with aligned code block output
|
||||||
- **loadplugin.py**: Load/unload plugins at runtime
|
- **loadplugin.py**: Load/unload plugins at runtime
|
||||||
- **news.py**: News headlines via GNews API
|
- **news.py**: News headlines via GNews API
|
||||||
- **plugins.py**: List all loaded plugins
|
- **plugins.py**: List all loaded plugins with count
|
||||||
- **proxy.py**: Working SOCKS5 proxy finder
|
- **proxy.py**: Working SOCKS5 proxy finder
|
||||||
- **quote.py**: Goodreads quotes via headless browser (Playwright)
|
- **quote.py**: Fetch Goodreads quotes
|
||||||
- **roomstats.py**: Per‑user room statistics (Limnoria‑style), with multi‑word name support
|
- **roomstats.py**: Per-user room statistics
|
||||||
- **shodan.py**: Shodan.io reconnaissance
|
- **shodan.py**: Shodan.io reconnaissance
|
||||||
- **sslscan.py**: SSL/TLS security scanner
|
- **sslscan.py**: SSL/TLS security scanner
|
||||||
- **stable-diffusion.py**: Stable Diffusion image generation
|
- **stable-diffusion.py**: Stable Diffusion image generation (LORA support)
|
||||||
- **subdomains.py**: Subdomain enumeration via CertSpotter
|
- **subdomains.py**: Subdomain enumeration via CertSpotter
|
||||||
- **sysinfo.py**: System information and monitoring
|
- **subnet.py**: Subnet calculator
|
||||||
- **timezone.py**: World clock (no hardcoded cities)
|
- **sysinfo.py**: System information plugin
|
||||||
|
- **timezone.py**: World clock (offline IANA zones + free geocoding)
|
||||||
- **urbandictionary.py**: Urban Dictionary definitions
|
- **urbandictionary.py**: Urban Dictionary definitions
|
||||||
- **weather.py**: Weather forecast (OWM primary, Open‑Meteo fallback)
|
- **utils.py**: Security utilities for Funguy Bot plugins.
|
||||||
|
- **weather.py**: Weather data plugin
|
||||||
- **welcome.py**: Room welcome message
|
- **welcome.py**: Room welcome message
|
||||||
- **whois.py**: WHOIS lookup
|
- **whois.py**: Domain WHOIS lookup
|
||||||
- **wikipedia.py**: Wikipedia article summary
|
- **wikipedia.py**: Wikipedia article summary
|
||||||
- **xkcd.py**: Random XKCD comic
|
- **xkcd.py**: Fetch random or specific xkcd comics
|
||||||
- **youtube-search.py**: YouTube video search
|
- **youtube-search.py**: YouTube video search
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from plugins.config import FunguyConfig
|
|||||||
|
|
||||||
# Rate limiter settings
|
# Rate limiter settings
|
||||||
RATE_LIMIT_WINDOW = 5.0 # seconds
|
RATE_LIMIT_WINDOW = 5.0 # seconds
|
||||||
MAX_COMMANDS_PER_WINDOW = 5
|
MAX_COMMANDS_PER_WINDOW = 3
|
||||||
|
|
||||||
|
|
||||||
class FunguyBot:
|
class FunguyBot:
|
||||||
@@ -118,22 +118,78 @@ class FunguyBot:
|
|||||||
toml.dump(existing_config, f)
|
toml.dump(existing_config, f)
|
||||||
|
|
||||||
def _check_rate_limit(self, sender: str) -> bool:
|
def _check_rate_limit(self, sender: str) -> bool:
|
||||||
"""Return True if the sender is allowed to proceed, False if rate limited."""
|
"""Return True if the sender is allowed to proceed.
|
||||||
|
Admin is always allowed."""
|
||||||
|
# Admin bypass
|
||||||
|
if sender == self.config.admin_user:
|
||||||
|
return True
|
||||||
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
bucket = self._rate_limit_buckets[sender]
|
bucket = self._rate_limit_buckets[sender]
|
||||||
# Prune old entries
|
# Prune old entries
|
||||||
bucket = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
|
bucket = [t for t in bucket if now - t < RATE_LIMIT_WINDOW]
|
||||||
self._rate_limit_buckets[sender] = bucket
|
self._rate_limit_buckets[sender] = bucket
|
||||||
|
|
||||||
if len(bucket) >= MAX_COMMANDS_PER_WINDOW:
|
if len(bucket) >= MAX_COMMANDS_PER_WINDOW:
|
||||||
|
logging.debug("Rate limit hit for %s", sender)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
bucket.append(now)
|
bucket.append(now)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# New: load/unload a single plugin at runtime
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def load_plugin(self, plugin_name: str) -> bool:
|
||||||
|
"""Dynamically load a plugin module, add to PLUGINS, and call its setup()."""
|
||||||
|
if plugin_name in self.PLUGINS:
|
||||||
|
logging.info(f"Plugin '{plugin_name}' is already loaded.")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
|
||||||
|
self.PLUGINS[plugin_name] = module
|
||||||
|
logging.info(f"Loaded plugin: {plugin_name}")
|
||||||
|
# Call setup if the bot is already running
|
||||||
|
if self.bot is not None and hasattr(module, "setup") and callable(module.setup):
|
||||||
|
module.setup(self.bot)
|
||||||
|
logging.info(f"Setup called for newly loaded plugin: {plugin_name}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error loading plugin {plugin_name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def unload_plugin(self, plugin_name: str) -> bool:
|
||||||
|
"""Remove a plugin from PLUGINS and unload its module."""
|
||||||
|
if plugin_name not in self.PLUGINS:
|
||||||
|
logging.info(f"Plugin '{plugin_name}' is not loaded.")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
del self.PLUGINS[plugin_name]
|
||||||
|
module_path = f"{self.PLUGINS_DIR}.{plugin_name}"
|
||||||
|
if module_path in sys.modules:
|
||||||
|
del sys.modules[module_path]
|
||||||
|
logging.info(f"Unloaded plugin: {plugin_name}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error unloading plugin {plugin_name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# New: restart the bot process
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def restart_bot(self, room_id):
|
||||||
|
await self.bot.api.send_text_message(room_id, "🔄 Restarting bot...")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
logging.info("Restart command received – exiting.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
async def handle_commands(self, room, message):
|
async def handle_commands(self, room, message):
|
||||||
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
|
match = botlib.MessageMatch(room, message, self.bot, self.config.prefix)
|
||||||
|
|
||||||
# Rate limit check (applies to all commands)
|
|
||||||
sender = str(message.sender)
|
sender = str(message.sender)
|
||||||
|
is_admin = (sender == self.config.admin_user)
|
||||||
|
|
||||||
|
# Rate limit check (applies to all non‑admin commands)
|
||||||
if not self._check_rate_limit(sender):
|
if not self._check_rate_limit(sender):
|
||||||
await self.bot.api.send_text_message(
|
await self.bot.api.send_text_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
@@ -143,45 +199,81 @@ class FunguyBot:
|
|||||||
|
|
||||||
# Admin commands
|
# 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 sender == self.config.admin_user:
|
if is_admin:
|
||||||
self.reload_plugins()
|
self.reload_plugins()
|
||||||
await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully")
|
await self.bot.api.send_text_message(room.room_id, "All plugins reloaded successfully.")
|
||||||
else:
|
else:
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if match.is_not_from_this_bot() and match.prefix() and match.command("load"):
|
||||||
|
if not is_admin:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
|
args = match.args()
|
||||||
|
if len(args) != 1:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "Usage: !load <plugin>")
|
||||||
|
return
|
||||||
|
success = await self.load_plugin(args[0])
|
||||||
|
msg = f"✅ Plugin '{args[0]}' loaded." if success else f"❌ Could not load '{args[0]}'. See logs for details."
|
||||||
|
await self.bot.api.send_text_message(room.room_id, msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
if match.is_not_from_this_bot() and match.prefix() and match.command("unload"):
|
||||||
|
if not is_admin:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
|
args = match.args()
|
||||||
|
if len(args) != 1:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "Usage: !unload <plugin>")
|
||||||
|
return
|
||||||
|
success = await self.unload_plugin(args[0])
|
||||||
|
msg = f"✅ Plugin '{args[0]}' unloaded." if success else f"❌ Could not unload '{args[0]}'. See logs for details."
|
||||||
|
await self.bot.api.send_text_message(room.room_id, msg)
|
||||||
|
return
|
||||||
|
|
||||||
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 sender == self.config.admin_user:
|
if not is_admin:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
args = match.args()
|
args = match.args()
|
||||||
if len(args) != 2:
|
if len(args) != 1:
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>")
|
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin>")
|
||||||
else:
|
return
|
||||||
plugin_name, room_id = args
|
plugin_name = args[0]
|
||||||
|
room_id = room.room_id
|
||||||
await self.disable_plugin(room_id, plugin_name)
|
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}'")
|
await self.bot.api.send_text_message(room.room_id, f"🚫 Plugin '{plugin_name}' disabled in this room.")
|
||||||
else:
|
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
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 sender == self.config.admin_user:
|
if not is_admin:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
args = match.args()
|
args = match.args()
|
||||||
if len(args) != 2:
|
if len(args) != 1:
|
||||||
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>")
|
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin>")
|
||||||
else:
|
return
|
||||||
plugin_name, room_id = args
|
plugin_name = args[0]
|
||||||
|
room_id = room.room_id
|
||||||
await self.enable_plugin(room_id, plugin_name)
|
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}'")
|
await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' enabled in this room.")
|
||||||
else:
|
return
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.")
|
|
||||||
|
if match.is_not_from_this_bot() and match.prefix() and match.command("restart"):
|
||||||
|
if not is_admin:
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
|
||||||
|
return
|
||||||
|
await self.restart_bot(room.room_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
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 sender == self.config.admin_user:
|
if not is_admin:
|
||||||
self.rehash_config()
|
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload config.")
|
||||||
await self.bot.api.send_text_message(room.room_id, "Config rehashed")
|
return
|
||||||
else:
|
self.load_config()
|
||||||
await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.")
|
self.load_disabled_plugins()
|
||||||
|
await self.bot.api.send_text_message(room.room_id, "🔄 Configuration rehashed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Dispatch to active plugins
|
# Dispatch to active plugins
|
||||||
@@ -192,10 +284,6 @@ class FunguyBot:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
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):
|
|
||||||
del self.config
|
|
||||||
self.config = FunguyConfig()
|
|
||||||
|
|
||||||
async def disable_plugin(self, room_id, plugin_name):
|
async def disable_plugin(self, room_id, plugin_name):
|
||||||
if room_id not in self.disabled_plugins:
|
if room_id not in self.disabled_plugins:
|
||||||
self.disabled_plugins[room_id] = []
|
self.disabled_plugins[room_id] = []
|
||||||
|
|||||||
+257
-93
@@ -2,6 +2,14 @@
|
|||||||
Custom configuration class for the Funguy bot.
|
Custom configuration class for the Funguy bot.
|
||||||
Security‑hardened: only the configured admin user can read or change settings.
|
Security‑hardened: only the configured admin user can read or change settings.
|
||||||
Save operation preserves extra sections (plugins.disabled, etc.).
|
Save operation preserves extra sections (plugins.disabled, etc.).
|
||||||
|
Commands:
|
||||||
|
!set <option> <value>
|
||||||
|
!get <option>
|
||||||
|
!show
|
||||||
|
!saveconf
|
||||||
|
!loadconf
|
||||||
|
!reset
|
||||||
|
!config help
|
||||||
"""
|
"""
|
||||||
# plugins/config.py
|
# plugins/config.py
|
||||||
|
|
||||||
@@ -10,6 +18,75 @@ import logging
|
|||||||
import toml
|
import toml
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from plugins.common import code_block, collapsible_summary, html_escape
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Allowed configuration keys and metadata
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
OPTIONS = {
|
||||||
|
"prefix": {
|
||||||
|
"description": "Command prefix (e.g., !)",
|
||||||
|
"validator": lambda v: isinstance(v, str) and len(v) == 1,
|
||||||
|
"type": "string",
|
||||||
|
"default": "!",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"description": "HTTP timeout (seconds)",
|
||||||
|
"validator": lambda v: isinstance(v, (int, float)) and v > 0,
|
||||||
|
"type": "integer",
|
||||||
|
"default": 30,
|
||||||
|
},
|
||||||
|
"join_on_invite": {
|
||||||
|
"description": "Auto‑join rooms on invite (true/false)",
|
||||||
|
"validator": lambda v: isinstance(v, bool),
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"encryption_enabled": {
|
||||||
|
"description": "Enable message encryption (true/false)",
|
||||||
|
"validator": lambda v: isinstance(v, bool),
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"emoji_verify": {
|
||||||
|
"description": "Use emoji verification (true/false)",
|
||||||
|
"validator": lambda v: isinstance(v, bool),
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
},
|
||||||
|
"ignore_unverified_devices": {
|
||||||
|
"description": "Ignore unverified devices (true/false)",
|
||||||
|
"validator": lambda v: isinstance(v, bool),
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
},
|
||||||
|
"store_path": {
|
||||||
|
"description": "Path for device store",
|
||||||
|
"validator": lambda v: isinstance(v, str),
|
||||||
|
"type": "string",
|
||||||
|
"default": "./store/",
|
||||||
|
},
|
||||||
|
"allowlist": {
|
||||||
|
"description": "Allowed users (comma separated)",
|
||||||
|
"validator": lambda v: isinstance(v, list),
|
||||||
|
"type": "list",
|
||||||
|
"default": [],
|
||||||
|
},
|
||||||
|
"blocklist": {
|
||||||
|
"description": "Blocked users (comma separated)",
|
||||||
|
"validator": lambda v: isinstance(v, list),
|
||||||
|
"type": "list",
|
||||||
|
"default": [],
|
||||||
|
},
|
||||||
|
"admin_user": {
|
||||||
|
"description": "Admin Matrix user ID",
|
||||||
|
"validator": lambda v: isinstance(v, str) and v.startswith("@"),
|
||||||
|
"type": "string (read‑only via !set)",
|
||||||
|
"default": "", # must be set via config file
|
||||||
|
"readonly": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -17,16 +94,24 @@ class FunguyConfig(botlib.Config):
|
|||||||
"""
|
"""
|
||||||
Custom configuration class for the Funguy bot.
|
Custom configuration class for the Funguy bot.
|
||||||
Extends the base Config class to provide additional configuration options.
|
Extends the base Config class to provide additional configuration options.
|
||||||
|
|
||||||
Args:
|
|
||||||
config_file (str): Path to the configuration file.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, config_file="funguy.conf"):
|
def __init__(self, config_file="funguy.conf"):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
# Load configuration from file
|
# Load the TOML file *first* so we can easily extract admin_user and prefix
|
||||||
|
raw = {}
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
raw = toml.load(f)
|
||||||
|
|
||||||
|
bot_section = raw.get('simplematrixbotlib', {}).get('config', {})
|
||||||
|
self._admin_user = bot_section.get('admin_user', '')
|
||||||
|
self._prefix = bot_section.get('prefix', '!')
|
||||||
|
|
||||||
|
# Now let the base class parse the file and fill in other attributes
|
||||||
self.load_toml(config_file)
|
self.load_toml(config_file)
|
||||||
# Store the actual file path used (so save_config can find it)
|
|
||||||
|
# Store the file path for later saves
|
||||||
self._config_file = config_file
|
self._config_file = config_file
|
||||||
logging.info(f"Loaded configuration from {config_file}")
|
logging.info(f"Loaded configuration from {config_file}")
|
||||||
|
|
||||||
@@ -59,7 +144,16 @@ class FunguyConfig(botlib.Config):
|
|||||||
self._config_file = value
|
self._config_file = value
|
||||||
|
|
||||||
def load_config(self, config_file):
|
def load_config(self, config_file):
|
||||||
"""Load configuration from a TOML file."""
|
"""Load configuration from a TOML file (re‑read admin_user and prefix)."""
|
||||||
|
raw = {}
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
raw = toml.load(f)
|
||||||
|
|
||||||
|
bot_section = raw.get('simplematrixbotlib', {}).get('config', {})
|
||||||
|
self._admin_user = bot_section.get('admin_user', '')
|
||||||
|
self._prefix = bot_section.get('prefix', '!')
|
||||||
|
|
||||||
self.load_toml(config_file)
|
self.load_toml(config_file)
|
||||||
self._config_file = config_file
|
self._config_file = config_file
|
||||||
logging.info(f"Loaded configuration from {config_file}")
|
logging.info(f"Loaded configuration from {config_file}")
|
||||||
@@ -68,37 +162,28 @@ class FunguyConfig(botlib.Config):
|
|||||||
"""
|
"""
|
||||||
Save configuration to a TOML file, **preserving** any extra sections
|
Save configuration to a TOML file, **preserving** any extra sections
|
||||||
(e.g., plugins.disabled) not managed by the base library.
|
(e.g., plugins.disabled) not managed by the base library.
|
||||||
|
|
||||||
If config_file is not provided, the instance's stored config_file is used.
|
|
||||||
"""
|
"""
|
||||||
if config_file is None:
|
if config_file is None:
|
||||||
config_file = self._config_file
|
config_file = self._config_file
|
||||||
if not config_file:
|
if not config_file:
|
||||||
raise ValueError("No config file path set for saving.")
|
raise ValueError("No config file path set for saving.")
|
||||||
|
|
||||||
# 1. Let the library write its portion to a temporary file
|
|
||||||
tmp_file = config_file + ".tmp"
|
tmp_file = config_file + ".tmp"
|
||||||
try:
|
try:
|
||||||
self.save_toml(tmp_file)
|
self.save_toml(tmp_file)
|
||||||
|
|
||||||
# 2. Read the temporary file (library's view of the config)
|
|
||||||
with open(tmp_file, 'r') as f:
|
with open(tmp_file, 'r') as f:
|
||||||
new_config = toml.load(f)
|
new_config = toml.load(f)
|
||||||
|
|
||||||
# 3. Read the current config file (if it exists) to preserve extra sections
|
|
||||||
original = {}
|
original = {}
|
||||||
if os.path.exists(config_file):
|
if os.path.exists(config_file):
|
||||||
with open(config_file, 'r') as f:
|
with open(config_file, 'r') as f:
|
||||||
original = toml.load(f)
|
original = toml.load(f)
|
||||||
|
|
||||||
# 4. Merge: keep everything from the original, then overlay
|
|
||||||
# the library's config table(s). This leaves any top-level
|
|
||||||
# sections not produced by the library exactly as they were.
|
|
||||||
merged = original.copy()
|
merged = original.copy()
|
||||||
for key, value in new_config.items():
|
for key, value in new_config.items():
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
|
|
||||||
# 5. Write back the merged result
|
|
||||||
with open(config_file, 'w') as f:
|
with open(config_file, 'w') as f:
|
||||||
toml.dump(merged, f)
|
toml.dump(merged, f)
|
||||||
|
|
||||||
@@ -107,31 +192,95 @@ class FunguyConfig(botlib.Config):
|
|||||||
logging.error(f"Error saving config to {config_file}: {e}")
|
logging.error(f"Error saving config to {config_file}: {e}")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Always remove the temp file
|
|
||||||
if os.path.exists(tmp_file):
|
if os.path.exists(tmp_file):
|
||||||
os.remove(tmp_file)
|
os.remove(tmp_file)
|
||||||
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
# ----------------------------------------------------------------------
|
||||||
"""
|
# Helpers for formatting
|
||||||
Handle commands related to bot configuration.
|
# ----------------------------------------------------------------------
|
||||||
All sub‑commands require the sender to be the configured admin_user.
|
def _bool_to_str(v):
|
||||||
"""
|
return "true" if v else "false"
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
|
||||||
|
|
||||||
if not match.is_not_from_this_bot() or not match.prefix():
|
|
||||||
|
def _str_to_bool(s: str):
|
||||||
|
return s.lower() in ("true", "yes", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(key, value):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return _bool_to_str(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(value) if value else "(empty)"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_config_option(config, key, value_str):
|
||||||
|
meta = OPTIONS.get(key)
|
||||||
|
if not meta:
|
||||||
|
return False, f"Unknown option '{html_escape(key)}'."
|
||||||
|
|
||||||
|
if meta.get("readonly"):
|
||||||
|
return False, f"'{html_escape(key)}' cannot be changed via !set for safety."
|
||||||
|
|
||||||
|
try:
|
||||||
|
if meta["type"] == "boolean":
|
||||||
|
value = _str_to_bool(value_str)
|
||||||
|
elif meta["type"] == "integer":
|
||||||
|
value = int(value_str)
|
||||||
|
elif meta["type"] == "list":
|
||||||
|
if value_str.strip() == "":
|
||||||
|
value = []
|
||||||
|
else:
|
||||||
|
value = [item.strip() for item in value_str.split(",") if item.strip()]
|
||||||
|
else:
|
||||||
|
value = value_str
|
||||||
|
|
||||||
|
if not meta["validator"](value):
|
||||||
|
return False, f"Invalid value for '{html_escape(key)}'. Expected {meta['type']}."
|
||||||
|
|
||||||
|
# Apply to config object
|
||||||
|
if key == "prefix":
|
||||||
|
config.prefix = value
|
||||||
|
elif key == "timeout":
|
||||||
|
config.timeout = value
|
||||||
|
elif key == "join_on_invite":
|
||||||
|
config.join_on_invite = value
|
||||||
|
elif key == "encryption_enabled":
|
||||||
|
config.encryption_enabled = value
|
||||||
|
elif key == "emoji_verify":
|
||||||
|
config.emoji_verify = value
|
||||||
|
elif key == "ignore_unverified_devices":
|
||||||
|
config.ignore_unverified_devices = value
|
||||||
|
elif key == "store_path":
|
||||||
|
config.store_path = value
|
||||||
|
elif key == "allowlist":
|
||||||
|
config.allowlist = value
|
||||||
|
elif key == "blocklist":
|
||||||
|
config.blocklist = value
|
||||||
|
# admin_user is readonly, not set here
|
||||||
|
|
||||||
|
return True, f"✅ {html_escape(key)} set to {_format_value(key, value)}."
|
||||||
|
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return False, f"Invalid value: {html_escape(str(e))}"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Command handler
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
async def handle_command(room, message, bot, prefix, config):
|
||||||
|
match = botlib.MessageMatch(room, message, bot, prefix)
|
||||||
|
if not (match.is_not_from_this_bot() and match.prefix()):
|
||||||
return
|
return
|
||||||
|
|
||||||
cmd = match.command()
|
cmd = match.command()
|
||||||
if cmd not in ("set", "get", "saveconf", "loadconf", "show", "reset"):
|
if cmd not in ("config", "set", "get", "show", "saveconf", "loadconf", "reset"):
|
||||||
return
|
return
|
||||||
|
|
||||||
sender = str(message.sender)
|
sender = str(message.sender)
|
||||||
if sender != config.admin_user:
|
if sender != config.admin_user:
|
||||||
logging.warning(
|
logging.warning("Unauthorized config command attempt by %s (%s vs %s)", sender, config.admin_user)
|
||||||
"Unauthorized config command attempt by %s in room %s: %s",
|
|
||||||
sender, room.room_id, cmd
|
|
||||||
)
|
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(
|
||||||
room.room_id,
|
room.room_id,
|
||||||
"⛔ You are not authorized to use configuration commands."
|
"⛔ You are not authorized to use configuration commands."
|
||||||
@@ -139,94 +288,109 @@ async def handle_command(room, message, bot, prefix, config):
|
|||||||
return
|
return
|
||||||
|
|
||||||
args = match.args()
|
args = match.args()
|
||||||
|
if cmd == "config":
|
||||||
|
if not args:
|
||||||
|
await _send_help(room, bot)
|
||||||
|
return
|
||||||
|
subcmd = args[0].lower()
|
||||||
|
args = args[1:]
|
||||||
|
else:
|
||||||
|
subcmd = cmd
|
||||||
|
|
||||||
if cmd == "set":
|
if subcmd == "set":
|
||||||
if len(args) != 2:
|
if len(args) != 2:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "Usage: !set <option> <value>\nUse !config show for options.")
|
||||||
"Usage: !set <config_option> <value>")
|
|
||||||
return
|
return
|
||||||
option, value = args
|
option, value = args
|
||||||
if option == "admin_user":
|
success, msg = _set_config_option(config, option, value)
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, msg)
|
||||||
room.room_id,
|
|
||||||
"❌ Changing 'admin_user' via !set is not allowed for security reasons."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif option == "prefix":
|
|
||||||
config.prefix = value
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
f"Prefix set to `{value}`")
|
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
"Invalid configuration option.")
|
|
||||||
|
|
||||||
elif cmd == "get":
|
elif subcmd == "get":
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
await bot.api.send_text_message(room.room_id,
|
await bot.api.send_text_message(room.room_id, "Usage: !get <option>")
|
||||||
"Usage: !get <config_option>")
|
|
||||||
return
|
return
|
||||||
option = args[0]
|
option = args[0]
|
||||||
if option == "admin_user":
|
meta = OPTIONS.get(option)
|
||||||
await bot.api.send_text_message(room.room_id,
|
if not meta:
|
||||||
f"Admin user: {config.admin_user}")
|
await bot.api.send_text_message(room.room_id, f"Unknown option '{html_escape(option)}'.")
|
||||||
elif option == "prefix":
|
return
|
||||||
await bot.api.send_text_message(room.room_id,
|
val = getattr(config, option, meta["default"])
|
||||||
f"Prefix: {config.prefix}")
|
await bot.api.send_text_message(room.room_id, f"{html_escape(option)}: {_format_value(option, val)}")
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id,
|
|
||||||
"Invalid configuration option.")
|
|
||||||
|
|
||||||
elif cmd == "show":
|
elif subcmd == "show":
|
||||||
await bot.api.send_text_message(
|
rows = []
|
||||||
room.room_id,
|
for key, meta in OPTIONS.items():
|
||||||
f"Admin user: {config.admin_user}\nPrefix: {config.prefix}"
|
val = getattr(config, key, meta["default"])
|
||||||
)
|
rows.append(("⚙️", key, _format_value(key, val)))
|
||||||
|
block = code_block("📋 Current Configuration", [{"title": "", "rows": rows}])
|
||||||
|
output = collapsible_summary("📋 Current Configuration", block)
|
||||||
|
await bot.api.send_markdown_message(room.room_id, output)
|
||||||
|
|
||||||
elif cmd == "saveconf":
|
elif subcmd == "saveconf":
|
||||||
try:
|
try:
|
||||||
config.save_config() # uses the stored config_file by default
|
config.save_config()
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "💾 Configuration saved to file.")
|
||||||
room.room_id,
|
|
||||||
"Configuration saved (including disabled plugins)."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, f"❌ Failed to save: {html_escape(str(e))}")
|
||||||
room.room_id,
|
|
||||||
f"❌ Failed to save configuration: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif cmd == "loadconf":
|
elif subcmd == "loadconf":
|
||||||
|
try:
|
||||||
config.load_config(config.config_file)
|
config.load_config(config.config_file)
|
||||||
await bot.api.send_text_message(
|
await bot.api.send_text_message(room.room_id, "🔄 Configuration reloaded from file.")
|
||||||
room.room_id,
|
except Exception as e:
|
||||||
"Configuration reloaded from file."
|
await bot.api.send_text_message(room.room_id, f"❌ Failed to load: {html_escape(str(e))}")
|
||||||
)
|
|
||||||
|
|
||||||
elif cmd == "reset":
|
elif subcmd == "reset":
|
||||||
config.prefix = "!"
|
for key, meta in OPTIONS.items():
|
||||||
await bot.api.send_text_message(
|
if key == "admin_user":
|
||||||
room.room_id,
|
continue
|
||||||
"Configuration reset to defaults (admin_user unchanged)."
|
setattr(config, key, meta["default"])
|
||||||
)
|
if key == "prefix":
|
||||||
|
config.prefix = meta["default"]
|
||||||
|
await bot.api.send_text_message(room.room_id, "♻️ Configuration reset to defaults (admin_user preserved).")
|
||||||
|
|
||||||
|
elif subcmd == "help":
|
||||||
|
await _send_help(room, bot)
|
||||||
|
else:
|
||||||
|
await bot.api.send_text_message(room.room_id, f"Unknown subcommand '{html_escape(subcmd)}'. Use !config help.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_help(room, bot):
|
||||||
|
help_text = """
|
||||||
|
<details>
|
||||||
|
<summary><strong>🔧 Config Plugin Commands</strong></summary>
|
||||||
|
<p><code>!set <option> <value></code> – Change a configuration option</p>
|
||||||
|
<p><code>!get <option></code> – Display a single option</p>
|
||||||
|
<p><code>!show</code> – Show all current settings</p>
|
||||||
|
<p><code>!saveconf</code> – Save configuration to file</p>
|
||||||
|
<p><code>!loadconf</code> – Reload from file</p>
|
||||||
|
<p><code>!reset</code> – Reset to defaults (keeps admin_user)</p>
|
||||||
|
<p><code>!config help</code> – This help</p>
|
||||||
|
<p><strong>Available options:</strong><br>
|
||||||
|
<code>prefix, timeout, join_on_invite, encryption_enabled, emoji_verify, ignore_unverified_devices, store_path, allowlist, blocklist</code></p>
|
||||||
|
<p><em>Note: <code>admin_user</code> can only be changed by editing funguy.conf directly.</em></p>
|
||||||
|
</details>
|
||||||
|
"""
|
||||||
|
await bot.api.send_markdown_message(room.room_id, help_text)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Plugin Metadata
|
# Plugin Metadata
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
__version__ = "1.0.2"
|
__version__ = "1.1.1"
|
||||||
__author__ = "Funguy Bot (hardened)"
|
__author__ = "Funguy Bot"
|
||||||
__description__ = "Admin-only configuration commands (preserves disabled plugins)"
|
__description__ = "Admin‑only configuration management"
|
||||||
__help__ = """
|
__help__ = """
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Admin Config</strong> (!set, !get, !saveconf, …)</summary>
|
<summary><strong>!config</strong> – Manage bot settings</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><code>!set prefix <value></code> – Change command prefix (admin only)</li>
|
<li><code>!set <option> <value></code> – Change a setting</li>
|
||||||
<li><code>!get <option></code> – Display config value (admin only)</li>
|
<li><code>!get <option></code> – View a setting</li>
|
||||||
<li><code>!show</code> – Show current settings (admin only)</li>
|
<li><code>!show</code> – All settings</li>
|
||||||
<li><code>!saveconf</code> / <code>!loadconf</code> – Save/load config (admin only)</li>
|
<li><code>!saveconf</code> – Save to file</li>
|
||||||
<li><code>!reset</code> – Reset to defaults, preserving admin_user (admin only)</li>
|
<li><code>!loadconf</code> – Reload from file</li>
|
||||||
|
<li><code>!reset</code> – Reset defaults</li>
|
||||||
|
<li><code>!config help</code> – This help</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Changing <code>admin_user</code> via bot commands is blocked for safety.</p>
|
|
||||||
<p>The <code>plugins.disabled</code> section is now preserved when saving.</p>
|
|
||||||
</details>
|
</details>
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-2
@@ -6,8 +6,7 @@ import logging
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import socket
|
import socket
|
||||||
import simplematrixbotlib as botlib
|
import simplematrixbotlib as botlib
|
||||||
|
from plugins.common import is_public_destination
|
||||||
from plugins.utils import is_public_destination
|
|
||||||
|
|
||||||
async def check_http(domain):
|
async def check_http(domain):
|
||||||
"""Check if HTTP service is up for the given domain."""
|
"""Check if HTTP service is up for the given domain."""
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"""
|
|
||||||
Plugin for providing a command for the admin to load a plugin.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
import importlib
|
|
||||||
import simplematrixbotlib as botlib
|
|
||||||
import sys # Import sys module for unloading plugins
|
|
||||||
|
|
||||||
# Dictionary to store loaded plugins
|
|
||||||
PLUGINS = {}
|
|
||||||
|
|
||||||
async def load_plugin(plugin_name):
|
|
||||||
"""
|
|
||||||
Asynchronously loads a plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_name (str): The name of the plugin to load.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the plugin is loaded successfully, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Import the plugin module
|
|
||||||
module = importlib.import_module(f"plugins.{plugin_name}")
|
|
||||||
# Add the plugin module to the PLUGINS dictionary
|
|
||||||
PLUGINS[plugin_name] = module
|
|
||||||
logging.info(f"Loaded plugin: {plugin_name}")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
# Log an error if the plugin fails to load
|
|
||||||
logging.error(f"Error loading plugin {plugin_name}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def unload_plugin(plugin_name):
|
|
||||||
"""
|
|
||||||
Asynchronously unloads a plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_name (str): The name of the plugin to unload.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the plugin is unloaded successfully, False otherwise.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if plugin_name in PLUGINS:
|
|
||||||
del PLUGINS[plugin_name] # Remove the plugin from the PLUGINS dictionary
|
|
||||||
del sys.modules[f"plugins.{plugin_name}"] # Unload the plugin module from sys.modules
|
|
||||||
logging.info(f"Unloaded plugin: {plugin_name}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logging.warning(f"Plugin '{plugin_name}' is not loaded")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
# Log an error if the plugin fails to unload
|
|
||||||
logging.error(f"Error unloading plugin {plugin_name}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
|
||||||
"""
|
|
||||||
Asynchronously handles the command to load or unload a plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room (Room): The Matrix room where the command was invoked.
|
|
||||||
message (RoomMessage): The message object containing the command.
|
|
||||||
bot (MatrixBot): The Matrix bot instance.
|
|
||||||
prefix (str): The command prefix.
|
|
||||||
config (dict): The bot's configuration.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
match = botlib.MessageMatch(room, message, bot, prefix)
|
|
||||||
if match.is_not_from_this_bot() and match.prefix():
|
|
||||||
command = match.command()
|
|
||||||
if command == "load":
|
|
||||||
if str(message.sender) == config.admin_user:
|
|
||||||
args = match.args()
|
|
||||||
if len(args) != 1:
|
|
||||||
# Send usage message if the command format is incorrect
|
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !load <plugin>")
|
|
||||||
else:
|
|
||||||
plugin_name = args[0]
|
|
||||||
# Check if the plugin is not already loaded
|
|
||||||
if plugin_name not in PLUGINS:
|
|
||||||
# Load the plugin
|
|
||||||
success = await load_plugin(plugin_name)
|
|
||||||
if success:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' loaded successfully")
|
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error loading plugin '{plugin_name}'")
|
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' is already loaded")
|
|
||||||
else:
|
|
||||||
# Send unauthorized message if the sender is not the admin
|
|
||||||
await bot.api.send_text_message(room.room_id, "You are not authorized to load plugins.")
|
|
||||||
elif command == "unload":
|
|
||||||
if str(message.sender) == config.admin_user:
|
|
||||||
args = match.args()
|
|
||||||
if len(args) != 1:
|
|
||||||
# Send usage message if the command format is incorrect
|
|
||||||
await bot.api.send_text_message(room.room_id, "Usage: !unload <plugin>")
|
|
||||||
else:
|
|
||||||
plugin_name = args[0]
|
|
||||||
# Unload the plugin
|
|
||||||
success = await unload_plugin(plugin_name)
|
|
||||||
if success:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' unloaded successfully")
|
|
||||||
else:
|
|
||||||
await bot.api.send_text_message(room.room_id, f"Error unloading plugin '{plugin_name}'")
|
|
||||||
else:
|
|
||||||
# Send unauthorized message if the sender is not the admin
|
|
||||||
await bot.api.send_text_message(room.room_id, "You are not authorized to unload plugins.")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Metadata
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
|
||||||
__author__ = "Funguy Bot"
|
|
||||||
__description__ = "Load/unload plugins at runtime"
|
|
||||||
__help__ = """
|
|
||||||
<details>
|
|
||||||
<summary><strong>Admin: !load / !unload</strong></summary>
|
|
||||||
<p><code>!load <plugin></code> / <code>!unload <plugin></code> – Dynamically load or unload a plugin module. Admin only.</p>
|
|
||||||
</details>
|
|
||||||
"""
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""
|
|
||||||
Security utilities for Funguy Bot plugins.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import socket
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger("security_utils")
|
|
||||||
|
|
||||||
# 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'), # link‑local
|
|
||||||
ipaddress.ip_network('0.0.0.0/8'), # "this" network
|
|
||||||
ipaddress.ip_network('::1/128'), # IPv6 loopback
|
|
||||||
ipaddress.ip_network('fc00::/7'), # unique local
|
|
||||||
ipaddress.ip_network('fe80::/10'), # link‑local
|
|
||||||
ipaddress.ip_network('::/128'), # unspecified
|
|
||||||
]
|
|
||||||
|
|
||||||
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:
|
|
||||||
# Try parsing as an IP address first
|
|
||||||
addr = ipaddress.ip_address(target)
|
|
||||||
if any(addr in net for net in PRIVATE_RANGES):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Resolve hostname to IPs
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# No‑op command handler – prevents bot crash because funguy.py calls
|
|
||||||
# handle_command() on every module in the plugins directory.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
async def handle_command(room, message, bot, prefix, config):
|
|
||||||
"""This module is not a command plugin; ignore all messages."""
|
|
||||||
pass
|
|
||||||
Reference in New Issue
Block a user