various plugin refactors and fixes

This commit is contained in:
2026-05-09 12:08:38 -05:00
parent 5c6234a317
commit b722a78d21
6 changed files with 402 additions and 334 deletions
+21 -17
View File
@@ -104,16 +104,18 @@ systemctl start funguybot
The bot includes the following plugins: The bot includes the following plugins:
- **admin.py**: Full room moderation multiword 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**: Inprocess cron scheduler (roomaware, 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**: Peruser room statistics (Limnoriastyle), with multiword 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, OpenMeteo 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
+122 -34
View File
@@ -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 nonadmin 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:
args = match.args() await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
if len(args) != 2: return
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") args = match.args()
else: if len(args) != 1:
plugin_name, room_id = args await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin>")
await self.disable_plugin(room_id, plugin_name) return
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'") plugin_name = args[0]
else: room_id = room.room_id
await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.") await self.disable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"🚫 Plugin '{plugin_name}' disabled in this room.")
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:
args = match.args() await self.bot.api.send_text_message(room.room_id, "⛔ Admin only.")
if len(args) != 2: return
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") args = match.args()
else: if len(args) != 1:
plugin_name, room_id = args await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin>")
await self.enable_plugin(room_id, plugin_name) return
await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'") plugin_name = args[0]
else: room_id = room.room_id
await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") await self.enable_plugin(room_id, plugin_name)
await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' enabled in this room.")
return
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] = []
+258 -94
View File
@@ -2,6 +2,14 @@
Custom configuration class for the Funguy bot. Custom configuration class for the Funguy bot.
Securityhardened: only the configured admin user can read or change settings. Securityhardened: 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": "Autojoin 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 (readonly 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 (reread 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 subcommands 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":
config.load_config(config.config_file) try:
await bot.api.send_text_message( config.load_config(config.config_file)
room.room_id, await bot.api.send_text_message(room.room_id, "🔄 Configuration reloaded from file.")
"Configuration reloaded from file." except Exception as e:
) 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 &lt;option&gt; &lt;value&gt;</code> Change a configuration option</p>
<p><code>!get &lt;option&gt;</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__ = "Adminonly 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 &lt;value&gt;</code> Change command prefix (admin only)</li> <li><code>!set &lt;option&gt; &lt;value&gt;</code> Change a setting</li>
<li><code>!get &lt;option&gt;</code> Display config value (admin only)</li> <li><code>!get &lt;option&gt;</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
View File
@@ -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."""
-128
View File
@@ -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 &lt;plugin&gt;</code> / <code>!unload &lt;plugin&gt;</code> Dynamically load or unload a plugin module. Admin only.</p>
</details>
"""
-59
View File
@@ -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'), # linklocal
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'), # linklocal
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 linklocal 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
# ---------------------------------------------------------------------------
# Noop 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