From 82aa5ee1604ad7939097700a5fcdfb99ab25d507 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 25 Nov 2025 17:26:40 -0800 Subject: [PATCH] Add greeter command configuration to config.ini.example and implement greeter functionality in message handling. Introduce favorite status management for repeaters, allowing users to toggle favorite status via API. Update path command and test command to incorporate favorite bias in scoring. Enhance database schema to support star status tracking for repeaters and roomservers. --- config.ini.example | 72 ++ modules/commands/greeter_command.py | 849 +++++++++++++++++++++ modules/commands/path_command.py | 42 +- modules/commands/test_command.py | 17 +- modules/message_handler.py | 17 +- modules/repeater_manager.py | 10 +- modules/web_viewer/app.py | 64 +- modules/web_viewer/templates/contacts.html | 73 ++ 8 files changed, 1127 insertions(+), 17 deletions(-) create mode 100644 modules/commands/greeter_command.py 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';