diff --git a/modules/command_manager.py b/modules/command_manager.py
index 2392485..96594c0 100644
--- a/modules/command_manager.py
+++ b/modules/command_manager.py
@@ -513,9 +513,15 @@ class CommandManager:
# Check if command can execute (cooldown, DM requirements, etc.)
if not command.can_execute_now(message):
+ # For DM-only commands in public channels, only show error if channel is allowed
+ # (i.e., channel is in monitor_channels or command's allowed_channels)
+ # This prevents prompting users in channels where the command shouldn't work at all
if command.requires_dm and not message.is_dm:
- error_msg = command.translate('errors.dm_only', command=command_name)
- await self.send_response(message, error_msg)
+ # Only prompt if channel is allowed (configured channels)
+ if command.is_channel_allowed(message):
+ error_msg = command.translate('errors.dm_only', command=command_name)
+ await self.send_response(message, error_msg)
+ # Otherwise, silently ignore (channel not configured for this command)
elif command.requires_admin_access():
error_msg = command.translate('errors.access_denied', command=command_name)
await self.send_response(message, error_msg)
diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py
index d9efb1b..5e58e1a 100644
--- a/modules/commands/base_command.py
+++ b/modules/commands/base_command.py
@@ -310,7 +310,21 @@ class BaseCommand(ABC):
def should_execute(self, message: MeshMessage) -> bool:
"""Check if this command should execute for the given message"""
- return (self.matches_keyword(message) or self.matches_custom_syntax(message))
+ # First check if keyword matches
+ if not (self.matches_keyword(message) or self.matches_custom_syntax(message)):
+ return False
+
+ # For DM-only commands, only consider them if:
+ # 1. Message is a DM, OR
+ # 2. Channel is in allowed_channels (or monitor_channels if no override)
+ # This prevents DM-only commands from being processed in public channels
+ if self.requires_dm and not message.is_dm:
+ # Check if channel is allowed for this command
+ if not self.is_channel_allowed(message):
+ # Channel not allowed - don't even consider this command
+ return False
+
+ return True
def can_execute_now(self, message: MeshMessage) -> bool:
"""Check if this command can execute right now (permissions, cooldown, etc.)"""
diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py
index 737f69f..f565fda 100644
--- a/modules/web_viewer/app.py
+++ b/modules/web_viewer/app.py
@@ -250,7 +250,17 @@ class BotDataViewer:
def api_stats():
"""Get comprehensive database statistics for dashboard"""
try:
- stats = self._get_database_stats()
+ # Get optional time window parameters for analytics
+ top_users_window = request.args.get('top_users_window', 'all')
+ top_commands_window = request.args.get('top_commands_window', 'all')
+ top_paths_window = request.args.get('top_paths_window', 'all')
+ top_channels_window = request.args.get('top_channels_window', 'all')
+ stats = self._get_database_stats(
+ top_users_window=top_users_window,
+ top_commands_window=top_commands_window,
+ top_paths_window=top_paths_window,
+ top_channels_window=top_channels_window
+ )
return jsonify(stats)
except Exception as e:
self.logger.error(f"Error getting stats: {e}")
@@ -795,7 +805,8 @@ class BotDataViewer:
except Exception as e:
self.logger.error(f"Error cleaning up old packet stream data: {e}")
- def _get_database_stats(self):
+ def _get_database_stats(self, top_users_window='all', top_commands_window='all',
+ top_paths_window='all', top_channels_window='all'):
"""Get comprehensive database statistics for dashboard"""
conn = None
try:
@@ -971,14 +982,25 @@ class BotDataViewer:
cursor.execute("SELECT COUNT(DISTINCT channel) FROM message_stats WHERE channel IS NOT NULL")
stats['unique_channels_total'] = cursor.fetchone()[0]
- # Top users (most frequent message senders)
- cursor.execute("""
+ # Top users (most frequent message senders) - filter by time window
+ if top_users_window == '24h':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
+ elif top_users_window == '7d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
+ elif top_users_window == '30d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
+ else: # 'all'
+ time_filter = ""
+
+ query = f"""
SELECT sender_id, COUNT(*) as count
FROM message_stats
+ {time_filter}
GROUP BY sender_id
ORDER BY count DESC
LIMIT 15
- """)
+ """
+ cursor.execute(query)
stats['top_users'] = [{'user': row[0], 'count': row[1]} for row in cursor.fetchall()]
if 'command_stats' in tables:
@@ -991,14 +1013,25 @@ class BotDataViewer:
""")
stats['commands_24h'] = cursor.fetchone()[0]
- # Top commands
- cursor.execute("""
+ # Top commands - filter by time window
+ if top_commands_window == '24h':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
+ elif top_commands_window == '7d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
+ elif top_commands_window == '30d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
+ else: # 'all'
+ time_filter = ""
+
+ query = f"""
SELECT command_name, COUNT(*) as count
FROM command_stats
+ {time_filter}
GROUP BY command_name
ORDER BY count DESC
LIMIT 15
- """)
+ """
+ cursor.execute(query)
stats['top_commands'] = [{'command': row[0], 'count': row[1]} for row in cursor.fetchall()]
# Bot reply rate (commands that got responses)
@@ -1012,15 +1045,25 @@ class BotDataViewer:
else:
stats['bot_reply_rate'] = 0
- # Top channels by message count
- cursor.execute("""
+ # Top channels by message count - filter by time window
+ if top_channels_window == '24h':
+ time_filter = "AND timestamp > strftime('%s', 'now', '-24 hours')"
+ elif top_channels_window == '7d':
+ time_filter = "AND timestamp > strftime('%s', 'now', '-7 days')"
+ elif top_channels_window == '30d':
+ time_filter = "AND timestamp > strftime('%s', 'now', '-30 days')"
+ else: # 'all'
+ time_filter = ""
+
+ query = f"""
SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users
FROM message_stats
- WHERE channel IS NOT NULL
+ WHERE channel IS NOT NULL {time_filter}
GROUP BY channel
ORDER BY message_count DESC
LIMIT 10
- """)
+ """
+ cursor.execute(query)
stats['top_channels'] = [
{'channel': row[0], 'messages': row[1], 'users': row[2]}
for row in cursor.fetchall()
@@ -1043,13 +1086,24 @@ class BotDataViewer:
'timestamp': longest_path[3]
}
- # Top paths (longest paths)
- cursor.execute("""
+ # Top paths (longest paths) - filter by time window
+ if top_paths_window == '24h':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-24 hours')"
+ elif top_paths_window == '7d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-7 days')"
+ elif top_paths_window == '30d':
+ time_filter = "WHERE timestamp > strftime('%s', 'now', '-30 days')"
+ else: # 'all'
+ time_filter = ""
+
+ query = f"""
SELECT sender_id, path_length, path_string, timestamp
FROM path_stats
+ {time_filter}
ORDER BY path_length DESC
LIMIT 5
- """)
+ """
+ cursor.execute(query)
stats['top_paths'] = [
{
'user': row[0],
@@ -1373,23 +1427,137 @@ class BotDataViewer:
server_stats['total_advertisements'] = cursor.fetchone()[0] or 0
# Nodes per day statistics
+ # Calculate today's unique nodes from complete_contact_tracking
+ # (last_heard in last 24 hours) since daily_stats might not have today's data yet
cursor.execute("""
- SELECT COUNT(DISTINCT public_key) FROM daily_stats
- WHERE date = date('now')
+ SELECT COUNT(DISTINCT public_key) FROM complete_contact_tracking
+ WHERE last_heard >= datetime('now', '-24 hours')
""")
server_stats['nodes_24h'] = cursor.fetchone()[0] or 0
+ # Get today's unique nodes by role for the stacked chart
+ cursor.execute("""
+ SELECT role, COUNT(DISTINCT public_key) as count
+ FROM complete_contact_tracking
+ WHERE last_heard >= datetime('now', '-24 hours')
+ AND role IS NOT NULL AND role != ''
+ GROUP BY role
+ """)
+ today_by_role = {}
+ for row in cursor.fetchall():
+ role = row[0].lower() if row[0] else 'unknown'
+ count = row[1]
+ today_by_role[role] = count
+
+ server_stats['nodes_24h_by_role'] = {
+ 'companion': today_by_role.get('companion', 0),
+ 'repeater': today_by_role.get('repeater', 0),
+ 'roomserver': today_by_role.get('roomserver', 0),
+ 'sensor': today_by_role.get('sensor', 0),
+ 'other': sum(v for k, v in today_by_role.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
+ }
+
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', '-7 days') AND date < date('now')
""")
server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
+ # Calculate day-over-day and period-over-period comparisons
+ # Today vs 7 days ago (single day comparison)
+ cursor.execute("""
+ SELECT COUNT(DISTINCT public_key) FROM daily_stats
+ WHERE date = date('now', '-7 days')
+ """)
+ result = cursor.fetchone()
+ server_stats['nodes_7d_ago'] = result[0] if result and result[0] else 0
+
+ # Last 7 days vs previous 7 days (days 8-14 ago)
+ cursor.execute("""
+ SELECT COUNT(DISTINCT public_key) FROM daily_stats
+ WHERE date >= date('now', '-14 days') AND date < date('now', '-7 days')
+ """)
+ result = cursor.fetchone()
+ server_stats['nodes_prev_7d'] = result[0] if result and result[0] else 0
+
+ # Last 30 days vs previous 30 days (days 31-60 ago)
+ cursor.execute("""
+ SELECT COUNT(DISTINCT public_key) FROM daily_stats
+ WHERE date >= date('now', '-60 days') AND date < date('now', '-30 days')
+ """)
+ result = cursor.fetchone()
+ server_stats['nodes_prev_30d'] = result[0] if result and result[0] else 0
+
+ # Also get current period totals for comparison
+ cursor.execute("""
+ SELECT COUNT(DISTINCT public_key) FROM daily_stats
+ WHERE date >= date('now', '-7 days')
+ """)
+ server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
+
+ cursor.execute("""
+ SELECT COUNT(DISTINCT public_key) FROM daily_stats
+ WHERE date >= date('now', '-30 days')
+ """)
+ server_stats['nodes_30d'] = cursor.fetchone()[0] or 0
+
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
""")
server_stats['nodes_all'] = cursor.fetchone()[0] or 0
+ # Get daily unique node counts by role for the last 30 days for the stacked graph
+ # Join daily_stats with complete_contact_tracking to get role information
+ # This gives us accurate historical daily counts by role
+ cursor.execute("""
+ SELECT ds.date, c.role, COUNT(DISTINCT ds.public_key) as daily_count
+ FROM daily_stats ds
+ LEFT JOIN complete_contact_tracking c ON ds.public_key = c.public_key
+ WHERE ds.date >= date('now', '-30 days') AND ds.date <= date('now')
+ AND (c.role IS NOT NULL AND c.role != '')
+ GROUP BY ds.date, c.role
+ ORDER BY ds.date ASC, c.role ASC
+ """)
+ daily_data_by_role = cursor.fetchall()
+
+ # Organize data by date and role
+ daily_by_role = {}
+ for row in daily_data_by_role:
+ date_str = row[0]
+ role = (row[1] or 'unknown').lower()
+ count = row[2]
+
+ if date_str not in daily_by_role:
+ daily_by_role[date_str] = {}
+ daily_by_role[date_str][role] = count
+
+ # Convert to array format with all roles for each date
+ server_stats['daily_nodes_30d_by_role'] = []
+ for date_str in sorted(daily_by_role.keys()):
+ roles_data = daily_by_role[date_str]
+ server_stats['daily_nodes_30d_by_role'].append({
+ 'date': date_str,
+ 'companion': roles_data.get('companion', 0),
+ 'repeater': roles_data.get('repeater', 0),
+ 'roomserver': roles_data.get('roomserver', 0),
+ 'sensor': roles_data.get('sensor', 0),
+ 'other': sum(v for k, v in roles_data.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
+ })
+
+ # Also keep the total count for backward compatibility
+ cursor.execute("""
+ SELECT date, COUNT(DISTINCT public_key) as daily_count
+ FROM daily_stats
+ WHERE date >= date('now', '-30 days') AND date <= date('now')
+ GROUP BY date
+ ORDER BY date ASC
+ """)
+ daily_data = cursor.fetchall()
+ server_stats['daily_nodes_30d'] = [
+ {'date': row[0], 'count': row[1]}
+ for row in daily_data
+ ]
+
except Exception as e:
self.logger.debug(f"Could not get server stats: {e}")
diff --git a/modules/web_viewer/templates/base.html b/modules/web_viewer/templates/base.html
index c9bf9b7..27f200f 100644
--- a/modules/web_viewer/templates/base.html
+++ b/modules/web_viewer/templates/base.html
@@ -193,6 +193,9 @@
+
+
+