diff --git a/config.ini.example b/config.ini.example index 4fbee27..4e9ac5b 100644 --- a/config.ini.example +++ b/config.ini.example @@ -249,6 +249,71 @@ colored_output = true # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL meshcore_log_level = INFO +[Greeter_Command] +# Enable or disable the greeter command +# true: Bot will greet users on their first public channel message +# false: Greeter is disabled +enabled = false + +# Channels where greetings should occur (comma-separated) +# If not specified, uses the channels from [Channels] monitor_channels setting +# Example: general,welcome,newbies +# Leave empty to use monitor_channels from [Channels] section +channels = + +# Greeting message template (default for all channels) +# Available fields: {sender} - the user's name/ID +# For multi-part greetings, separate messages with pipe (|) +# Example (single): "Welcome to the mesh, @[{sender}]!" +# Example (multi-part): "Welcome to the mesh, @[{sender}]!|This is a great place to chat.|Use !help for commands." +greeting_message = Welcome to the mesh, @[{sender}]! + +# Channel-specific greeting messages (optional) +# Format: channel_name:greeting_message,channel_name2:greeting_message2 +# If a channel has a specific greeting, it will be used instead of the default greeting_message +# Example: Public:Welcome to Public channel, @[{sender}]!|general:Welcome to general, @[{sender}]! +# Multi-part greetings are supported per channel using pipe (|) separator +# Leave empty to use greeting_message for all channels +channel_greetings = + +# Per-channel greetings (tracking behavior) +# false: Greet each user only once globally (default - user gets one greeting total) +# true: Greet each user once per channel (user can be greeted on each channel separately) +# Note: This controls tracking, not the greeting message itself. Use channel_greetings for different messages. +per_channel_greetings = false + +# Include mesh network information in greeting +# true: Add mesh statistics to greeting (total contacts, repeaters, etc.) +# false: Only send the greeting message +include_mesh_info = true + +# Mesh info format template +# Available fields: {total_contacts}, {repeaters}, {companions}, {recent_activity_24h} +# Example: "\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters" +# Note: Mesh info is appended to the last greeting message part +mesh_info_format = \n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters, {recent_activity_24h} active in last 24h + +# Rollout period in days +# When greeter is first enabled on an active mesh, this sets how many days +# to listen and mark all active users as already greeted before beginning +# to greet new users. This prevents greeting everyone on an established mesh. +# Set to 0 to disable rollout (will greet all new users immediately) +# Note: Use auto_backfill to mark historical users and shorten/eliminate rollout period +rollout_days = 7 + +# Auto-backfill from historical message_stats data +# true: Automatically mark all users who have posted on public channels in the past +# false: Only mark users during rollout period (default) +# This allows shortening or eliminating the rollout period by using existing data +auto_backfill = false + +# Backfill lookback period in days +# Number of days to look back when auto-backfilling (0 = all time) +# Only used if auto_backfill = true +# Example: 30 = only mark users who posted in last 30 days +# Example: 0 = mark all users who have ever posted (all time) +backfill_lookback_days = 30 + [Custom_Syntax] # Custom syntax patterns for special message formats # Format: pattern = "response_format" @@ -488,6 +553,13 @@ max_proximity_range = 200 # Set to 0 to disable age filtering max_repeater_age_days = 14 +# Star bias multiplier for path command +# When a contact is starred in the web viewer, multiply its selection score by this value +# Higher values = stronger preference for starred repeaters +# Default: 2.5 (starred repeaters get 2.5x their normal score) +# Set to 1.0 to disable star bias +star_bias_multiplier = 2.5 + # Recency vs Proximity weighting (0.0 to 1.0) # Controls how much recency (when last heard) vs proximity (distance) matters # 0.0 = 100% proximity (only distance matters) diff --git a/modules/commands/greeter_command.py b/modules/commands/greeter_command.py new file mode 100644 index 0000000..42aa957 --- /dev/null +++ b/modules/commands/greeter_command.py @@ -0,0 +1,849 @@ +#!/usr/bin/env python3 +""" +Greeter command for the MeshCore Bot +Greets users on their first public channel message with mesh information +""" + +import sqlite3 +import time +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +from .base_command import BaseCommand +from ..models import MeshMessage + + +class GreeterCommand(BaseCommand): + """Handles greeting new users on public channels""" + + # Plugin metadata + name = "greeter" + keywords = [] # No keywords - this command is triggered automatically + description = "Greets users on their first public channel message (once globally by default, or per-channel if configured)" + category = "system" + + def __init__(self, bot): + super().__init__(bot) + self._init_greeter_tables() + self._load_config() + + # Auto-backfill if enabled + if self.enabled and self.auto_backfill: + self.logger.info("Auto-backfill enabled - backfilling greeted users from historical data") + result = self.backfill_greeted_users(lookback_days=self.backfill_lookback_days) + if result['success']: + self.logger.info(f"Auto-backfill completed: {result['marked_count']} users marked") + else: + self.logger.warning(f"Auto-backfill failed: {result.get('error', 'Unknown error')}") + + # Check for existing rollout and mark active users if needed + self._check_rollout_period() + + # Auto-start rollout if enabled, rollout_days > 0, and no active rollout exists + if self.enabled and self.rollout_days > 0: + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT id FROM greeter_rollout + WHERE rollout_completed = 0 + ''') + if not cursor.fetchone(): + # No active rollout - start one automatically + self.logger.info(f"Auto-starting greeter rollout for {self.rollout_days} days") + self.start_rollout(backfill_first=self.auto_backfill) + except Exception as e: + self.logger.error(f"Error checking for existing rollout: {e}") + + def _load_config(self): + """Load configuration for greeter command""" + self.enabled = self.get_config_value('Greeter_Command', 'enabled', fallback=False, value_type='bool') + self.greeting_message = self.get_config_value('Greeter_Command', 'greeting_message', + fallback='Welcome to the mesh, {sender}!') + self.rollout_days = self.get_config_value('Greeter_Command', 'rollout_days', fallback=7, value_type='int') + self.include_mesh_info = self.get_config_value('Greeter_Command', 'include_mesh_info', + fallback=True, value_type='bool') + self.mesh_info_format = self.get_config_value('Greeter_Command', 'mesh_info_format', + fallback='\n\nMesh Info: {total_contacts} contacts, {repeaters} repeaters') + self.per_channel_greetings = self.get_config_value('Greeter_Command', 'per_channel_greetings', + fallback=False, value_type='bool') + self.auto_backfill = self.get_config_value('Greeter_Command', 'auto_backfill', + fallback=False, value_type='bool') + self.backfill_lookback_days = self.get_config_value('Greeter_Command', 'backfill_lookback_days', + fallback=None, value_type='int') + # Convert 0 to None (all time) + if self.backfill_lookback_days == 0: + self.backfill_lookback_days = None + + # Load greeter-specific channels (if not set, uses monitor_channels from [Channels] section) + channels_str = self.get_config_value('Greeter_Command', 'channels', fallback='') + if channels_str: + # Store both original and lowercase versions for case-insensitive matching + self.greeter_channels = [ch.strip() for ch in channels_str.split(',') if ch.strip()] + self.greeter_channels_lower = [ch.lower() for ch in self.greeter_channels] + else: + # Fall back to monitor_channels if not specified + self.greeter_channels = None + self.greeter_channels_lower = None + + # Load channel-specific greeting messages + # Format: channel_name:greeting_message,channel_name2:greeting_message2 + # Example: Public:Welcome to Public, {sender}!|general:Welcome to general, {sender}! + channel_greetings_str = self.get_config_value('Greeter_Command', 'channel_greetings', fallback='') + self.channel_greetings = {} + if channel_greetings_str: + for entry in channel_greetings_str.split(','): + entry = entry.strip() + if ':' in entry: + channel_name, greeting = entry.split(':', 1) + channel_name = channel_name.strip() + greeting = greeting.strip() + # Store both original and lowercase channel name for case-insensitive matching + self.channel_greetings[channel_name.lower()] = { + 'channel': channel_name, + 'greeting': greeting + } + + # Parse multi-part greetings (pipe-separated) + # If greeting_message contains '|', split it into multiple parts + if '|' in self.greeting_message: + self.greeting_parts = [part.strip() for part in self.greeting_message.split('|') if part.strip()] + else: + self.greeting_parts = [self.greeting_message] + + def _init_greeter_tables(self): + """Initialize database tables for greeter tracking""" + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + # Create greeted_users table for tracking who has been greeted + # channel can be NULL for global greetings (default behavior) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS greeted_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id TEXT NOT NULL, + channel TEXT, + greeted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + rollout_marked BOOLEAN DEFAULT 0, + UNIQUE(sender_id, channel) + ) + ''') + + # Create indexes for better performance + cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_sender ON greeted_users(sender_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_channel ON greeted_users(channel)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_at ON greeted_users(greeted_at)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_greeted_sender_channel ON greeted_users(sender_id, channel)') + + # Create greeter_rollout table to track rollout period + cursor.execute(''' + CREATE TABLE IF NOT EXISTS greeter_rollout ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rollout_started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + rollout_days INTEGER NOT NULL, + rollout_completed BOOLEAN DEFAULT 0, + active_users_marked INTEGER DEFAULT 0 + ) + ''') + + conn.commit() + self.logger.info("Greeter tables initialized successfully") + + except Exception as e: + self.logger.error(f"Failed to initialize greeter tables: {e}") + raise + + def _check_rollout_period(self): + """Check if we're in a rollout period and mark active users if needed""" + if not self.enabled: + return + + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + # Check if there's an active rollout + cursor.execute(''' + SELECT id, rollout_started_at, rollout_days, rollout_completed + FROM greeter_rollout + WHERE rollout_completed = 0 + ORDER BY rollout_started_at DESC + LIMIT 1 + ''') + + rollout = cursor.fetchone() + + if rollout: + rollout_id, started_at_str, rollout_days, completed = rollout + # Use SQLite's datetime functions to handle timezone correctly + cursor.execute(''' + SELECT datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date, + datetime('now') as current_time + FROM greeter_rollout + WHERE id = ? + ''', (rollout_id,)) + time_result = cursor.fetchone() + + if time_result: + end_date_str, current_time_str = time_result + end_date = datetime.fromisoformat(end_date_str) + current_time = datetime.fromisoformat(current_time_str) + + if current_time < end_date: + # Still in rollout period - mark active users + self.logger.info(f"Greeter rollout active: marking active users (ends {end_date})") + self._mark_active_users_as_greeted(rollout_id) + else: + # Rollout period ended - mark as completed + cursor.execute(''' + UPDATE greeter_rollout + SET rollout_completed = 1 + WHERE id = ? + ''', (rollout_id,)) + conn.commit() + self.logger.info(f"Greeter rollout period completed (ended {end_date})") + + except Exception as e: + self.logger.error(f"Error checking rollout period: {e}") + + def _mark_active_users_as_greeted(self, rollout_id: int): + """Mark all users who have posted on public channels during rollout period as greeted""" + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + # Get rollout start date + cursor.execute(''' + SELECT rollout_started_at FROM greeter_rollout WHERE id = ? + ''', (rollout_id,)) + result = cursor.fetchone() + if not result: + return + + rollout_start = datetime.fromisoformat(result[0]) + + # Find all users who posted on public channels since rollout started + # Only get messages that are NOT DMs (is_dm = 0) and have a channel + cursor.execute(''' + SELECT DISTINCT sender_id, channel + FROM message_stats + WHERE is_dm = 0 + AND channel IS NOT NULL + AND channel != '' + AND timestamp >= ? + ''', (int(rollout_start.timestamp()),)) + + active_users = cursor.fetchall() + marked_count = 0 + + for sender_id, channel in active_users: + # Mark based on per_channel_greetings setting + # If per_channel_greetings is False, mark globally (channel = NULL) + # If per_channel_greetings is True, mark per channel + if self.per_channel_greetings: + mark_channel = channel + # Check if already greeted on this channel + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel = ? + ''', (sender_id, mark_channel)) + else: + mark_channel = None + # Check if already greeted globally + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel IS NULL + ''', (sender_id,)) + + if not cursor.fetchone(): + # Mark as greeted with rollout flag + cursor.execute(''' + INSERT OR IGNORE INTO greeted_users + (sender_id, channel, rollout_marked, greeted_at) + VALUES (?, ?, 1, ?) + ''', (sender_id, mark_channel, rollout_start.isoformat())) + marked_count += 1 + + # Update rollout record + cursor.execute(''' + UPDATE greeter_rollout + SET active_users_marked = active_users_marked + ? + WHERE id = ? + ''', (marked_count, rollout_id)) + + conn.commit() + + if marked_count > 0: + self.logger.info(f"Marked {marked_count} active users as greeted during rollout") + + except Exception as e: + self.logger.error(f"Error marking active users as greeted: {e}") + + def backfill_greeted_users(self, lookback_days: Optional[int] = None) -> Dict[str, Any]: + """ + Backfill greeted_users table from historical message_stats data + + This allows marking all users who have posted on public channels in the past, + which can shorten or eliminate the rollout period. + + Args: + lookback_days: Number of days to look back (None = all time) + + Returns: + Dictionary with backfill results (marked_count, total_users, etc.) + """ + if not self.enabled: + self.logger.warning("Greeter is disabled - cannot backfill") + return {'success': False, 'error': 'Greeter is disabled'} + + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + # Check if message_stats table exists + cursor.execute(''' + SELECT name FROM sqlite_master + WHERE type='table' AND name='message_stats' + ''') + if not cursor.fetchone(): + return { + 'success': False, + 'error': 'message_stats table does not exist', + 'marked_count': 0 + } + + # Build query to find all users who posted on public channels + if lookback_days: + cutoff_timestamp = int(time.time()) - (lookback_days * 24 * 60 * 60) + cursor.execute(''' + SELECT DISTINCT sender_id, channel + FROM message_stats + WHERE is_dm = 0 + AND channel IS NOT NULL + AND channel != '' + AND timestamp >= ? + ''', (cutoff_timestamp,)) + else: + # All time + cursor.execute(''' + SELECT DISTINCT sender_id, channel + FROM message_stats + WHERE is_dm = 0 + AND channel IS NOT NULL + AND channel != '' + ''') + + historical_users = cursor.fetchall() + marked_count = 0 + skipped_count = 0 + + for sender_id, channel in historical_users: + # Mark based on per_channel_greetings setting + if self.per_channel_greetings: + mark_channel = channel + # Check if already greeted on this channel + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel = ? + ''', (sender_id, mark_channel)) + else: + mark_channel = None + # Check if already greeted globally + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel IS NULL + ''', (sender_id,)) + + if not cursor.fetchone(): + # Mark as greeted with backfill flag (use current time as greeted_at) + cursor.execute(''' + INSERT OR IGNORE INTO greeted_users + (sender_id, channel, rollout_marked, greeted_at) + VALUES (?, ?, 1, datetime('now')) + ''', (sender_id, mark_channel)) + marked_count += 1 + else: + skipped_count += 1 + + conn.commit() + + result = { + 'success': True, + 'marked_count': marked_count, + 'skipped_count': skipped_count, + 'total_users_found': len(historical_users), + 'lookback_days': lookback_days + } + + self.logger.info(f"Backfilled {marked_count} users from historical message_stats data " + f"({skipped_count} already marked, {len(historical_users)} total found)") + + return result + + except Exception as e: + self.logger.error(f"Error backfilling greeted users: {e}") + return { + 'success': False, + 'error': str(e), + 'marked_count': 0 + } + + def start_rollout(self, days: Optional[int] = None, backfill_first: bool = True) -> bool: + """ + Start a rollout period where all active users are marked as greeted + + Args: + days: Number of days for rollout period (uses config default if None) + backfill_first: If True, backfill from historical data before starting rollout + + Returns: + True if rollout started successfully + """ + if not self.enabled: + self.logger.warning("Greeter is disabled - cannot start rollout") + return False + + try: + # Backfill from historical data first if requested + if backfill_first: + self.logger.info("Backfilling from historical data before starting rollout...") + backfill_result = self.backfill_greeted_users(lookback_days=None) # All time + if backfill_result['success']: + self.logger.info(f"Backfilled {backfill_result['marked_count']} users from history") + + rollout_days = days or self.rollout_days + + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + # Check if there's already an active rollout + cursor.execute(''' + SELECT id FROM greeter_rollout + WHERE rollout_completed = 0 + ''') + + if cursor.fetchone(): + self.logger.warning("Rollout already in progress") + return False + + # Start new rollout + cursor.execute(''' + INSERT INTO greeter_rollout (rollout_days) + VALUES (?) + ''', (rollout_days,)) + + rollout_id = cursor.lastrowid + conn.commit() + + # Mark active users immediately + self._mark_active_users_as_greeted(rollout_id) + + self.logger.info(f"Started greeter rollout for {rollout_days} days (ID: {rollout_id})") + return True + + except Exception as e: + self.logger.error(f"Error starting rollout: {e}") + return False + + def has_been_greeted(self, sender_id: str, channel: str) -> bool: + """ + Check if a user has been greeted + + Args: + sender_id: The user's ID + channel: The channel name (used only if per_channel_greetings is True) + + Returns: + True if user has been greeted (globally or on this channel, depending on config) + """ + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + + if self.per_channel_greetings: + # Per-channel mode: check if greeted on this specific channel + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel = ? + ''', (sender_id, channel)) + else: + # Global mode: check if greeted at all (channel = NULL) + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel IS NULL + ''', (sender_id,)) + + return cursor.fetchone() is not None + except Exception as e: + self.logger.error(f"Error checking if user has been greeted: {e}") + return False + + def mark_as_greeted(self, sender_id: str, channel: str) -> bool: + """ + Mark a user as greeted + + Args: + sender_id: The user's ID + channel: The channel name (stored only if per_channel_greetings is True) + + Returns: + True if user was marked (or already marked), False on error + """ + try: + db_path = self.bot.db_manager.db_path + self.logger.debug(f"Marking {sender_id} as greeted (channel: {channel}, db: {db_path})") + + with sqlite3.connect(db_path) as conn: + cursor = conn.cursor() + + # Check if already exists first + if self.per_channel_greetings: + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel = ? + ''', (sender_id, channel)) + exists = cursor.fetchone() is not None + + if not exists: + cursor.execute(''' + INSERT INTO greeted_users (sender_id, channel) + VALUES (?, ?) + ''', (sender_id, channel)) + conn.commit() + self.logger.info(f"✅ Saved: Marked {sender_id} as greeted on channel {channel}") + return True + else: + self.logger.debug(f"User {sender_id} already marked as greeted on channel {channel}") + return True + else: + # Global mode: store NULL for channel (greeted once globally) + cursor.execute(''' + SELECT id FROM greeted_users + WHERE sender_id = ? AND channel IS NULL + ''', (sender_id,)) + exists = cursor.fetchone() is not None + + if not exists: + cursor.execute(''' + INSERT INTO greeted_users (sender_id, channel) + VALUES (?, NULL) + ''', (sender_id,)) + conn.commit() + self.logger.info(f"✅ Saved: Marked {sender_id} as greeted globally (all channels)") + return True + else: + self.logger.debug(f"User {sender_id} already marked as greeted globally") + return True + + except Exception as e: + self.logger.error(f"❌ Error marking user as greeted: {e}") + import traceback + self.logger.error(traceback.format_exc()) + return False + + def get_greeted_users_count(self) -> int: + """Get count of users who have been greeted (for verification)""" + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + cursor.execute('SELECT COUNT(*) FROM greeted_users') + count = cursor.fetchone()[0] + return count + except Exception as e: + self.logger.error(f"Error getting greeted users count: {e}") + return 0 + + def get_recent_greeted_users(self, limit: int = 10) -> List[Dict[str, Any]]: + """Get recent greeted users (for verification)""" + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(''' + SELECT sender_id, channel, greeted_at, rollout_marked + FROM greeted_users + ORDER BY greeted_at DESC + LIMIT ? + ''', (limit,)) + rows = cursor.fetchall() + return [dict(row) for row in rows] + except Exception as e: + self.logger.error(f"Error getting recent greeted users: {e}") + return [] + + async def _get_mesh_info(self) -> Dict[str, Any]: + """Get mesh network information for greeting""" + info = { + 'total_contacts': 0, + 'repeaters': 0, + 'companions': 0, + 'recent_activity_24h': 0 + } + + try: + # Get contact statistics from repeater manager if available + if hasattr(self.bot, 'repeater_manager'): + try: + stats = await self.bot.repeater_manager.get_contact_statistics() + if stats: + info['total_contacts'] = stats.get('total_heard', 0) + info['repeaters'] = stats.get('by_role', {}).get('repeater', 0) + info['companions'] = stats.get('by_role', {}).get('companion', 0) + info['recent_activity_24h'] = stats.get('recent_activity', 0) + except Exception as e: + self.logger.debug(f"Error getting stats from repeater_manager: {e}") + + # Fallback to device contacts if repeater manager stats not available + if info['total_contacts'] == 0 and hasattr(self.bot, 'meshcore') and hasattr(self.bot.meshcore, 'contacts'): + info['total_contacts'] = len(self.bot.meshcore.contacts) + + # Count repeaters and companions + if hasattr(self.bot, 'repeater_manager'): + for contact_data in self.bot.meshcore.contacts.values(): + if self.bot.repeater_manager._is_repeater_device(contact_data): + info['repeaters'] += 1 + else: + info['companions'] += 1 + + # Get recent activity from message_stats if available + if info['recent_activity_24h'] == 0: + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + # Check if message_stats table exists + cursor.execute(''' + SELECT name FROM sqlite_master + WHERE type='table' AND name='message_stats' + ''') + if cursor.fetchone(): + cutoff_time = int(time.time()) - (24 * 60 * 60) + cursor.execute(''' + SELECT COUNT(DISTINCT sender_id) + FROM message_stats + WHERE timestamp >= ? AND is_dm = 0 + ''', (cutoff_time,)) + result = cursor.fetchone() + if result: + info['recent_activity_24h'] = result[0] + except Exception: + pass + + except Exception as e: + self.logger.debug(f"Error getting mesh info: {e}") + + return info + + def _get_greeting_for_channel(self, channel: str) -> str: + """ + Get greeting message for a specific channel + + Args: + channel: Channel name + + Returns: + Greeting message template for the channel, or default if not specified + """ + if channel and channel.lower() in self.channel_greetings: + return self.channel_greetings[channel.lower()]['greeting'] + return self.greeting_message + + async def _format_greeting_parts(self, sender_id: str, channel: str = None, mesh_info: Optional[Dict[str, Any]] = None) -> list: + """ + Format greeting message parts with mesh information + + Args: + sender_id: The user's ID + channel: Channel name (for channel-specific greetings) + mesh_info: Optional mesh info dict (will be fetched if None) + + Returns: + List of greeting message strings (for multi-part greetings) + """ + if mesh_info is None: + mesh_info = await self._get_mesh_info() + + # Get channel-specific greeting if available, otherwise use default + greeting_template = self._get_greeting_for_channel(channel) if channel else self.greeting_message + + # Parse multi-part greetings (pipe-separated) + if '|' in greeting_template: + greeting_parts = [part.strip() for part in greeting_template.split('|') if part.strip()] + else: + greeting_parts = [greeting_template] + + # Format each greeting part + formatted_parts = [] + for part in greeting_parts: + formatted_part = part.format(sender=sender_id) + formatted_parts.append(formatted_part) + + # Add mesh info to the last part if enabled + if self.include_mesh_info: + mesh_info_text = self.mesh_info_format.format( + total_contacts=mesh_info.get('total_contacts', 0), + repeaters=mesh_info.get('repeaters', 0), + companions=mesh_info.get('companions', 0), + recent_activity_24h=mesh_info.get('recent_activity_24h', 0) + ) + # Append mesh info to the last greeting part + if formatted_parts: + formatted_parts[-1] += mesh_info_text + else: + formatted_parts.append(mesh_info_text) + + return formatted_parts + + def matches_keyword(self, message: MeshMessage) -> bool: + """Greeter doesn't match keywords - it's triggered automatically""" + return False + + def matches_custom_syntax(self, message: MeshMessage) -> bool: + """Greeter doesn't match custom syntax""" + return False + + def _is_rollout_active(self) -> bool: + """Check if there's an active rollout period""" + try: + with sqlite3.connect(self.bot.db_manager.db_path) as conn: + cursor = conn.cursor() + # Use SQLite's datetime functions to calculate end date and compare with current time + # This handles timezone issues automatically since both are in UTC + cursor.execute(''' + SELECT id, rollout_started_at, rollout_days, + datetime(rollout_started_at, '+' || rollout_days || ' days') as end_date, + datetime('now') as current_time + FROM greeter_rollout + WHERE rollout_completed = 0 + ORDER BY rollout_started_at DESC + LIMIT 1 + ''') + rollout = cursor.fetchone() + + if rollout: + rollout_id, started_at_str, rollout_days, end_date_str, current_time_str = rollout + + # Parse for logging (both are in UTC from SQLite) + started_at = datetime.fromisoformat(started_at_str) + end_date = datetime.fromisoformat(end_date_str) + current_time = datetime.fromisoformat(current_time_str) + + if current_time < end_date: + remaining = (end_date - current_time).total_seconds() / 86400 # days + self.logger.debug(f"Rollout active: {remaining:.1f} days remaining (started {started_at}, ends {end_date})") + return True + else: + # Rollout period ended - mark as completed + cursor.execute(''' + UPDATE greeter_rollout + SET rollout_completed = 1 + WHERE id = ? + ''', (rollout_id,)) + conn.commit() + self.logger.info(f"Greeter rollout period completed (ended {end_date})") + return False + + self.logger.debug("No active rollout found") + return False + except Exception as e: + self.logger.error(f"Error checking rollout status: {e}") + import traceback + self.logger.error(traceback.format_exc()) + return False + + def should_execute(self, message: MeshMessage) -> bool: + """ + Check if greeter should execute for this message + Only executes for public channel messages (not DMs) on monitored channels + """ + if not self.enabled: + return False + + # Only greet on public channels + if message.is_dm: + return False + + # Must have a channel name + if not message.channel: + return False + + # Check if channel is in greeter-specific channels or fall back to monitor_channels + if self.greeter_channels is not None: + # Use greeter-specific channels if configured (case-insensitive matching) + if message.channel and message.channel.lower() not in self.greeter_channels_lower: + return False + else: + # Fall back to general monitor_channels setting (case-insensitive matching) + monitor_channels_lower = [ch.lower() for ch in self.bot.command_manager.monitor_channels] + if message.channel and message.channel.lower() not in monitor_channels_lower: + return False + + # Check if we're in an active rollout period + rollout_active = self._is_rollout_active() + if rollout_active: + # During rollout, mark user as greeted but don't actually greet them + self.logger.info(f"🔄 Rollout active: Marking {message.sender_id} as greeted on {message.channel} (no greeting sent)") + self.mark_as_greeted(message.sender_id, message.channel) + return False + else: + self.logger.debug(f"Rollout not active - proceeding with greeting check for {message.sender_id}") + + # Check if user has already been greeted (globally or per-channel, depending on config) + if self.has_been_greeted(message.sender_id, message.channel): + return False + + return True + + async def execute(self, message: MeshMessage) -> bool: + """Execute the greeter command - greet the user on their first public message""" + try: + # Double-check we should greet (race condition protection) + if not self.should_execute(message): + return False + + # Mark as greeted BEFORE getting mesh info (to prevent duplicate greetings) + # This ensures we don't greet the same user twice even if there's a delay + marked = self.mark_as_greeted(message.sender_id, message.channel) + if not marked: + self.logger.warning(f"Failed to mark {message.sender_id} as greeted - aborting greeting") + return False + + # Format greeting parts (may be single or multi-part) + # Pass channel name for channel-specific greetings + greeting_parts = await self._format_greeting_parts(message.sender_id, message.channel) + + # Send greeting(s) + mode_str = "per-channel" if self.per_channel_greetings else "global" + self.logger.info(f"Greeting {message.sender_id} on channel {message.channel} ({mode_str} mode, {len(greeting_parts)} part(s))") + + # Log database verification + total_greeted = self.get_greeted_users_count() + self.logger.debug(f"Database verification: {total_greeted} total user(s) marked as greeted") + + # Send all greeting parts + success = True + for i, greeting_part in enumerate(greeting_parts): + if i > 0: + # Wait for bot TX rate limiter between multi-part messages + # This ensures we respect the bot's rate limiting configuration + await self.bot.bot_tx_rate_limiter.wait_for_tx() + # Additional delay to ensure proper spacing (use configured rate limit) + import asyncio + rate_limit = self.bot.config.getfloat('Bot', 'bot_tx_rate_limit_seconds', fallback=1.0) + # Use a conservative sleep time to avoid rate limiting + sleep_time = max(rate_limit + 0.5, 1.0) # At least 1 second, or rate_limit + 0.5 seconds + await asyncio.sleep(sleep_time) + + result = await self.send_response(message, greeting_part) + if not result: + success = False + + return success + + except Exception as e: + self.logger.error(f"Error executing greeter command: {e}") + return False + + def get_help_text(self) -> str: + mode = "per-channel" if self.per_channel_greetings else "global (once total)" + return f"Greeter automatically welcomes new users on public channels ({mode} mode). Configure in [Greeter_Command] section." + diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index ad8ed9e..edecd98 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -44,6 +44,11 @@ class PathCommand(BaseCommand): self.recency_weight = max(0.0, min(1.0, recency_weight)) # Clamp to 0.0-1.0 self.proximity_weight = 1.0 - self.recency_weight + # Get star bias multiplier (how much to boost starred repeaters' scores) + # Default 2.5 means starred repeaters get 2.5x their normal score + self.star_bias_multiplier = bot.config.getfloat('Path_Command', 'star_bias_multiplier', fallback=2.5) + self.star_bias_multiplier = max(1.0, self.star_bias_multiplier) # Ensure at least 1.0 + # Get confidence indicator symbols from config self.high_confidence_symbol = bot.config.get('Path_Command', 'high_confidence_symbol', fallback='🎯') self.medium_confidence_symbol = bot.config.get('Path_Command', 'medium_confidence_symbol', fallback='📍') @@ -194,7 +199,8 @@ class PathCommand(BaseCommand): 'advert_count': row['advert_count'], 'signal_strength': row['signal_strength'], 'hop_count': row['hop_count'], - 'role': row['role'] + 'role': row['role'], + 'is_starred': bool(row.get('is_starred', 0)) # Include star status for bias }) except Exception as e: self.logger.debug(f"Error getting complete database: {e}") @@ -209,7 +215,7 @@ class PathCommand(BaseCommand): query = ''' SELECT name, public_key, device_type, last_heard, last_heard as last_seen, last_advert_timestamp, latitude, longitude, city, state, country, - advert_count, signal_strength, hop_count, role + advert_count, signal_strength, hop_count, role, is_starred FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND ( @@ -222,7 +228,7 @@ class PathCommand(BaseCommand): query = ''' SELECT name, public_key, device_type, last_heard, last_heard as last_seen, last_advert_timestamp, latitude, longitude, city, state, country, - advert_count, signal_strength, hop_count, role + advert_count, signal_strength, hop_count, role, is_starred FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC @@ -250,7 +256,8 @@ class PathCommand(BaseCommand): 'advert_count': row.get('advert_count', 0), 'signal_strength': row.get('signal_strength'), 'hop_count': row.get('hop_count'), - 'role': row.get('role') + 'role': row.get('role'), + 'is_starred': bool(row.get('is_starred', 0)) # Include star status for bias } for row in results ] except Exception as e: @@ -272,7 +279,8 @@ class PathCommand(BaseCommand): 'longitude': row['longitude'], 'city': row['city'], 'state': row['state'], - 'country': row['country'] + 'country': row['country'], + 'is_starred': row.get('is_starred', False) # Include star status for bias } for row in results ] @@ -510,6 +518,12 @@ class PathCommand(BaseCommand): # Use configurable weighting (default: 40% recency, 60% proximity) combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight) + + # Apply star bias multiplier if repeater is starred + if repeater.get('is_starred', False): + combined_score *= self.star_bias_multiplier + self.logger.debug(f"Applied star bias ({self.star_bias_multiplier}x) to {repeater.get('name', 'unknown')}") + combined_scores.append((combined_score, distance, repeater)) if not combined_scores: @@ -805,22 +819,22 @@ class PathCommand(BaseCommand): # Use last_advert_timestamp if available, otherwise fall back to last_heard if self.max_repeater_age_days > 0: query = ''' - SELECT latitude, longitude FROM complete_contact_tracking + SELECT latitude, longitude, is_starred FROM complete_contact_tracking WHERE public_key LIKE ? AND latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 AND role IN ('repeater', 'roomserver') AND ( (last_advert_timestamp IS NOT NULL AND last_advert_timestamp >= datetime('now', '-{} days')) OR (last_advert_timestamp IS NULL AND last_heard >= datetime('now', '-{} days')) ) - ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 '''.format(self.max_repeater_age_days, self.max_repeater_age_days) else: query = ''' - SELECT latitude, longitude FROM complete_contact_tracking + SELECT latitude, longitude, is_starred FROM complete_contact_tracking WHERE public_key LIKE ? AND latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 AND role IN ('repeater', 'roomserver') - ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 ''' @@ -871,6 +885,11 @@ class PathCommand(BaseCommand): # Use configurable weighting (default: 40% recency, 60% proximity) combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight) + # Apply star bias multiplier if repeater is starred + if repeater.get('is_starred', False): + combined_score *= self.star_bias_multiplier + self.logger.debug(f"Applied star bias ({self.star_bias_multiplier}x) to {repeater.get('name', 'unknown')}") + if combined_score > best_combined_score: best_combined_score = combined_score best_repeater = repeater @@ -928,6 +947,11 @@ class PathCommand(BaseCommand): # Use configurable weighting (default: 40% recency, 60% proximity) combined_score = (recency_score * self.recency_weight) + (proximity_score * self.proximity_weight) + # Apply star bias multiplier if repeater is starred + if repeater.get('is_starred', False): + combined_score *= self.star_bias_multiplier + self.logger.debug(f"Applied star bias ({self.star_bias_multiplier}x) to {repeater.get('name', 'unknown')}") + if combined_score > best_combined_score: best_combined_score = combined_score best_repeater = repeater diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index b21116c..1f2379f 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -114,7 +114,7 @@ class TestCommand(BaseCommand): # Query for all repeaters with matching prefix query = ''' SELECT latitude, longitude, public_key, name, - last_advert_timestamp, last_heard, advert_count + last_advert_timestamp, last_heard, advert_count, is_starred FROM complete_contact_tracking WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND latitude IS NOT NULL AND longitude IS NOT NULL @@ -137,7 +137,8 @@ class TestCommand(BaseCommand): 'name': row.get('name'), 'last_advert_timestamp': row.get('last_advert_timestamp'), 'last_heard': row.get('last_heard'), - 'advert_count': row.get('advert_count', 0) + 'advert_count': row.get('advert_count', 0), + 'is_starred': bool(row.get('is_starred', 0)) }) # If only one repeater, return it @@ -221,7 +222,7 @@ class TestCommand(BaseCommand): WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') AND latitude IS NOT NULL AND longitude IS NOT NULL AND latitude != 0 AND longitude != 0 - ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + ORDER BY is_starred DESC, COALESCE(last_advert_timestamp, last_heard) DESC LIMIT 1 ''' @@ -315,6 +316,11 @@ class TestCommand(BaseCommand): # Weight: 40% recency, 60% proximity combined_score = (recency_score * 0.4) + (proximity_score * 0.6) + # Apply star bias multiplier if repeater is starred (use same config as path command) + star_bias_multiplier = self.bot.config.getfloat('Path_Command', 'star_bias_multiplier', fallback=2.5) + if repeater.get('is_starred', False): + combined_score *= star_bias_multiplier + if combined_score > best_combined_score: best_combined_score = combined_score best_repeater = repeater @@ -346,6 +352,11 @@ class TestCommand(BaseCommand): # Weight: 40% recency, 60% proximity combined_score = (recency_score * 0.4) + (proximity_score * 0.6) + # Apply star bias multiplier if repeater is starred (use same config as path command) + star_bias_multiplier = self.bot.config.getfloat('Path_Command', 'star_bias_multiplier', fallback=2.5) + if repeater.get('is_starred', False): + combined_score *= star_bias_multiplier + if combined_score > best_combined_score: best_combined_score = combined_score best_repeater = repeater diff --git a/modules/message_handler.py b/modules/message_handler.py index 5745d9f..5366c6e 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -486,6 +486,11 @@ class MessageHandler: decoded_packet = self.decode_meshcore_packet(raw_hex, extracted_payload) if decoded_packet: # Calculate packet hash for this packet (useful for tracking same message via different paths) + # Use extracted_payload if available (actual MeshCore packet), otherwise use raw_hex + # This matches the logic in decode_meshcore_packet which prefers extracted_payload + # extracted_payload is the actual MeshCore packet without RF wrapper, so use it if available + packet_hex_for_hash = extracted_payload if (extracted_payload and len(extracted_payload) > 0) else raw_hex + # Ensure we use the numeric payload_type value (not enum or string) payload_type_value = decoded_packet.get('payload_type', None) if payload_type_value is not None: @@ -493,7 +498,7 @@ class MessageHandler: if hasattr(payload_type_value, 'value'): payload_type_value = payload_type_value.value payload_type_value = int(payload_type_value) - packet_hash = calculate_packet_hash(raw_hex, payload_type_value) + packet_hash = calculate_packet_hash(packet_hex_for_hash, payload_type_value) routing_info = { 'path_length': decoded_packet.get('path_len', 0), @@ -1553,6 +1558,16 @@ class MessageHandler: stats_command.record_message(message) stats_command.record_path_stats(message) + # Check greeter command for public channel messages (BEFORE general message filtering) + # This allows greeter to work on its own configured channels even if not in monitor_channels + if 'greeter' in self.bot.command_manager.commands: + greeter_command = self.bot.command_manager.commands['greeter'] + if greeter_command and greeter_command.should_execute(message): + try: + await greeter_command.execute(message) + except Exception as e: + self.logger.error(f"Error executing greeter command: {e}") + # Now check if we should process this message for bot responses if not self.should_process_message(message): return diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index df491c5..23377ff 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -180,6 +180,12 @@ class RepeaterManager: cursor.execute(f"ALTER TABLE complete_contact_tracking ADD COLUMN {column_name} {column_type}") conn.commit() + # Add is_starred column for path command bias + if 'is_starred' not in tracking_columns: + self.logger.info("Adding is_starred column to complete_contact_tracking") + cursor.execute("ALTER TABLE complete_contact_tracking ADD COLUMN is_starred BOOLEAN DEFAULT 0") + conn.commit() + self.logger.info("Database schema migration completed") except Exception as e: @@ -454,7 +460,7 @@ class RepeaterManager: query = ''' SELECT public_key, name, role, device_type, first_heard, last_heard, advert_count, latitude, longitude, city, state, country, - signal_strength, hop_count, is_currently_tracked, last_advert_timestamp + signal_strength, hop_count, is_currently_tracked, last_advert_timestamp, is_starred FROM complete_contact_tracking WHERE role = ? ORDER BY last_heard DESC @@ -465,7 +471,7 @@ class RepeaterManager: query = ''' SELECT public_key, name, role, device_type, first_heard, last_heard, advert_count, latitude, longitude, city, state, country, - signal_strength, hop_count, is_currently_tracked, last_advert_timestamp + signal_strength, hop_count, is_currently_tracked, last_advert_timestamp, is_starred FROM complete_contact_tracking ORDER BY last_heard DESC ''' diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 8251441..99bb031 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -464,6 +464,64 @@ class BotDataViewer: except Exception as e: self.logger.error(f"Error geocoding contact: {e}", exc_info=True) return jsonify({'error': str(e)}), 500 + + @self.app.route('/api/toggle-star-contact', methods=['POST']) + def api_toggle_star_contact(): + """Toggle star status for a contact by public_key (only for repeaters and roomservers)""" + try: + data = request.get_json() + if not data or 'public_key' not in data: + return jsonify({'error': 'public_key is required'}), 400 + + public_key = data['public_key'] + + # Get contact data from database + conn = self._get_db_connection() + cursor = conn.cursor() + + # Check if contact exists and is a repeater or roomserver + cursor.execute(''' + SELECT name, is_starred, role FROM complete_contact_tracking + WHERE public_key = ? + ''', (public_key,)) + + contact = cursor.fetchone() + if not contact: + conn.close() + return jsonify({'error': 'Contact not found'}), 404 + + # Only allow starring repeaters and roomservers + # sqlite3.Row objects use dictionary-style access with [] + role = contact['role'] + if role and role.lower() not in ('repeater', 'roomserver'): + conn.close() + return jsonify({'error': 'Only repeaters and roomservers can be starred'}), 400 + + # Toggle star status + # sqlite3.Row objects use dictionary-style access with [] + current_starred = contact['is_starred'] + new_star_status = 1 if not current_starred else 0 + cursor.execute(''' + UPDATE complete_contact_tracking + SET is_starred = ? + WHERE public_key = ? + ''', (new_star_status, public_key)) + + conn.commit() + conn.close() + + action = 'starred' if new_star_status else 'unstarred' + self.logger.info(f"Contact {contact['name']} ({public_key[:16]}...) {action}") + + return jsonify({ + 'success': True, + 'is_starred': bool(new_star_status), + 'message': f'Contact {action} successfully' + }) + + except Exception as e: + self.logger.error(f"Error toggling star status: {e}", exc_info=True) + return jsonify({'error': str(e)}), 500 def _setup_socketio_handlers(self): """Setup SocketIO event handlers using modern patterns""" @@ -1169,6 +1227,7 @@ class BotDataViewer: snr, hop_count, first_heard, last_heard, advert_count, is_currently_tracked, raw_advert_data, signal_strength, + is_starred, COUNT(*) as total_messages, MAX(last_advert_timestamp) as last_message FROM complete_contact_tracking @@ -1176,7 +1235,7 @@ class BotDataViewer: latitude, longitude, city, state, country, snr, hop_count, first_heard, last_heard, advert_count, is_currently_tracked, - raw_advert_data, signal_strength + raw_advert_data, signal_strength, is_starred ORDER BY last_heard DESC """) @@ -1218,7 +1277,8 @@ class BotDataViewer: 'signal_strength': row['signal_strength'], 'total_messages': row['total_messages'], 'last_message': row['last_message'], - 'distance': distance + 'distance': distance, + 'is_starred': bool(row['is_starred'] if row['is_starred'] is not None else 0) }) # Get server statistics for daily tracking using direct database queries diff --git a/modules/web_viewer/templates/contacts.html b/modules/web_viewer/templates/contacts.html index 5b9b81b..d8a7741 100644 --- a/modules/web_viewer/templates/contacts.html +++ b/modules/web_viewer/templates/contacts.html @@ -419,6 +419,12 @@ class ModernContactsManager { ${contact.advert_count || 0}
+ ${contact.role && (contact.role.toLowerCase() === 'repeater' || contact.role.toLowerCase() === 'roomserver') ? + `` : + '' + } @@ -882,6 +888,73 @@ class ModernContactsManager { } } + async toggleStar(userId, buttonElement = null) { + // Find the contact data + const contact = this.filteredData.find(c => c.user_id === userId); + if (!contact) { + this.showError('Contact data not found'); + return; + } + + // Get the button element - use passed element or find it + const button = buttonElement || document.querySelector(`button[onclick*="toggleStar('${userId.substring(0, 16)}"]`); + if (!button) { + this.showError('Button not found'); + return; + } + + // Disable button and show loading state + const originalHTML = button.innerHTML; + button.disabled = true; + button.innerHTML = ''; + + try { + // Call the toggle star API + const response = await fetch('/api/toggle-star-contact', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + public_key: userId + }) + }); + + const data = await response.json(); + + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to toggle star status'); + } + + // Update the contact data in our local array + const contactIndex = this.filteredData.findIndex(c => c.user_id === userId); + if (contactIndex !== -1) { + this.filteredData[contactIndex].is_starred = data.is_starred; + } + + // Also update in the main contacts data + const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId); + if (mainContactIndex !== -1) { + this.contactsData.tracking_data[mainContactIndex].is_starred = data.is_starred; + } + + // Re-render the table to show updated star status + this.renderContactsData(); + + // Show success message + const action = data.is_starred ? 'starred' : 'unstarred'; + this.showSuccess(`Contact ${action} successfully. Path command will ${data.is_starred ? 'strongly prefer' : 'no longer prefer'} this repeater.`); + + } catch (error) { + console.error('Error toggling star status:', error); + this.showError('Failed to toggle star status: ' + error.message); + } finally { + // Restore button state + button.disabled = false; + button.innerHTML = originalHTML; + } + } + showError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'alert alert-danger alert-dismissible fade show';