karma plugin added. various debug fixes to funguy.py

This commit is contained in:
2026-05-06 23:14:35 -05:00
parent c72ea72bae
commit abb4b5e245
5 changed files with 1114 additions and 253 deletions
+47
View File
@@ -733,6 +733,53 @@ Fetches current time information for locations using the TimeAPI.io service.
!time New York !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 <user>` - Show karma for a user
- `!karma++ <user>` - Give +1 karma to a user
- `!karma-- <user>` - Give -1 karma to a user
- `!karma top [n]` - Show top karma entries
- `!karma bottom [n]` - Show bottom karma entries
- `!karma rank <user>` - Show rank of 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:**
```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 - Ensure all environment variables are set correctly
- Check that required services are running (Stable Diffusion API, Ollama, etc.) - Check that required services are running (Stable Diffusion API, Ollama, etc.)
- Verify plugin permissions and whitelist settings - Verify plugin permissions and whitelist settings
+1 -1
View File
@@ -14,4 +14,4 @@ config_file = "funguy.conf"
[plugins.disabled] [plugins.disabled]
"!uFhErnfpYhhlauJsNK:matrix.org" = [ "youtube-preview", "ai", "proxy",] "!uFhErnfpYhhlauJsNK:matrix.org" = [ "youtube-preview", "ai", "proxy",]
"!vYcfWXpPvxeQvhlFdV:matrix.org" = [] "!vYcfWXpPvxeQvhlFdV:matrix.org" = []
"!NXdVjDXPxXowPkrJJY:matrix.org" = [ "karma"] "!NXdVjDXPxXowPkrJJY:matrix.org" = []
+195 -146
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Funguy Bot Class - A modular Matrix bot with plugin support Funguy Bot Class
""" """
# Importing necessary libraries and modules # 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 time # Time-related functions
import sys # System-specific parameters and functions import sys # System-specific parameters and functions
import toml # Library for parsing TOML configuration files import toml # Library for parsing TOML configuration files
import socket # For network diagnostics
# Importing FunguyConfig class from plugins.config module # Importing FunguyConfig class from plugins.config module
from plugins.config import FunguyConfig from plugins.config import FunguyConfig
class FunguyBot: class FunguyBot:
""" """
A bot class for managing plugins and handling commands in a Matrix chat environment. A bot class for managing plugins and handling commands in a Matrix chat environment.
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): def __init__(self):
""" """
Constructor method for FunguyBot class. Constructor method for FunguyBot class.
""" """
print("[INIT] Starting FunguyBot initialization...")
# Setting up instance variables # Setting up instance variables
self.PLUGINS_DIR = "plugins" # Directory where plugins are stored self.PLUGINS_DIR = "plugins" # Directory where plugins are stored
self.PLUGINS = {} # Dictionary to store loaded plugins self.PLUGINS = {} # Dictionary to store loaded plugins
self.config = None # Configuration object self.config = None # Configuration object
self.bot = None # Bot object self.bot = None # Bot object
self.disabled_plugins = {} # Dictionary to store disabled plugins for each room 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 self.load_dotenv() # Loading environment variables from .env file
print("[INIT] Setting up logging...")
self.setup_logging() # Setting up logging configurations self.setup_logging() # Setting up logging configurations
print("[INIT] Loading plugins...")
self.load_plugins() # Loading plugins self.load_plugins() # Loading plugins
print("[INIT] Loading config...")
self.load_config() # Loading bot configuration self.load_config() # Loading bot configuration
print("[INIT] Loading disabled plugins...")
self.load_disabled_plugins() # Loading disabled plugins from configuration file self.load_disabled_plugins() # Loading disabled plugins from configuration file
print("[INIT] FunguyBot initialization complete!")
def load_dotenv(self): def load_dotenv(self):
""" """
Method to load environment variables from a .env file. Method to load environment variables from a .env file.
""" """
load_dotenv() load_dotenv()
print("[ENV] Environment variables loaded")
def setup_logging(self): def setup_logging(self):
""" """
Method to configure logging settings. 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() 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( logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 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 # Optionally silence noisy libraries
logging.getLogger().setLevel(getattr(logging, log_level, logging.INFO)) logging.getLogger("aiohttp").setLevel(logging.WARNING)
logging.getLogger("simplematrixbotlib").setLevel(logging.WARNING)
logging.getLogger("nio").setLevel(logging.WARNING) logging.getLogger("nio").setLevel(logging.WARNING)
logging.info(f"Logging configured with level: {log_level}") logging.info(f"Logging configured with level: {log_level}")
@@ -92,20 +94,16 @@ class FunguyBot:
Method to load plugins from the specified directory. Method to load plugins from the specified directory.
""" """
# Iterating through files in the plugins directory # Iterating through files in the plugins directory
plugin_count = 0
for plugin_file in os.listdir(self.PLUGINS_DIR): 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 plugin_name = os.path.splitext(plugin_file)[0] # Extracting plugin name
try: try:
# Importing plugin module dynamically # Importing plugin module dynamically
module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}") module = importlib.import_module(f"{self.PLUGINS_DIR}.{plugin_name}")
self.PLUGINS[plugin_name] = module # Storing loaded plugin module self.PLUGINS[plugin_name] = module # Storing loaded plugin module
logging.info(f"Loaded plugin: {plugin_name}") logging.info(f"Loaded plugin: {plugin_name}") # Logging successful plugin loading
plugin_count += 1
except Exception as e: except Exception as e:
logging.error(f"Error loading plugin {plugin_name}: {e}", exc_info=True) logging.error(f"Error loading plugin {plugin_name}: {e}") # Logging error if plugin loading fails
logging.info(f"Total plugins loaded: {plugin_count}")
def setup_plugins(self): def setup_plugins(self):
""" """
@@ -115,45 +113,28 @@ class FunguyBot:
that plugins which register custom event listeners (e.g. on_custom_event that plugins which register custom event listeners (e.g. on_custom_event
for RoomMemberEvent) receive a valid bot instance. for RoomMemberEvent) receive a valid bot instance.
""" """
setup_count = 0
for plugin_name, plugin_module in self.PLUGINS.items(): for plugin_name, plugin_module in self.PLUGINS.items():
if hasattr(plugin_module, "setup") and callable(plugin_module.setup): if hasattr(plugin_module, "setup") and callable(plugin_module.setup):
try: try:
plugin_module.setup(self.bot) plugin_module.setup(self.bot)
logging.info(f"Setup called for plugin: {plugin_name}") logging.info(f"Setup called for plugin: {plugin_name}")
setup_count += 1
except Exception as e: except Exception as e:
logging.error(f"Error during setup of plugin {plugin_name}: {e}", exc_info=True) logging.error(f"Error during setup of plugin {plugin_name}: {e}")
logging.info(f"Setup completed for {setup_count} plugins")
def reload_plugins(self): def reload_plugins(self):
""" """
Method to reload all plugins. Method to reload all plugins.
""" """
logging.info("Reloading plugins...") self.PLUGINS = {} # Clearing loaded plugins dictionary
# Clear loaded plugins dictionary
self.PLUGINS.clear()
# Unloading modules from sys.modules # Unloading modules from sys.modules
modules_to_remove = [] for plugin_name in list(sys.modules.keys()):
for module_name in sys.modules.keys(): if plugin_name.startswith(self.PLUGINS_DIR + "."):
if module_name.startswith(self.PLUGINS_DIR + "."): del sys.modules[plugin_name] # Deleting plugin module from system modules
modules_to_remove.append(module_name) self.load_plugins() # Reloading plugins
for module_name in modules_to_remove:
del sys.modules[module_name]
# Reload plugins
self.load_plugins()
# Re-run setup for any plugin that needs it (bot already exists at this point) # Re-run setup for any plugin that needs it (bot already exists at this point)
if self.bot is not None: if self.bot is not None:
self.setup_plugins() self.setup_plugins()
logging.info("Plugins reloaded successfully")
def load_config(self): def load_config(self):
""" """
Method to load configuration settings. Method to load configuration settings.
@@ -167,18 +148,11 @@ class FunguyBot:
""" """
# Checking if configuration file exists # Checking if configuration file exists
if os.path.exists('funguy.conf'): if os.path.exists('funguy.conf'):
try:
# Loading configuration data from TOML file # Loading configuration data from TOML file
with open('funguy.conf', 'r') as f: with open('funguy.conf', 'r') as f:
config_data = toml.load(f) config_data = toml.load(f)
# Extracting disabled plugins from configuration data # Extracting disabled plugins from configuration data
self.disabled_plugins = config_data.get('plugins', {}).get('disabled', {}) 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")
def save_disabled_plugins(self): def save_disabled_plugins(self):
""" """
@@ -187,25 +161,14 @@ class FunguyBot:
existing_config = {} existing_config = {}
# Checking if configuration file exists # Checking if configuration file exists
if os.path.exists('funguy.conf'): if os.path.exists('funguy.conf'):
try:
# Loading existing configuration data # Loading existing configuration data
with open('funguy.conf', 'r') as f: with open('funguy.conf', 'r') as f:
existing_config = toml.load(f) existing_config = toml.load(f)
except Exception as e:
logging.error(f"Error reading funguy.conf: {e}")
# Updating configuration data with disabled plugins # Updating configuration data with disabled plugins
if 'plugins' not in existing_config: existing_config['plugins'] = {'disabled': self.disabled_plugins}
existing_config['plugins'] = {}
existing_config['plugins']['disabled'] = self.disabled_plugins
# Writing updated configuration data back to file # Writing updated configuration data back to file
try:
with open('funguy.conf', 'w') as f: with open('funguy.conf', 'w') as f:
toml.dump(existing_config, 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}")
async def handle_commands(self, room, message): 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 match.is_not_from_this_bot() and match.prefix() and match.command("reload"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
self.reload_plugins() # Reloading plugins 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: 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 return
# Disable plugin command # Disable plugin command
@@ -227,13 +190,13 @@ class FunguyBot:
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
args = match.args() # Getting command arguments args = match.args() # Getting command arguments
if len(args) != 2: # Checking if correct number of arguments provided if len(args) != 2: # Checking if correct number of arguments provided
await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") await self.bot.api.send_text_message(room.room_id, "Usage: !disable <plugin> <room_id>") # Sending usage message
else: else:
plugin_name, room_id = args # Extracting plugin name and room ID plugin_name, room_id = args # Extracting plugin name and room ID
await self.disable_plugin(room_id, plugin_name) # Disabling plugin 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: 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 return
# Enable plugin command # Enable plugin command
@@ -241,85 +204,88 @@ class FunguyBot:
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
args = match.args() # Getting command arguments args = match.args() # Getting command arguments
if len(args) != 2: # Checking if correct number of arguments provided if len(args) != 2: # Checking if correct number of arguments provided
await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") await self.bot.api.send_text_message(room.room_id, "Usage: !enable <plugin> <room_id>") # Sending usage message
else: else:
plugin_name, room_id = args # Extracting plugin name and room ID plugin_name, room_id = args # Extracting plugin name and room ID
await self.enable_plugin(room_id, plugin_name) # Enabling plugin 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: 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 <strong> tag
plugin_list.append(f"<strong>[{plugin_name}.py]</strong>: {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 return
# Rehash config command # Rehash config command
if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"): if match.is_not_from_this_bot() and match.prefix() and match.command("rehash"):
if str(message.sender) == self.config.admin_user: # Checking if sender is admin user if str(message.sender) == self.config.admin_user: # Checking if sender is admin user
self.rehash_config() # Rehashing configuration 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: 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 return
# List plugins command # Dispatching commands to plugins
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)
for plugin_name, plugin_module in self.PLUGINS.items(): for plugin_name, plugin_module in self.PLUGINS.items():
# Check if plugin is disabled for this room if plugin_name not in self.disabled_plugins.get(room.room_id, []):
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):
try: try:
await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config) await plugin_module.handle_command(room, message, self.bot, self.config.prefix, self.config)
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)
# Don't break the loop, continue with other plugins
def rehash_config(self): def rehash_config(self):
""" """
Method to rehash the configuration settings. 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 del self.config # Deleting current configuration object
self.config = FunguyConfig() # Creating new instance of FunguyConfig to load updated configuration 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}")
async def disable_plugin(self, room_id, plugin_name): async def disable_plugin(self, room_id, plugin_name):
""" """
Method to disable a plugin for a specific room. 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: if room_id not in self.disabled_plugins:
self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist self.disabled_plugins[room_id] = [] # Creating entry for room ID if not exist
if plugin_name not in self.disabled_plugins[room_id]: if plugin_name not in self.disabled_plugins[room_id]:
self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room self.disabled_plugins[room_id].append(plugin_name) # Adding plugin to list of disabled plugins for the room
self.save_disabled_plugins() # Saving disabled plugins to configuration file 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): 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]: if room_id in self.disabled_plugins and plugin_name in self.disabled_plugins[room_id]:
self.disabled_plugins[room_id].remove(plugin_name) # Removing plugin from list of disabled plugins for the room self.disabled_plugins[room_id].remove(plugin_name) # 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 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): def run(self):
""" """
Method to initialize and run the bot. Method to initialize and run the bot.
""" """
try: print("\n" + "="*60)
print("FUNGUY BOT - STARTING")
print("="*60 + "\n")
# Retrieving Matrix credentials from environment variables # Retrieving Matrix credentials from environment variables
MATRIX_URL = os.getenv("MATRIX_URL") MATRIX_URL = os.getenv("MATRIX_URL")
MATRIX_USER = os.getenv("MATRIX_USER") MATRIX_USER = os.getenv("MATRIX_USER")
MATRIX_PASS = os.getenv("MATRIX_PASS") MATRIX_PASS = os.getenv("MATRIX_PASS")
# Validate credentials # Validate credentials
if not all([MATRIX_URL, MATRIX_USER, MATRIX_PASS]): if not MATRIX_URL:
logging.error("Missing Matrix credentials in .env file. Please check MATRIX_URL, MATRIX_USER, MATRIX_PASS") logging.error("MATRIX_URL not set in .env file")
return
if not MATRIX_USER:
logging.error("MATRIX_USER not set in .env file")
return
if not MATRIX_PASS:
logging.error("MATRIX_PASS not set in .env file")
return return
logging.info(f"Matrix URL: {MATRIX_URL}")
logging.info(f"Matrix 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:
logging.info(f"Creating credentials object for {MATRIX_USER}...")
creds = botlib.Creds(MATRIX_URL, MATRIX_USER, MATRIX_PASS) # Creating credentials object 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 self.bot = botlib.Bot(creds, self.config) # Creating bot instance
logging.info("✓ Bot instance created")
logging.info(f"Bot starting with user: {MATRIX_USER}") # Check if async_client is available
logging.info(f"Connected to homeserver: {MATRIX_URL}") 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. # 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() self.setup_plugins()
logging.info("✓ Plugin setup complete")
# Defining listener for message events # Defining listener for message events
@self.bot.listener.on_message_event @self.bot.listener.on_message_event
async def wrapper_handle_commands(room, message): async def wrapper_handle_commands(room, message):
await self.handle_commands(room, message) # Calling handle_commands method for incoming messages await self.handle_commands(room, message) # 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 self.bot.run() # Running the bot
except Exception as e: except Exception as e:
logging.error(f"Fatal error running bot: {e}", exc_info=True) logging.error(f"Fatal error during bot startup: {e}", exc_info=True)
sys.exit(1) 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__": if __name__ == "__main__":
print("\n" + "="*60)
print("FUNGUY BOT LAUNCHER")
print("="*60)
try: try:
print("Creating bot instance...")
bot = FunguyBot() # Creating instance of FunguyBot bot = FunguyBot() # Creating instance of FunguyBot
print("Bot instance created. Running bot...")
bot.run() # Running the bot bot.run() # Running the bot
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Bot stopped by user") print("\n[!] Bot stopped by user")
sys.exit(0) sys.exit(0)
except Exception as e: except Exception as e:
print(f"\n[!] Fatal error: {e}")
logging.error(f"Unhandled exception: {e}", exc_info=True) logging.error(f"Unhandled exception: {e}", exc_info=True)
sys.exit(1) sys.exit(1)
+68
View File
@@ -508,6 +508,30 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
<li><code>!arxiv <query></code> - Search for papers (shows abstracts)</li> <li><code>!arxiv <query></code> - Search for papers (shows abstracts)</li>
<li><code>!arxiv list <query></code> - List papers without abstracts</li> <li><code>!arxiv list <query></code> - List papers without abstracts</li>
<li><code>!arxiv category <category></code> - Browse recent papers by category</li> <li><code>!arxiv category <category></code> - Browse recent papers by category</li>
<li><code>!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"
}
}
</style>
<div class="codeblock" style="white-space: pre-line; padding: 10px; border: 1px solid #c8c8c8; border-radius: 3px; background: #f8f8f8; color: #3b3a30; font-family: monospace; font-size: 12px;">
</style>
<div class="codeblock">
<summary>📚 <strong>!arxiv [query]</strong></summary>
<p>Search academic papers on arXiv.org. Categories include AI, ML, Security, Physics, Math, and more. No API key required.</p>
<p><strong>Commands:</strong></p>
<ul>
<li><code>!arxiv <query></code> - Search for papers (shows abstracts)</li>
<li><code>!arxiv list <query></code> - List papers without abstracts</li>
<li><code>!arxiv category <category></code> - Browse recent papers by category</li>
<li><code>!arxiv recent <category></code> - Most recent papers in category</li> <li><code>!arxiv recent <category></code> - Most recent papers in category</li>
<li><code>!arxiv random</code> - Get a random paper</li> <li><code>!arxiv random</code> - Get a random paper</li>
<li><code>!arxiv <id></code> - Get paper by arXiv ID (e.g., 2101.00101)</li> <li><code>!arxiv <id></code> - Get paper by arXiv ID (e.g., 2101.00101)</li>
@@ -548,6 +572,50 @@ Search Exploit-DB for security vulnerabilities and exploits. Returns detailed in
</ul> </ul>
</details> </details>
<details><summary>☯ <strong>!karma [user]</strong></summary>
<p>Track karma points for users with leaderboards and statistics. Supports display names and Matrix IDs.</p>
<p><strong>Commands:</strong></p>
<ul>
<li><code>!karma <user></code> - Show karma for a user</li>
<li><code>!karma++ <user></code> - Give +1 karma to a user</li>
<li><code>!karma-- <user></code> - Give -1 karma to a user</li>
<li><code>!karma top [n]</code> - Show top karma entries</li>
<li><code>!karma bottom [n]</code> - Show bottom karma entries</li>
<li><code>!karma rank <user></code> - Show rank of user</li>
<li><code>!karma stats</code> - Show overall statistics</li>
<li><code>!karma history <user></code> - Show recent karma history</li>
<li><code>!++ <user></code> - Shortcut for !karma++</li>
<li><code>!-- <user></code> - Shortcut for !karma--</li>
<li><code><user>++</code> - Inline karma (message contains ++)</li>
<li><code><user>--</code> - Inline karma (message contains --)</li>
</ul>
<p><strong>Features:</strong></p>
<ul>
<li>Supports display names and Matrix IDs</li>
<li>Room-specific karma tracking</li>
<li>Rate limiting to prevent spam</li>
<li>Karma history tracking</li>
<li>Leaderboards and statistics</li>
</ul>
</details>
<details><summary>🔥 <strong>!hn [command]</strong></summary>
<p>Fetch top stories from Hacker News using Firebase API. No API key required.</p>
<p><strong>Commands:</strong></p>
<ul>
<li><code>!hn</code> - Show top 5 stories (default)</li>
<li><code>!hn top</code> - Top stories</li>
<li><code>!hn new</code> - Newest stories</li>
<li><code>!hn best</code> - Best stories</li>
<li><code>!hn ask</code> - Ask HN threads</li>
<li><code>!hn show</code> - Show HN posts</li>
<li><code>!hn job</code> - Job postings</li>
<li><code>!hn story <id></code> - Get details of a specific story</li>
<li><code>!hn comments <id></code> - Show comments for a story</li>
<li><code>!hn search <query></code> - Search stories (via Algolia)</li>
</ul>
</details>
<details><summary>⏱️ <strong>!cron [add|remove] [room_id] [cron_entry] [command]</strong></summary> <details><summary>⏱️ <strong>!cron [add|remove] [room_id] [cron_entry] [command]</strong></summary>
<p>Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.</p> <p>Schedule automated commands using cron syntax. Add or remove cron jobs for specific rooms and commands.</p>
</details> </details>
+785 -88
View File
@@ -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 <user> - Show karma for a user
!karma++ <user> - Give +1 karma
!karma-- <user> - Give -1 karma
!karma top [n] - Show top karma entries
!karma bottom [n] - Show bottom karma entries
!karma rank <user> - Show rank of 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)
""" """
# plugins/karma.py
import sqlite3 import sqlite3
import logging import logging
import simplematrixbotlib as botlib import simplematrixbotlib as botlib
from datetime import datetime, timedelta
import re
import asyncio
import traceback
async def handle_command(room, message, bot, prefix, config): # ---------------------------------------------------------------------------
""" # Configuration
Function to handle the !karma command. # ---------------------------------------------------------------------------
Args: # Prevent spam: minimum seconds between karma changes to same target by same user
room (Room): The Matrix room where the command was invoked. COOLDOWN_SECONDS = 5
message (RoomMessage): The message object containing the command.
Returns: # Database file
None DB_FILE = "karma.db"
"""
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)
if len(args) == 0: # Cache for display name to user ID mappings (per room)
# Query sender's own karma # Structure: {room_id: {display_name: user_id}}
conn = sqlite3.connect('karma.db') 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() 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")
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: # Create tables (store user_id, not display name)
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma.") c.execute('''CREATE TABLE IF NOT EXISTS karma (
logging.info("Sent self-modification warning message to the room") room_id TEXT,
return 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)
)''')
conn = sqlite3.connect('karma.db') c.execute('''CREATE TABLE IF NOT EXISTS karma_history (
c = conn.cursor() id INTEGER PRIMARY KEY AUTOINCREMENT,
c.execute('''CREATE TABLE IF NOT EXISTS karma room_id TEXT,
(username TEXT PRIMARY KEY, points INTEGER)''') user_id TEXT,
c.execute('''INSERT OR IGNORE INTO karma (username, points) VALUES (?, ?)''', (username, 0)) change INTEGER,
c.execute('''SELECT points FROM karma WHERE username = ?''', (username,)) new_points INTEGER,
row = c.fetchone() voter TEXT,
if row is not None: voted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
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")
return
if username == sender: c.execute('''CREATE TABLE IF NOT EXISTS karma_cooldown (
await bot.api.send_markdown_message(room.room_id, "❌ You cannot modify your own karma.") room_id TEXT,
logging.info("Sent self-modification warning message to the room") user_id TEXT,
return voter TEXT,
last_voted TIMESTAMP,
conn = sqlite3.connect('karma.db') PRIMARY KEY (room_id, user_id, voter)
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,))
conn.commit() 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() 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: else:
await bot.api.send_markdown_message(room.room_id, "☯ Usage: !karma [username] [up/down]") c.execute('''SELECT COUNT(*) as total_users FROM karma''')
logging.info("Sent usage message to the room") 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):
"""Handle karma commands."""
match = botlib.MessageMatch(room, message, bot, prefix)
room_id = room.room_id
# 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++ <username>")
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-- <username>")
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} <username>")
return
display_name = ' '.join(args[1:])
await process_karma_vote(room, display_name, action, message.sender, bot)
return
else:
# !karma with subcommands or username
await handle_karma_command(room, message, bot, config)
return
# 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} <username>\nExample: !{full_cmd} Nexilva")
return
display_name = ' '.join(args)
await process_karma_vote(room, display_name, full_cmd, message.sender, bot)
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)
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
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"""<details>
<summary>💗 <strong>Karma Plugin Help</strong> (click to expand)</summary>
<strong>Commands:</strong>
<ul>
<li><code>!karma</code> - Show this help</li>
<li><code>!karma &lt;user&gt;</code> - Show karma for a user (use display name or @user:server)</li>
<li><code>!karma++ &lt;user&gt;</code> - Give +1 karma to a user</li>
<li><code>!karma-- &lt;user&gt;</code> - Give -1 karma to a user</li>
<li><code>!karma top [n]</code> - Show top karma leaders</li>
<li><code>!karma bottom [n]</code> - Show bottom karma entries</li>
<li><code>!karma rank &lt;user&gt;</code> - Show rank of a user</li>
<li><code>!karma stats</code> - Show overall statistics</li>
<li><code>!karma history &lt;user&gt;</code> - Show recent karma history</li>
</ul>
<strong>Shortcuts:</strong>
<ul>
<li><code>!++ &lt;user&gt;</code> - Same as !karma++ &lt;user&gt;</li>
<li><code>!-- &lt;user&gt;</code> - Same as !karma-- &lt;user&gt;</li>
<li><code>&lt;user&gt;++</code> - Give +1 karma (inline)</li>
<li><code>&lt;user&gt;--</code> - Give -1 karma (inline)</li>
</ul>
<strong>Examples:</strong>
<ul>
<li><code>!karma Nexilva</code> - Check Nexilva's karma</li>
<li><code>!karma++ @hb:matrix.org</code> - Give HB +1 karma</li>
<li><code>!karma top 5</code> - Show top 5 leaders</li>
<li><code>Nexilva++</code> - Give Nexilva +1 karma (inline)</li>
</ul>
<strong>Notes:</strong>
<ul>
<li>You cannot modify your own karma</li>
<li>There is a {COOLDOWN_SECONDS} second cooldown between votes</li>
<li>Karma is tracked separately per room</li>
<li>Display names with emojis are supported</li>
</ul>
</details>"""
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, "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 <user>
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 <user>
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 <user> (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)