From abb4b5e245e940cd2b4b61031a5eb90d43c9e6b7 Mon Sep 17 00:00:00 2001 From: Hash Borgir Date: Wed, 6 May 2026 23:14:35 -0500 Subject: [PATCH] karma plugin added. various debug fixes to funguy.py --- README.md | 47 +++ funguy.conf | 2 +- funguy.py | 379 ++++++++++++--------- plugins/help.py | 68 ++++ plugins/karma.py | 871 ++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 1114 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index eb8b928..89d74f4 100644 --- a/README.md +++ b/README.md @@ -733,6 +733,53 @@ Fetches current time information for locations using the TimeAPI.io service. !time New York ``` +### Karma Tracking System + +**☯ !karma [user]** +Track karma points for users with leaderboards and statistics. Supports display names and Matrix IDs. + +**Features:** +- Give/take karma points from users using display names or Matrix IDs +- Track karma history with timestamps +- View karma leaderboards (top/bottom) +- Rate limiting to prevent spam +- Room-specific karma tracking + +**Commands:** +- `!karma ` - Show karma for a user +- `!karma++ ` - Give +1 karma to a user +- `!karma-- ` - Give -1 karma to a user +- `!karma top [n]` - Show top karma entries +- `!karma bottom [n]` - Show bottom karma entries +- `!karma rank ` - Show rank of user +- `!karma stats` - Show overall statistics +- `!karma history ` - Show recent karma history + +**Shortcuts:** +- `!++ ` - Same as `!karma++ ` +- `!-- ` - Same as `!karma-- ` +- `++` - Give +1 karma (inline) +- `--` - Give -1 karma (inline) + +**Examples:** +```bash +!karma @user:server.com +!karma++ @user:server.com +!karma top 5 +!karma bottom 3 +!karma rank @user:server.com +!karma stats +!karma history @user:server.com +``` + +**Notes:** +- You cannot modify your own karma +- There is a 5 second cooldown between votes +- Karma is tracked separately per room +- Display names with emojis are supported + +### Dependencies + - Ensure all environment variables are set correctly - Check that required services are running (Stable Diffusion API, Ollama, etc.) - Verify plugin permissions and whitelist settings diff --git a/funguy.conf b/funguy.conf index dcf2536..33780be 100644 --- a/funguy.conf +++ b/funguy.conf @@ -14,4 +14,4 @@ config_file = "funguy.conf" [plugins.disabled] "!uFhErnfpYhhlauJsNK:matrix.org" = [ "youtube-preview", "ai", "proxy",] "!vYcfWXpPvxeQvhlFdV:matrix.org" = [] -"!NXdVjDXPxXowPkrJJY:matrix.org" = [ "karma"] +"!NXdVjDXPxXowPkrJJY:matrix.org" = [] diff --git a/funguy.py b/funguy.py index fe493f2..e4e2155 100755 --- a/funguy.py +++ b/funguy.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ -Funguy Bot Class - A modular Matrix bot with plugin support +Funguy Bot Class """ # Importing necessary libraries and modules @@ -13,76 +13,78 @@ from dotenv import load_dotenv # Library for loading environment variables from import time # Time-related functions import sys # System-specific parameters and functions import toml # Library for parsing TOML configuration files +import socket # For network diagnostics # Importing FunguyConfig class from plugins.config module from plugins.config import FunguyConfig - class FunguyBot: """ A bot class for managing plugins and handling commands in a Matrix chat environment. - - Methods: - - __init__: Constructor method for initializing the bot. - - load_dotenv: Method to load environment variables from a .env file. - - setup_logging: Method to configure logging settings. - - load_plugins: Method to load plugins from the specified directory. - - reload_plugins: Method to reload all plugins. - - load_config: Method to load configuration settings. - - load_disabled_plugins: Method to load disabled plugins from configuration file. - - save_disabled_plugins: Method to save disabled plugins to configuration file. - - handle_commands: Method to handle incoming commands and dispatch them to appropriate plugins. - - rehash_config: Method to rehash the configuration settings. - - disable_plugin: Method to disable a plugin for a specific room. - - enable_plugin: Method to enable a plugin for a specific room. - - run: Method to initialize and run the bot. - - Properties: - - PLUGINS_DIR: Directory where plugins are stored - - PLUGINS: Dictionary to store loaded plugins - - config: Configuration object - - bot: Bot object - - disabled_plugins: Dictionary to store disabled plugins for each room """ def __init__(self): """ Constructor method for FunguyBot class. """ + print("[INIT] Starting FunguyBot initialization...") + # Setting up instance variables self.PLUGINS_DIR = "plugins" # Directory where plugins are stored self.PLUGINS = {} # Dictionary to store loaded plugins self.config = None # Configuration object self.bot = None # Bot object self.disabled_plugins = {} # Dictionary to store disabled plugins for each room + + print("[INIT] Loading environment variables...") self.load_dotenv() # Loading environment variables from .env file + + print("[INIT] Setting up logging...") self.setup_logging() # Setting up logging configurations + + print("[INIT] Loading plugins...") self.load_plugins() # Loading plugins + + print("[INIT] Loading config...") self.load_config() # Loading bot configuration + + print("[INIT] Loading disabled plugins...") self.load_disabled_plugins() # Loading disabled plugins from configuration file + print("[INIT] FunguyBot initialization complete!") + def load_dotenv(self): """ Method to load environment variables from a .env file. """ load_dotenv() + print("[ENV] Environment variables loaded") def setup_logging(self): """ Method to configure logging settings. """ - # Basic configuration for logging messages to console + # Get log level from environment, default to INFO log_level = os.getenv("LOG_LEVEL", "INFO").upper() - # Configure logging format + # Convert string to logging constant + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL + } + level = level_map.get(log_level, logging.INFO) + logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=getattr(logging, log_level, logging.INFO) + level=level ) + logging.getLogger().setLevel(level) - # Set specific loggers to appropriate levels - logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO)) - logging.getLogger("simplematrixbotlib").setLevel(logging.WARNING) + # Optionally silence noisy libraries + logging.getLogger("aiohttp").setLevel(logging.WARNING) logging.getLogger("nio").setLevel(logging.WARNING) logging.info(f"Logging configured with level: {log_level}") @@ -92,20 +94,16 @@ class FunguyBot: Method to load plugins from the specified directory. """ # Iterating through files in the plugins directory - plugin_count = 0 for plugin_file in os.listdir(self.PLUGINS_DIR): - if plugin_file.endswith(".py") and plugin_file != "__init__.py": + if plugin_file.endswith(".py"): # Checking if file is a Python file plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name try: # Importing plugin module dynamically module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}") self.PLUGINS[plugin_name] = module # Storing loaded plugin module - logging.info(f"Loaded plugin: {plugin_name}") - plugin_count += 1 + logging.info(f"Loaded plugin: {plugin_name}") # Logging successful plugin loading except Exception as e: - logging.error(f"Error loading plugin {plugin_name}: {e}", exc_info=True) - - logging.info(f"Total plugins loaded: {plugin_count}") + logging.error(f"Error loading plugin {plugin_name}: {e}") # Logging error if plugin loading fails def setup_plugins(self): """ @@ -115,45 +113,28 @@ class FunguyBot: that plugins which register custom event listeners (e.g. on_custom_event for RoomMemberEvent) receive a valid bot instance. """ - setup_count = 0 for plugin_name, plugin_module in self.PLUGINS.items(): if hasattr(plugin_module, "setup") and callable(plugin_module.setup): try: plugin_module.setup(self.bot) logging.info(f"Setup called for plugin: {plugin_name}") - setup_count += 1 except Exception as e: - logging.error(f"Error during setup of plugin {plugin_name}: {e}", exc_info=True) - - logging.info(f"Setup completed for {setup_count} plugins") + logging.error(f"Error during setup of plugin {plugin_name}: {e}") def reload_plugins(self): """ Method to reload all plugins. """ - logging.info("Reloading plugins...") - - # Clear loaded plugins dictionary - self.PLUGINS.clear() - + self.PLUGINS = {} # Clearing loaded plugins dictionary # Unloading modules from sys.modules - modules_to_remove = [] - for module_name in sys.modules.keys(): - if module_name.startswith(self.PLUGINS_DIR + "."): - modules_to_remove.append(module_name) - - for module_name in modules_to_remove: - del sys.modules[module_name] - - # Reload plugins - self.load_plugins() - + for plugin_name in list(sys.modules.keys()): + if plugin_name.startswith(self.PLUGINS_DIR + "."): + del sys.modules[plugin_name] # Deleting plugin module from system modules + self.load_plugins() # Reloading plugins # Re-run setup for any plugin that needs it (bot already exists at this point) if self.bot is not None: self.setup_plugins() - logging.info("Plugins reloaded successfully") - def load_config(self): """ Method to load configuration settings. @@ -167,18 +148,11 @@ class FunguyBot: """ # Checking if configuration file exists if os.path.exists('funguy.conf'): - try: - # Loading configuration data from TOML file - with open('funguy.conf', 'r') as f: - config_data = toml.load(f) - # Extracting disabled plugins from configuration data - self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {}) - logging.info(f"Loaded disabled plugins configuration for {len(self.disabled_plugins)} rooms") - except Exception as e: - logging.error(f"Error loading disabled plugins configuration: {e}") - self.disabled_plugins = {} - else: - logging.info("No funguy.conf found, starting with empty disabled plugins list") + # Loading configuration data from TOML file + with open('funguy.conf', 'r') as f: + config_data = toml.load(f) + # Extracting disabled plugins from configuration data + self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {}) def save_disabled_plugins(self): """ @@ -187,25 +161,14 @@ class FunguyBot: existing_config = {} # Checking if configuration file exists if os.path.exists('funguy.conf'): - try: - # Loading existing configuration data - with open('funguy.conf', 'r') as f: - existing_config = toml.load(f) - except Exception as e: - logging.error(f"Error reading funguy.conf: {e}") - + # Loading existing configuration data + with open('funguy.conf', 'r') as f: + existing_config = toml.load(f) # Updating configuration data with disabled plugins - if 'plugins' not in existing_config: - existing_config['plugins'] = {} - existing_config['plugins']['disabled'] = self.disabled_plugins - + existing_config['plugins'] = {'disabled': self.disabled_plugins} # Writing updated configuration data back to file - try: - with open('funguy.conf', 'w') as f: - toml.dump(existing_config, f) - logging.info("Saved disabled plugins configuration") - except Exception as e: - logging.error(f"Error saving disabled plugins configuration: {e}") + with open('funguy.conf', 'w') as f: + toml.dump(existing_config, f) async def handle_commands(self, room, message): """ @@ -217,9 +180,9 @@ class FunguyBot: if match.is_not_from_this_bot() and match.prefix() and match.command("reload"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user self.reload_plugins() # Reloading plugins - await self.bot.api.send_text_message(room.room_id, "✅ Plugins reloaded successfully") + await self.bot.api.send_text_message(room.room_id, "Plugins reloaded successfully") # Sending success message 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.") # Sending unauthorized message return # Disable plugin command @@ -227,13 +190,13 @@ class FunguyBot: if str(message.sender) == self.config.admin_user: # Checking if sender is admin user args = match.args() # Getting command arguments if len(args) != 2: # Checking if correct number of arguments provided - await self.bot.api.send_text_message(room.room_id, "Usage: !disable ") + await self.bot.api.send_text_message(room.room_id, "Usage: !disable ") # Sending usage message else: plugin_name, room_id = args # Extracting plugin name and room ID await self.disable_plugin(room_id, plugin_name) # Disabling plugin - await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' disabled for room '{room_id}'") + await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' disabled for room '{room_id}'") # Sending success message else: - await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to disable plugins.") + await self.bot.api.send_text_message(room.room_id, "You are not authorized to disable plugins.") # Sending unauthorized message return # Enable plugin command @@ -241,85 +204,88 @@ class FunguyBot: if str(message.sender) == self.config.admin_user: # Checking if sender is admin user args = match.args() # Getting command arguments if len(args) != 2: # Checking if correct number of arguments provided - await self.bot.api.send_text_message(room.room_id, "Usage: !enable ") + await self.bot.api.send_text_message(room.room_id, "Usage: !enable ") # Sending usage message else: plugin_name, room_id = args # Extracting plugin name and room ID await self.enable_plugin(room_id, plugin_name) # Enabling plugin - await self.bot.api.send_text_message(room.room_id, f"✅ Plugin '{plugin_name}' enabled for room '{room_id}'") + await self.bot.api.send_text_message(room.room_id, f"Plugin '{plugin_name}' enabled for room '{room_id}'") # Sending success message else: - await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to enable plugins.") + await self.bot.api.send_text_message(room.room_id, "You are not authorized to enable plugins.") # Sending unauthorized message + return + + # List plugins command with descriptions (bold plugin names) + if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"): + if self.PLUGINS: + # Build a list with plugin names and their descriptions + plugin_list = [] + for plugin_name in sorted(self.PLUGINS.keys()): + plugin_module = self.PLUGINS[plugin_name] + # Try to get description from plugin + description = getattr(plugin_module, "__description__", None) + if not description: + # Try to get from docstring + docstring = getattr(plugin_module, "__doc__", None) + if docstring: + # Get first line of docstring + description = docstring.strip().split('\n')[0] + else: + description = "No description available" + + # Make plugin name bold using HTML tag + plugin_list.append(f"[{plugin_name}.py]: {description}") + + # Send as HTML message (bold will render) + response = "\n".join(plugin_list) + # Split into multiple messages if too long + if len(response) > 4000: + chunk = "" + for line in plugin_list: + if len(chunk) + len(line) + 1 > 4000: + await self.bot.api.send_markdown_message(room.room_id, chunk) + chunk = line + "\n" + else: + chunk += line + "\n" + if chunk: + await self.bot.api.send_markdown_message(room.room_id, chunk) + else: + await self.bot.api.send_markdown_message(room.room_id, response) + else: + await self.bot.api.send_text_message(room.room_id, "No plugins are currently loaded.") return # Rehash config command if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"): if str(message.sender) == self.config.admin_user: # Checking if sender is admin user self.rehash_config() # Rehashing configuration - await self.bot.api.send_text_message(room.room_id, "✅ Configuration rehashed successfully") + await self.bot.api.send_text_message(room.room_id, "Config rehashed") # Sending success message else: - await self.bot.api.send_text_message(room.room_id, "❌ You are not authorized to rehash configuration.") + await self.bot.api.send_text_message(room.room_id, "You are not authorized to reload plugins.") # Sending unauthorized message return - # List plugins command - if match.is_not_from_this_bot() and match.prefix() and match.command("plugins"): - if self.PLUGINS: - plugin_list = "\n".join([f" • {name}" for name in sorted(self.PLUGINS.keys())]) - await self.bot.api.send_markdown_message( - room.room_id, - f"**📦 Loaded Plugins ({len(self.PLUGINS)})**\n\n{plugin_list}" - ) - else: - await self.bot.api.send_text_message(room.room_id, "No plugins are currently loaded.") - return - - # Dispatching commands to plugins (only if plugin is not disabled for this room) + # Dispatching commands to plugins for plugin_name, plugin_module in self.PLUGINS.items(): - # Check if plugin is disabled for this room - if plugin_name in self.disabled_plugins.get(room.room_id, []): - continue - - # Check if plugin has handle_command function - if hasattr(plugin_module, "handle_command") and callable(plugin_module.handle_command): + if plugin_name not in self.disabled_plugins.get(room.room_id, []): try: await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config) except Exception as e: logging.error(f"Error in plugin {plugin_name}: {e}", exc_info=True) - # Don't break the loop, continue with other plugins def rehash_config(self): """ Method to rehash the configuration settings. """ - logging.info("Rehashing configuration...") - try: - # Reload environment variables - load_dotenv() - - # Reload configuration - del self.config # Deleting current configuration object - self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration - - # Reload disabled plugins - self.load_disabled_plugins() - - logging.info("Configuration rehashed successfully") - except Exception as e: - logging.error(f"Error rehashing configuration: {e}") + 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): """ Method to disable a plugin for a specific room. """ - if plugin_name not in self.PLUGINS: - logging.warning(f"Attempted to disable non-existent plugin: {plugin_name}") - return - if room_id not in self.disabled_plugins: self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist - if plugin_name not in self.disabled_plugins[room_id]: self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room self.save_disabled_plugins() # Saving disabled plugins to configuration file - logging.info(f"Plugin '{plugin_name}' disabled for room {room_id}") async def enable_plugin(self, room_id, plugin_name): """ @@ -327,60 +293,143 @@ class FunguyBot: """ 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 - - # Clean up empty room entries - if not self.disabled_plugins[room_id]: - del self.disabled_plugins[room_id] - self.save_disabled_plugins() # Saving disabled plugins to configuration file - logging.info(f"Plugin '{plugin_name}' enabled for room {room_id}") + + def test_connectivity(self, hostname, port=443): + """ + Test network connectivity to Matrix server. + """ + logging.info(f"Testing connectivity to {hostname}:{port}...") + try: + # Test DNS resolution + ip_address = socket.gethostbyname(hostname) + logging.info(f"✓ DNS resolution successful: {hostname} -> {ip_address}") + + # Test socket connection + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + result = sock.connect_ex((hostname, port)) + sock.close() + + if result == 0: + logging.info(f"✓ Socket connection successful to {hostname}:{port}") + return True + else: + logging.error(f"✗ Socket connection failed to {hostname}:{port} (error code: {result})") + return False + except socket.gaierror as e: + logging.error(f"✗ DNS resolution failed for {hostname}: {e}") + return False + except Exception as e: + logging.error(f"✗ Connectivity test failed: {e}") + return False def run(self): """ Method to initialize and run the bot. """ + print("\n" + "="*60) + print("FUNGUY BOT - STARTING") + print("="*60 + "\n") + + # Retrieving Matrix credentials from environment variables + MATRIX_URL = os.getenv("MATRIX_URL") + MATRIX_USER = os.getenv("MATRIX_USER") + MATRIX_PASS = os.getenv("MATRIX_PASS") + + # Validate credentials + if not MATRIX_URL: + logging.error("MATRIX_URL not set in .env file") + return + if not MATRIX_USER: + logging.error("MATRIX_USER not set in .env file") + return + if not MATRIX_PASS: + logging.error("MATRIX_PASS not set in .env file") + return + + logging.info(f"Matrix URL: {MATRIX_URL}") + logging.info(f"Matrix User: {MATRIX_USER}") + + # Extract hostname from URL for connectivity test + hostname = MATRIX_URL.replace("https://", "").replace("http://", "").split("/")[0] + + # Test connectivity before attempting to connect + logging.info("="*40) + logging.info("RUNNING NETWORK DIAGNOSTICS") + logging.info("="*40) + + if not self.test_connectivity(hostname, 443): + logging.error("Connectivity test failed. Please check:") + logging.error(" 1. Your internet connection") + logging.error(" 2. Firewall settings (outbound port 443)") + logging.error(" 3. DNS resolution") + logging.error(f" 4. If {hostname} is accessible") + return + + logging.info("="*40) + logging.info("ATTEMPTING MATRIX CONNECTION") + logging.info("="*40) + try: - # Retrieving Matrix credentials from environment variables - MATRIX_URL = os.getenv("MATRIX_URL") - MATRIX_USER = os.getenv("MATRIX_USER") - MATRIX_PASS = os.getenv("MATRIX_PASS") - - # Validate credentials - if not all([MATRIX_URL, MATRIX_USER, MATRIX_PASS]): - logging.error("Missing Matrix credentials in .env file. Please check MATRIX_URL, MATRIX_USER, MATRIX_PASS") - return - + logging.info(f"Creating credentials object for {MATRIX_USER}...") creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object + logging.info("✓ Credentials object created") + + logging.info("Creating bot instance...") self.bot = botlib.Bot(creds, self.config) # Creating bot instance + logging.info("✓ Bot instance created") - logging.info(f"Bot starting with user: {MATRIX_USER}") - logging.info(f"Connected to homeserver: {MATRIX_URL}") + # 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. - # This is what registers custom event listeners such as the join-event - # listener in the welcome plugin. self.setup_plugins() + logging.info("✓ Plugin setup complete") # Defining listener for message events @self.bot.listener.on_message_event async def wrapper_handle_commands(room, message): await self.handle_commands(room, message) # Calling handle_commands method for incoming messages - logging.info("Bot is ready and listening for commands...") + 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 except Exception as e: - logging.error(f"Fatal error running bot: {e}", exc_info=True) - sys.exit(1) - + 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 if __name__ == "__main__": + print("\n" + "="*60) + print("FUNGUY BOT LAUNCHER") + print("="*60) + try: + print("Creating bot instance...") bot = FunguyBot() # Creating instance of FunguyBot + print("Bot instance created. Running bot...") bot.run() # Running the bot except KeyboardInterrupt: - logging.info("Bot stopped by user") + print("\n[!] Bot stopped by user") sys.exit(0) except Exception as e: + print(f"\n[!] Fatal error: {e}") logging.error(f"Unhandled exception: {e}", exc_info=True) sys.exit(1) diff --git a/plugins/help.py b/plugins/help.py index f99e20b..7d871e7 100644 --- a/plugins/help.py +++ b/plugins/help.py @@ -508,6 +508,30 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
  • !arxiv - Search for papers (shows abstracts)
  • !arxiv list - List papers without abstracts
  • !arxiv category - Browse recent papers by category
  • +
  • !ar0; 10px; color: #3b3a30; font-family: monospace; font-size: 12px; background: #f8f8f8; border: 1px solid #c8c8c8; border-radius: 3px; padding: 0 5px; }" + } + }, + "code": { + "background": "white", + "color": "#3b3a30", + "font-family": "monospace", + "font-size": "12px", + "border": "1px solid #c8c8c8", + "border-radius": "3px", + "padding": "0 5px" + } +} + +
    + +
    +📚 !arxiv [query] +

    Search academic papers on arXiv.org. Categories include AI, ML, Security, Physics, Math, and more. No API key required.

    +

    Commands:

    +
      +
    • !arxiv - Search for papers (shows abstracts)
    • +
    • !arxiv list - List papers without abstracts
    • +
    • !arxiv category - Browse recent papers by category
    • !arxiv recent - Most recent papers in category
    • !arxiv random - Get a random paper
    • !arxiv - Get paper by arXiv ID (e.g., 2101.00101)
    • @@ -548,6 +572,50 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
    +
    !karma [user] +

    Track karma points for users with leaderboards and statistics. Supports display names and Matrix IDs.

    +

    Commands:

    +
      +
    • !karma - Show karma for a user
    • +
    • !karma++ - Give +1 karma to a user
    • +
    • !karma-- - Give -1 karma to a user
    • +
    • !karma top [n] - Show top karma entries
    • +
    • !karma bottom [n] - Show bottom karma entries
    • +
    • !karma rank - Show rank of user
    • +
    • !karma stats - Show overall statistics
    • +
    • !karma history - Show recent karma history
    • +
    • !++ - Shortcut for !karma++
    • +
    • !-- - Shortcut for !karma--
    • +
    • ++ - Inline karma (message contains ++)
    • +
    • -- - Inline karma (message contains --)
    • +
    +

    Features:

    +
      +
    • Supports display names and Matrix IDs
    • +
    • Room-specific karma tracking
    • +
    • Rate limiting to prevent spam
    • +
    • Karma history tracking
    • +
    • Leaderboards and statistics
    • +
    +
    + +
    🔥 !hn [command] +

    Fetch top stories from Hacker News using Firebase API. No API key required.

    +

    Commands:

    +
      +
    • !hn - Show top 5 stories (default)
    • +
    • !hn top - Top stories
    • +
    • !hn new - Newest stories
    • +
    • !hn best - Best stories
    • +
    • !hn ask - Ask HN threads
    • +
    • !hn show - Show HN posts
    • +
    • !hn job - Job postings
    • +
    • !hn story - Get details of a specific story
    • +
    • !hn comments - Show comments for a story
    • +
    • !hn search - Search stories (via Algolia)
    • +
    +
    +
    ⏱️ !cron [add|remove] [room_id] [cron_entry] [command]

    Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.

    diff --git a/plugins/karma.py b/plugins/karma.py index f09eb52..833a823 100644 --- a/plugins/karma.py +++ b/plugins/karma.py @@ -1,106 +1,803 @@ """ -This plugin provides a command to manage karma points for users. +Advanced Karma Plugin for Funguy Bot + +Provides comprehensive karma tracking with leaderboards, trends, and statistics. + +Features: + * Give/take karma points from users using display names or Matrix IDs + * Track karma history with timestamps + * View karma leaderboards (top/bottom) + * Rate limiting to prevent spam + * Room-specific karma tracking + +Commands: + !karma - Show this help + !karma - Show karma for a user + !karma++ - Give +1 karma + !karma-- - Give -1 karma + !karma top [n] - Show top karma entries + !karma bottom [n] - Show bottom karma entries + !karma rank - Show rank of user + !karma stats - Show overall statistics + !karma history - Show recent karma history + +Shortcuts: + !++ - Same as !karma++ + !-- - Same as !karma-- + ++ - Give +1 karma (inline) + -- - Give -1 karma (inline) """ - -# plugins/karma.py - import sqlite3 import logging import simplematrixbotlib as botlib +from datetime import datetime, timedelta +import re +import asyncio +import traceback + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +# Prevent spam: minimum seconds between karma changes to same target by same user +COOLDOWN_SECONDS = 5 + +# Database file +DB_FILE = "karma.db" + +# Cache for display name to user ID mappings (per room) +# Structure: {room_id: {display_name: user_id}} +display_name_cache = {} + +# Last time we refreshed the cache (per room) +cache_timestamp = {} + + +# --------------------------------------------------------------------------- +# Database Setup with Room Support +# --------------------------------------------------------------------------- + +def init_db(): + """Initialize the database tables with room support.""" + logging.info("Initializing karma database...") + conn = sqlite3.connect(DB_FILE) + c = conn.cursor() + + # Create tables (store user_id, not display name) + c.execute('''CREATE TABLE IF NOT EXISTS karma ( + room_id TEXT, + user_id TEXT, + points INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (room_id, user_id) + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS karma_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT, + user_id TEXT, + change INTEGER, + new_points INTEGER, + voter TEXT, + voted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )''') + + c.execute('''CREATE TABLE IF NOT EXISTS karma_cooldown ( + room_id TEXT, + user_id TEXT, + voter TEXT, + last_voted TIMESTAMP, + PRIMARY KEY (room_id, user_id, voter) + )''') + + conn.commit() + conn.close() + logging.info("Karma database initialized successfully") + + +def get_connection(): + """Get database connection with row factory for dict access.""" + conn = sqlite3.connect(DB_FILE) + conn.row_factory = sqlite3.Row + return conn + + +# --------------------------------------------------------------------------- +# Display Name Resolution +# --------------------------------------------------------------------------- + +async def refresh_display_name_cache(bot, room_id): + """Refresh the cache of display names to user IDs for a room.""" + global display_name_cache, cache_timestamp + + # Check if we need to refresh (cache older than 5 minutes) + now = datetime.now().timestamp() + if room_id in cache_timestamp and (now - cache_timestamp[room_id]) < 300: + return + + logging.info(f"Refreshing display name cache for room {room_id}") + + try: + # Try to get room members from the bot's state + if hasattr(bot, 'async_client') and bot.async_client: + # Get the room state + room = bot.async_client.rooms.get(room_id) + if room and hasattr(room, 'users'): + # Build mapping of display names to user IDs + name_map = {} + for user_id, user_info in room.users.items(): + # Get display name - try different attributes + display_name = None + if hasattr(user_info, 'display_name') and user_info.display_name: + display_name = user_info.display_name + elif hasattr(user_info, 'name') and user_info.name: + display_name = user_info.name + + if display_name: + name_map[display_name.lower()] = user_id + # Also store without emojis for easier matching + clean_name = re.sub(r'[^\w\s]', '', display_name).strip().lower() + if clean_name and clean_name != display_name.lower(): + name_map[clean_name] = user_id + + display_name_cache[room_id] = name_map + cache_timestamp[room_id] = now + logging.info(f"Cached {len(name_map)} display names for room {room_id}") + return + + except Exception as e: + logging.warning(f"Could not refresh display name cache: {e}") + + # If we couldn't get members, initialize empty cache + display_name_cache[room_id] = {} + cache_timestamp[room_id] = now + + +def resolve_display_name(room_id, display_name, bot=None): + """Resolve a display name to a Matrix user ID.""" + global display_name_cache + + # If it's already a valid Matrix ID, return it + if display_name.startswith('@') and ':' in display_name: + return display_name + + # Check the cache + if room_id in display_name_cache: + name_map = display_name_cache[room_id] + + # Try exact match (case-insensitive) + key = display_name.lower() + if key in name_map: + return name_map[key] + + # Try without emojis/special characters + clean_key = re.sub(r'[^\w\s]', '', display_name).strip().lower() + if clean_key and clean_key in name_map: + return name_map[clean_key] + + # Try partial match (if display name is contained in a cached name) + for cached_name, user_id in name_map.items(): + if key in cached_name or cached_name in key: + return user_id + + return None + + +def get_display_name_from_user_id(bot, room_id, user_id): + """Get the display name for a user ID.""" + try: + if hasattr(bot, 'async_client') and bot.async_client: + room = bot.async_client.rooms.get(room_id) + if room and hasattr(room, 'users') and user_id in room.users: + user_info = room.users[user_id] + if hasattr(user_info, 'display_name') and user_info.display_name: + return user_info.display_name + except: + pass + + # Fallback: extract local part from user ID + if ':' in user_id: + return user_id.split(':')[0].lstrip('@') + return user_id.lstrip('@') + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def is_on_cooldown(room_id, user_id, voter): + """Check if voter is on cooldown for this user in this room.""" + conn = get_connection() + c = conn.cursor() + + c.execute('''SELECT last_voted FROM karma_cooldown + WHERE room_id = ? AND user_id = ? AND voter = ?''', + (room_id, user_id, voter)) + row = c.fetchone() + conn.close() + + if row: + try: + last_voted = datetime.fromisoformat(row['last_voted']) + if datetime.now() - last_voted < timedelta(seconds=COOLDOWN_SECONDS): + return True + except: + pass + return False + + +def get_cooldown_remaining(room_id, user_id, voter): + """Get remaining cooldown seconds for a voter.""" + conn = get_connection() + c = conn.cursor() + + c.execute('''SELECT last_voted FROM karma_cooldown + WHERE room_id = ? AND user_id = ? AND voter = ?''', + (room_id, user_id, voter)) + row = c.fetchone() + conn.close() + + if row: + try: + last_voted = datetime.fromisoformat(row['last_voted']) + elapsed = (datetime.now() - last_voted).total_seconds() + remaining = COOLDOWN_SECONDS - elapsed + if remaining > 0: + return int(remaining) + except: + pass + return 0 + + +def update_cooldown(room_id, user_id, voter): + """Update the cooldown timestamp for this voter.""" + conn = get_connection() + c = conn.cursor() + + c.execute('''INSERT OR REPLACE INTO karma_cooldown (room_id, user_id, voter, last_voted) + VALUES (?, ?, ?, ?)''', (room_id, user_id, voter, datetime.now().isoformat())) + conn.commit() + conn.close() + + +def update_karma(room_id, user_id, change, voter): + """Update karma points for a user in a specific room.""" + if change == 0: + return None + + logging.debug(f"Updating karma: room={room_id}, user={user_id}, change={change}, voter={voter}") + + conn = get_connection() + c = conn.cursor() + + # Insert or ignore the user + c.execute('''INSERT OR IGNORE INTO karma (room_id, user_id, points) VALUES (?, ?, 0)''', + (room_id, user_id)) + + # Update points + c.execute('''UPDATE karma SET points = points + ?, updated_at = CURRENT_TIMESTAMP + WHERE room_id = ? AND user_id = ?''', (change, room_id, user_id)) + + # Get new points + c.execute('''SELECT points FROM karma WHERE room_id = ? AND user_id = ?''', (room_id, user_id)) + row = c.fetchone() + new_points = row['points'] if row else 0 + + # Record history + c.execute('''INSERT INTO karma_history (room_id, user_id, change, new_points, voter) + VALUES (?, ?, ?, ?, ?)''', (room_id, user_id, change, new_points, voter)) + + conn.commit() + conn.close() + + logging.debug(f"Karma updated: {user_id} now has {new_points} points in room {room_id}") + return new_points + + +def get_karma(room_id, user_id): + """Get karma points for a user in a specific room.""" + conn = get_connection() + c = conn.cursor() + + c.execute('''SELECT points FROM karma WHERE room_id = ? AND user_id = ?''', (room_id, user_id)) + row = c.fetchone() + conn.close() + + return row['points'] if row else 0 + + +def get_leaderboard(room_id, bot, limit=10, reverse=False): + """Get top or bottom karma leaderboard for a room.""" + conn = get_connection() + c = conn.cursor() + + order = "DESC" if not reverse else "ASC" + c.execute(f'''SELECT user_id, points FROM karma + WHERE room_id = ? AND points != 0 + ORDER BY points {order} LIMIT ?''', (room_id, limit)) + rows = c.fetchall() + conn.close() + + # Convert user IDs to display names + leaderboard = [] + for row in rows: + display_name = get_display_name_from_user_id(bot, room_id, row['user_id']) + leaderboard.append({ + 'display_name': display_name, + 'user_id': row['user_id'], + 'points': row['points'] + }) + + return leaderboard + + +def get_rank(room_id, user_id): + """Get rank of a user in karma leaderboard for a room.""" + conn = get_connection() + c = conn.cursor() + + # Get all points ordered descending + c.execute('''SELECT user_id, points FROM karma + WHERE room_id = ? AND points != 0 + ORDER BY points DESC''', (room_id,)) + rows = c.fetchall() + conn.close() + + for i, row in enumerate(rows, 1): + if row['user_id'] == user_id: + return i, len(rows) + return None, len(rows) + + +def get_recent_history(room_id, user_id, bot, limit=5): + """Get recent karma history for a user in a room.""" + conn = get_connection() + c = conn.cursor() + + c.execute('''SELECT change, voter, voted_at + FROM karma_history + WHERE room_id = ? AND user_id = ? + ORDER BY voted_at DESC LIMIT ?''', (room_id, user_id, limit)) + rows = c.fetchall() + conn.close() + + history = [] + for row in rows: + voter_name = get_display_name_from_user_id(bot, room_id, row['voter']) + history.append({ + 'change': row['change'], + 'voter': voter_name, + 'voter_id': row['voter'], + 'voted_at': row['voted_at'] + }) + + return history + + +def get_stats(room_id=None): + """Get overall karma statistics.""" + conn = get_connection() + c = conn.cursor() + + if room_id: + c.execute('''SELECT COUNT(*) as total_users FROM karma WHERE room_id = ?''', (room_id,)) + total_users = c.fetchone()['total_users'] + + c.execute('''SELECT SUM(points) as total_points FROM karma WHERE room_id = ?''', (room_id,)) + total_points = c.fetchone()['total_points'] or 0 + + c.execute('''SELECT AVG(points) as avg_points FROM karma WHERE room_id = ?''', (room_id,)) + avg_points = c.fetchone()['avg_points'] or 0 + + c.execute('''SELECT MAX(points) as max_points FROM karma WHERE room_id = ?''', (room_id,)) + max_points = c.fetchone()['max_points'] or 0 + + c.execute('''SELECT MIN(points) as min_points FROM karma WHERE room_id = ?''', (room_id,)) + min_points = c.fetchone()['min_points'] or 0 + + c.execute('''SELECT COUNT(*) as total_votes FROM karma_history WHERE room_id = ?''', (room_id,)) + total_votes = c.fetchone()['total_votes'] + else: + c.execute('''SELECT COUNT(*) as total_users FROM karma''') + total_users = c.fetchone()['total_users'] + + c.execute('''SELECT SUM(points) as total_points FROM karma''') + total_points = c.fetchone()['total_points'] or 0 + + c.execute('''SELECT AVG(points) as avg_points FROM karma''') + avg_points = c.fetchone()['avg_points'] or 0 + + c.execute('''SELECT MAX(points) as max_points FROM karma''') + max_points = c.fetchone()['max_points'] or 0 + + c.execute('''SELECT MIN(points) as min_points FROM karma''') + min_points = c.fetchone()['min_points'] or 0 + + c.execute('''SELECT COUNT(*) as total_votes FROM karma_history''') + total_votes = c.fetchone()['total_votes'] + + conn.close() + + return { + 'total_users': total_users, + 'total_points': total_points, + 'avg_points': round(avg_points, 2), + 'max_points': max_points, + 'min_points': min_points, + 'total_votes': total_votes + } + + +def format_karma_display(display_name, points): + """Format karma display with visual indicators.""" + if points > 0: + indicator = "👍" if points > 10 else "➕" + return f"💗 **{display_name}** has {points} karma {indicator}" + elif points < 0: + indicator = "👎" if points < -10 else "➖" + return f"💔 **{display_name}** has {points} karma {indicator}" + else: + return f"⚖️ **{display_name}** has neutral karma (0)" + + +# --------------------------------------------------------------------------- +# Command Handlers +# --------------------------------------------------------------------------- async def handle_command(room, message, bot, prefix, config): - """ - Function to handle the !karma command. - - Args: - room (Room): The Matrix room where the command was invoked. - message (RoomMessage): The message object containing the command. - - Returns: - None - """ + """Handle karma commands.""" match = botlib.MessageMatch(room, message, bot, prefix) - if match.is_not_from_this_bot() and match.prefix() and match.command("karma"): - logging.info("Received !karma command") - args = match.args() - sender = str(message.sender) + room_id = room.room_id - if len(args) == 0: - # Query sender's own karma - conn = sqlite3.connect('karma.db') - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS karma - (username TEXT PRIMARY KEY, points INTEGER)''') - c.execute('''INSERT OR IGNORE INTO karma (username, points) VALUES (?, ?)''', (sender, 0)) - c.execute('''SELECT points FROM karma WHERE username = ?''', (sender,)) - row = c.fetchone() - if row is not None: - points = row[0] - await bot.api.send_markdown_message(room.room_id, f"💗 {sender}'s karma points: {points}") - logging.info(f"Sent {sender}'s karma points ({points}) to the room") + # Refresh display name cache + await refresh_display_name_cache(bot, room_id) + + # Debug logging + message_body = message.body if hasattr(message, 'body') else str(message) + logging.info(f"Karma plugin received message: '{message_body}' from {message.sender}") + + # Get the full command (including what might be karma++ etc.) + full_cmd = match.command() if hasattr(match, 'command') else '' + + logging.debug(f"Full command: '{full_cmd}'") + + # Check for !karma++ or !karma-- as a single command + if match.is_not_from_this_bot() and match.prefix(): + if full_cmd == 'karma++': + # !karma++ username + args = match.args() + if not args: + await bot.api.send_markdown_message(room.room_id, "Usage: !karma++ ") + return + display_name = ' '.join(args) + await process_karma_vote(room, display_name, '++', message.sender, bot) + return + + elif full_cmd == 'karma--': + # !karma-- username + args = match.args() + if not args: + await bot.api.send_markdown_message(room.room_id, "Usage: !karma-- ") + return + display_name = ' '.join(args) + await process_karma_vote(room, display_name, '--', message.sender, bot) + return + + elif full_cmd == 'karma': + # Regular !karma command + args = match.args() + if args and args[0] in ['++', '--']: + # !karma ++ username (space between) + action = args[0] + if len(args) < 2: + await bot.api.send_markdown_message(room.room_id, f"Usage: !karma {action} ") + return + display_name = ' '.join(args[1:]) + await process_karma_vote(room, display_name, action, message.sender, bot) + return else: - await bot.api.send_markdown_message(room.room_id, f"💗 {sender} does not have any karma points yet.") - logging.info(f"Sent message that {sender} does not have any karma points yet.") - conn.close() - elif len(args) == 1: - username = args[0] - - if username == sender: - await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma.") - logging.info("Sent self-modification warning message to the room") + # !karma with subcommands or username + await handle_karma_command(room, message, bot, config) return - conn = sqlite3.connect('karma.db') - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS karma - (username TEXT PRIMARY KEY, points INTEGER)''') - c.execute('''INSERT OR IGNORE INTO karma (username, points) VALUES (?, ?)''', (username, 0)) - c.execute('''SELECT points FROM karma WHERE username = ?''', (username,)) - row = c.fetchone() - if row is not None: - points = row[0] - await bot.api.send_markdown_message(room.room_id, f"💗 {username}'s karma points: {points}") - logging.info(f"Sent {username}'s karma points ({points}) to the room") - else: - await bot.api.send_markdown_message(room.room_id, f"💗 {username} does not have any karma points yet.") - logging.info(f"Sent message that {username} does not have any karma points yet.") - conn.close() - elif len(args) == 2: - username, action = args - if action not in ['up', 'down']: - await bot.api.send_markdown_message(room.room_id, "❌ Invalid action. Use 'up' or 'down'.") - logging.info("Sent invalid action message to the room") + # Handle !++ and !-- shortcuts + elif full_cmd in ['++', '--']: + args = match.args() + if not args: + await bot.api.send_markdown_message(room.room_id, f"Usage: !{full_cmd} \nExample: !{full_cmd} Nexilva") return + display_name = ' '.join(args) + await process_karma_vote(room, display_name, full_cmd, message.sender, bot) + return - if username == sender: - await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma.") - logging.info("Sent self-modification warning message to the room") - return + # Handle inline karma (message body contains ++ or --) + if match.is_not_from_this_bot() and not match.prefix(): + await handle_inline_karma(room, message, bot) - conn = sqlite3.connect('karma.db') - c = conn.cursor() - c.execute('''CREATE TABLE IF NOT EXISTS karma - (username TEXT PRIMARY KEY, points INTEGER)''') - if action == 'up': - c.execute('''INSERT OR IGNORE INTO karma (username, points) VALUES (?, ?)''', (username, 0)) - c.execute('''UPDATE karma SET points = points + 1 WHERE username = ?''', (username,)) - else: - c.execute('''INSERT OR IGNORE INTO karma (username, points) VALUES (?, ?)''', (username, 0)) - c.execute('''UPDATE karma SET points = points - 1 WHERE username = ?''', (username,)) +async def process_karma_vote(room, display_name, action, voter, bot): + """Process a karma vote using display name.""" + room_id = room.room_id + voter_str = str(voter) + change = 1 if action == '++' else -1 - conn.commit() - c.execute('''SELECT points FROM karma WHERE username = ?''', (username,)) - row = c.fetchone() - if row is not None: - points = row[0] - await bot.api.send_markdown_message(room.room_id, f"💗 {username}'s karma points: {points}") - logging.info(f"Sent {username}'s karma points ({points}) to the room") - else: - await bot.api.send_markdown_message(room.room_id, f"💗 {username} does not have any karma points yet.") - logging.info(f"Sent message that {username} does not have any karma points yet.") - conn.close() + logging.info(f"Karma vote: {voter_str} -> '{display_name}' ({action}, change={change:+d}) in room {room_id}") + + # Resolve display name to user ID + user_id = resolve_display_name(room_id, display_name, bot) + + if not user_id: + await bot.api.send_markdown_message(room.room_id, f"❌ Could not find user '{display_name}'. Please use their display name or Matrix ID (e.g., @username:server)") + return + + # Prevent self-modification + if user_id == voter_str: + await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma!") + return + + # Check cooldown + if is_on_cooldown(room_id, user_id, voter_str): + remaining = get_cooldown_remaining(room_id, user_id, voter_str) + await bot.api.send_markdown_message(room.room_id, f"⏳ You're doing that too fast! Wait {remaining} seconds.") + return + + # Update karma + try: + new_points = update_karma(room_id, user_id, change, voter_str) + update_cooldown(room_id, user_id, voter_str) + + # Get display name for response + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + response = format_karma_display(display_name_resolved, new_points) + await bot.api.send_markdown_message(room.room_id, response) + logging.info(f"Karma updated successfully: {user_id} now has {new_points} points") + except Exception as e: + logging.error(f"Error updating karma: {e}") + logging.error(traceback.format_exc()) + await bot.api.send_markdown_message(room.room_id, f"❌ Error updating karma: {str(e)}") + + +async def handle_karma_command(room, message, bot, config): + """Handle the !karma command and its subcommands.""" + match = botlib.MessageMatch(room, message, bot, config.prefix) + args = match.args() + room_id = room.room_id + + if not args: + # Show help for !karma command in collapsible details tag with proper HTML lists + help_text = f"""
    +💗 Karma Plugin Help (click to expand) + +Commands: +
      +
    • !karma - Show this help
    • +
    • !karma <user> - Show karma for a user (use display name or @user:server)
    • +
    • !karma++ <user> - Give +1 karma to a user
    • +
    • !karma-- <user> - Give -1 karma to a user
    • +
    • !karma top [n] - Show top karma leaders
    • +
    • !karma bottom [n] - Show bottom karma entries
    • +
    • !karma rank <user> - Show rank of a user
    • +
    • !karma stats - Show overall statistics
    • +
    • !karma history <user> - Show recent karma history
    • +
    + +Shortcuts: +
      +
    • !++ <user> - Same as !karma++ <user>
    • +
    • !-- <user> - Same as !karma-- <user>
    • +
    • <user>++ - Give +1 karma (inline)
    • +
    • <user>-- - Give -1 karma (inline)
    • +
    + +Examples: +
      +
    • !karma Nexilva - Check Nexilva's karma
    • +
    • !karma++ @hb:matrix.org - Give HB +1 karma
    • +
    • !karma top 5 - Show top 5 leaders
    • +
    • Nexilva++ - Give Nexilva +1 karma (inline)
    • +
    + +Notes: +
      +
    • You cannot modify your own karma
    • +
    • There is a {COOLDOWN_SECONDS} second cooldown between votes
    • +
    • Karma is tracked separately per room
    • +
    • Display names with emojis are supported
    • +
    + +
    """ + + await bot.api.send_markdown_message(room.room_id, help_text) + return + + subcommand = args[0].lower() + + # !karma top [n] + if subcommand == "top": + limit = 10 + if len(args) > 1 and args[1].isdigit(): + limit = min(int(args[1]), 25) + + leaderboard = get_leaderboard(room_id, bot, limit, reverse=False) + if leaderboard: + response = f"🏆 **Top {len(leaderboard)} Karma Leaders**\n\n" + for i, entry in enumerate(leaderboard, 1): + medal = "🥇" if i == 1 else "🥈" if i == 2 else "🥉" if i == 3 else "📌" + response += f"{medal} **{i}.** {entry['display_name']}: {entry['points']} points\n" + await bot.api.send_markdown_message(room.room_id, response) else: - await bot.api.send_markdown_message(room.room_id, "☯ Usage: !karma [username] [up/down]") - logging.info("Sent usage message to the room") + await bot.api.send_markdown_message(room.room_id, "No karma entries found in this room.") + return + + # !karma bottom [n] + if subcommand == "bottom": + limit = 10 + if len(args) > 1 and args[1].isdigit(): + limit = min(int(args[1]), 25) + + leaderboard = get_leaderboard(room_id, bot, limit, reverse=True) + if leaderboard: + response = f"📉 **Bottom {len(leaderboard)} Karma (Needs Love)**\n\n" + for i, entry in enumerate(leaderboard, 1): + response += f"⚠️ **{i}.** {entry['display_name']}: {entry['points']} points\n" + await bot.api.send_markdown_message(room.room_id, response) + else: + await bot.api.send_markdown_message(room.room_id, "No karma entries found in this room.") + return + + # !karma rank + if subcommand == "rank" and len(args) >= 2: + display_name = ' '.join(args[1:]) + user_id = resolve_display_name(room_id, display_name, bot) + + if not user_id: + await bot.api.send_markdown_message(room.room_id, f"❌ Could not find user '{display_name}'") + return + + rank, total = get_rank(room_id, user_id) + if rank: + points = get_karma(room_id, user_id) + percentile = round((1 - rank/total) * 100, 1) if total > 0 else 0 + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + response = f"📊 **{display_name_resolved}** is ranked #{rank} out of {total} (top {percentile}%)\n💗 Karma: {points} points" + await bot.api.send_markdown_message(room.room_id, response) + else: + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + await bot.api.send_markdown_message(room.room_id, f"❌ {display_name_resolved} has no karma yet in this room.") + return + + # !karma history + if subcommand == "history" and len(args) >= 2: + display_name = ' '.join(args[1:]) + user_id = resolve_display_name(room_id, display_name, bot) + + if not user_id: + await bot.api.send_markdown_message(room.room_id, f"❌ Could not find user '{display_name}'") + return + + history = get_recent_history(room_id, user_id, bot, 5) + + if history: + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + response = f"📜 **Recent Karma History for {display_name_resolved}**\n\n" + for h in history: + arrow = "⬆️" if h['change'] > 0 else "⬇️" + try: + voted_at = datetime.fromisoformat(h['voted_at']) + time_str = voted_at.strftime("%Y-%m-%d %H:%M") + except: + time_str = "recently" + response += f"{arrow} {h['change']:+d} by **{h['voter']}** at {time_str}\n" + await bot.api.send_markdown_message(room.room_id, response) + else: + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + await bot.api.send_markdown_message(room.room_id, f"No karma history for {display_name_resolved} in this room.") + return + + # !karma stats + if subcommand == "stats": + stats = get_stats(room_id) + response = f"📊 **Karma System Statistics for this Room**\n\n" + response += f"📝 Total users tracked: {stats['total_users']}\n" + response += f"⭐ Total karma points: {stats['total_points']}\n" + response += f"📈 Average karma: {stats['avg_points']}\n" + response += f"🏆 Highest karma: {stats['max_points']}\n" + response += f"📉 Lowest karma: {stats['min_points']}\n" + response += f"🗳️ Total votes cast: {stats['total_votes']}\n" + await bot.api.send_markdown_message(room.room_id, response) + return + + # !karma (show karma for specific user) + display_name = ' '.join(args) + user_id = resolve_display_name(room_id, display_name, bot) + + if not user_id: + await bot.api.send_markdown_message(room.room_id, f"❌ Could not find user '{display_name}'") + return + + points = get_karma(room_id, user_id) + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + response = format_karma_display(display_name_resolved, points) + await bot.api.send_markdown_message(room.room_id, response) + return + + +async def handle_inline_karma(room, message, bot): + """Handle inline karma expressions like 'user++' or 'user--'.""" + body = message.body if hasattr(message, 'body') else str(message) + sender = str(message.sender) + room_id = room.room_id + + # Refresh display name cache + await refresh_display_name_cache(bot, room_id) + + # Pattern to match text followed by ++ or -- at end of word/string + pattern = r'(.+?)(\+\+|--)(?:\s|$)' + matches = re.findall(pattern, body) + + if not matches: + return + + logging.info(f"Found inline karma matches: {matches}") + + responses = [] + for display_name, operator in matches: + display_name = display_name.strip() + change = 1 if operator == '++' else -1 + + # Resolve display name to user ID + user_id = resolve_display_name(room_id, display_name, bot) + + if not user_id: + logging.debug(f"Could not resolve display name: '{display_name}'") + continue + + # Skip self-modification + if user_id == sender: + logging.debug(f"Skipping self-modification: {sender} -> {display_name}") + continue + + # Check cooldown + if is_on_cooldown(room_id, user_id, sender): + logging.debug(f"Cooldown active for {sender} -> {user_id}") + continue + + # Update karma + try: + new_points = update_karma(room_id, user_id, change, sender) + update_cooldown(room_id, user_id, sender) + + # Format response + display_name_resolved = get_display_name_from_user_id(bot, room_id, user_id) + arrow = "⬆️" if change > 0 else "⬇️" + responses.append(f"{arrow} **{display_name_resolved}** → {new_points}") + except Exception as e: + logging.error(f"Error updating inline karma: {e}") + + await asyncio.sleep(0.3) + + if responses: + response_text = " | ".join(responses[:3]) + if len(responses) > 3: + response_text += f" (and {len(responses)-3} more)" + await bot.api.send_markdown_message(room.room_id, f"💗 {response_text}") + + +# --------------------------------------------------------------------------- +# Plugin Setup +# --------------------------------------------------------------------------- + +def setup(bot): + """Initialize the karma plugin.""" + logging.info("=" * 50) + logging.info("LOADING KARMA PLUGIN") + logging.info("=" * 50) + + init_db() + + logging.info("Advanced Karma plugin loaded successfully") + logging.info("Supports display names (e.g., 'Nexilva' or '🍄 HB🍄') and Matrix IDs") + logging.info("Commands: !karma, !karma++, !karma--, !++, !--, and inline ++/--") + logging.info("=" * 50)