Files
meshcore-bot/modules/commands/stats_command.py
agessaman 93c4004e2d feat: Add unique advert packet tracking and leaderboard functionality
- Introduced a new database table 'unique_advert_packets' for tracking unique advert packets by their hash.
- Enhanced the RepeaterManager to handle unique packet tracking during daily advertisement statistics.
- Updated StatsCommand to include a new subcommand for displaying the leaderboard of nodes with the most unique advert packets in the last 24 hours.
- Modified translations to support the new advert statistics feature, ensuring user-friendly command descriptions and error messages.
2026-01-18 14:16:04 -08:00

823 lines
37 KiB
Python

#!/usr/bin/env python3
"""
Stats command for the MeshCore Bot
Provides comprehensive statistics about bot usage, messages, and activity
"""
import time
import sqlite3
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from .base_command import BaseCommand
from ..models import MeshMessage
class StatsCommand(BaseCommand):
"""Handles the stats command with comprehensive data collection.
This command tracks usage statistics including messages, commands, and routing paths.
It provides insights into bot activity and network performance over the last 24 hours.
"""
# Plugin metadata
name = "stats"
keywords = ['stats']
description = "Show statistics for past 24 hours. Use 'stats messages', 'stats channels', 'stats paths', or 'stats adverts' for specific stats."
category = "analytics"
# Documentation
short_description = "Show bot usage statistics for past 24 hours"
usage = "stats [messages|channels|paths|adverts]"
examples = ["stats", "stats channels"]
parameters = [
{"name": "type", "description": "messages, channels, or paths (optional)"}
]
def __init__(self, bot: Any):
"""Initialize the stats command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self._load_config()
self._init_stats_tables()
def _load_config(self) -> None:
"""Load configuration settings for stats command."""
self.stats_enabled = self.get_config_value('Stats_Command', 'stats_enabled', fallback=True, value_type='bool')
self.data_retention_days = self.get_config_value('Stats_Command', 'data_retention_days', fallback=7, value_type='int')
self.auto_cleanup = self.get_config_value('Stats_Command', 'auto_cleanup', fallback=True, value_type='bool')
self.track_all_messages = self.get_config_value('Stats_Command', 'track_all_messages', fallback=True, value_type='bool')
self.track_command_details = self.get_config_value('Stats_Command', 'track_command_details', fallback=True, value_type='bool')
self.anonymize_users = self.get_config_value('Stats_Command', 'anonymize_users', fallback=False, value_type='bool')
def _init_stats_tables(self) -> None:
"""Initialize database tables for stats tracking.
Creates tables for message stats, command stats, and path stats if they
don't already exist. Also sets up necessary indexes for performance.
"""
try:
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Create message_stats table for tracking all messages
cursor.execute('''
CREATE TABLE IF NOT EXISTS message_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
sender_id TEXT NOT NULL,
channel TEXT,
content TEXT NOT NULL,
is_dm BOOLEAN NOT NULL,
hops INTEGER,
snr REAL,
rssi INTEGER,
path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create command_stats table for tracking bot commands
cursor.execute('''
CREATE TABLE IF NOT EXISTS command_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
sender_id TEXT NOT NULL,
command_name TEXT NOT NULL,
channel TEXT,
is_dm BOOLEAN NOT NULL,
response_sent BOOLEAN NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create path_stats table for tracking longest paths
cursor.execute('''
CREATE TABLE IF NOT EXISTS path_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
sender_id TEXT NOT NULL,
channel TEXT,
path_length INTEGER NOT NULL,
path_string TEXT NOT NULL,
hops INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create indexes for better performance
cursor.execute('CREATE INDEX IF NOT EXISTS idx_message_timestamp ON message_stats(timestamp)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_message_sender ON message_stats(sender_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_message_channel ON message_stats(channel)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_command_timestamp ON command_stats(timestamp)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_command_sender ON command_stats(sender_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_command_name ON command_stats(command_name)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_path_timestamp ON path_stats(timestamp)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_path_length ON path_stats(path_length)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_path_sender ON path_stats(sender_id)')
conn.commit()
self.logger.info("Stats tables initialized successfully")
except Exception as e:
self.logger.error(f"Failed to initialize stats tables: {e}")
raise
def record_message(self, message: MeshMessage) -> None:
"""Record a message in the stats database.
Args:
message: The message to record statistics for.
"""
if not self.stats_enabled or not self.track_all_messages:
return
try:
# Anonymize user if configured
sender_id = message.sender_id or 'unknown'
if self.anonymize_users and sender_id != 'unknown':
# Create a simple hash-based anonymization
import hashlib
sender_id = f"user_{hashlib.md5(sender_id.encode()).hexdigest()[:8]}"
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO message_stats
(timestamp, sender_id, channel, content, is_dm, hops, snr, rssi, path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
message.timestamp or int(time.time()),
sender_id,
message.channel,
message.content,
message.is_dm,
message.hops,
message.snr,
message.rssi,
message.path
))
conn.commit()
except Exception as e:
self.logger.error(f"Error recording message stats: {e}")
def record_command(self, message: MeshMessage, command_name: str, response_sent: bool = True) -> None:
"""Record a command execution in the stats database.
Args:
message: The message that triggered the command.
command_name: The name of the command executed.
response_sent: Whether a response was sent back to the user.
"""
if not self.stats_enabled or not self.track_command_details:
return
try:
# Anonymize user if configured
sender_id = message.sender_id or 'unknown'
if self.anonymize_users and sender_id != 'unknown':
# Create a simple hash-based anonymization
import hashlib
sender_id = f"user_{hashlib.md5(sender_id.encode()).hexdigest()[:8]}"
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO command_stats
(timestamp, sender_id, command_name, channel, is_dm, response_sent)
VALUES (?, ?, ?, ?, ?, ?)
''', (
message.timestamp or int(time.time()),
sender_id,
command_name,
message.channel,
message.is_dm,
response_sent
))
conn.commit()
except Exception as e:
self.logger.error(f"Error recording command stats: {e}")
def record_path_stats(self, message: MeshMessage) -> None:
"""Record path statistics for longest path tracking.
Args:
message: The message containing path information.
"""
if not self.stats_enabled or not self.track_all_messages:
return
# Only record if we have meaningful path data
if not message.hops or message.hops <= 0 or not message.path:
return
# Only record paths that contain actual node IDs (hex characters or comma-separated)
# Skip descriptive paths like "Routed through X hops"
if not self._is_valid_path_format(message.path):
return
try:
# Anonymize user if configured
sender_id = message.sender_id or 'unknown'
if self.anonymize_users and sender_id != 'unknown':
import hashlib
sender_id = f"user_{hashlib.md5(sender_id.encode()).hexdigest()[:8]}"
# Format the path string properly (e.g., "75,24,1d,5f,bd")
path_string = self._format_path_for_display(message.path)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO path_stats
(timestamp, sender_id, channel, path_length, path_string, hops)
VALUES (?, ?, ?, ?, ?, ?)
''', (
message.timestamp or int(time.time()),
sender_id,
message.channel,
message.hops, # Use hops as path length
path_string,
message.hops
))
conn.commit()
except Exception as e:
self.logger.error(f"Error recording path stats: {e}")
def _is_valid_path_format(self, path: str) -> bool:
"""Check if path contains actual node IDs rather than descriptive text.
Args:
path: The path string to validate.
Returns:
bool: True if the path structure appears valid, False otherwise.
"""
if not path:
return False
# If path contains spaces and common descriptive words, it's likely descriptive text
descriptive_words = ['routed', 'through', 'hops', 'direct', 'unknown', 'path']
path_lower = path.lower()
if any(word in path_lower for word in descriptive_words):
return False
# If path contains only hex characters and commas, it's valid
if all(c in '0123456789abcdefABCDEF,' for c in path):
return True
# If path is a single hex string without separators, it's valid
if all(c in '0123456789abcdefABCDEF' for c in path) and len(path) >= 2:
return True
return False
def _format_path_for_display(self, path: str) -> str:
"""Format path string for display (e.g., '75,24,1d,5f,bd').
Args:
path: The raw path string.
Returns:
str: The formatted path string.
"""
if not path:
return "Direct"
# If path already contains commas, it's likely already formatted
if ',' in path:
return path
# If path contains descriptive text (like "Routed through X hops"),
# extract just the numeric part or return as-is
if ' ' in path and not all(c in '0123456789abcdefABCDEF' for c in path.replace(' ', '')):
# This looks like descriptive text, return as-is
return path
# If path is a hex string without separators, add commas every 2 characters
# But only if it looks like a hex string (all hex characters)
if len(path) > 2 and ',' not in path and all(c in '0123456789abcdefABCDEF' for c in path):
# Split into 2-character chunks and join with commas
formatted = ','.join([path[i:i+2] for i in range(0, len(path), 2)])
return formatted
# If it's already a single node ID or short path, return as-is
return path
def get_help_text(self) -> str:
"""Get help text for the stats command.
Returns:
str: The help text for this command.
"""
return self.translate('commands.stats.help')
async def execute(self, message: MeshMessage) -> bool:
"""Execute the stats command.
Handles subcommands for messages, channels, and paths, or shows basic stats
if no subcommand is provided.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
if not self.stats_enabled:
await self.send_response(message, self.translate('commands.stats.disabled'))
return False
try:
# Perform automatic cleanup if enabled
if self.auto_cleanup:
self.cleanup_old_stats(self.data_retention_days)
# Parse command arguments
content = message.content.strip()
if content.startswith('!'):
content = content[1:].strip()
parts = content.split()
if len(parts) > 1:
subcommand = parts[1].lower()
if subcommand in ['messages', 'message']:
response = await self._get_bot_user_leaderboard()
elif subcommand in ['channels', 'channel']:
response = await self._get_channel_leaderboard()
elif subcommand in ['paths', 'path']:
response = await self._get_path_leaderboard(message)
elif subcommand in ['adverts', 'advert', 'advertisements', 'advertisement']:
# Check for verbose/hash option
show_hashes = len(parts) > 2 and parts[2].lower() in ['hash', 'hashes', 'verbose', 'verb']
response = await self._get_adverts_leaderboard(message, show_hashes=show_hashes)
else:
response = self.translate('commands.stats.unknown_subcommand', subcommand=subcommand)
else:
response = await self._get_basic_stats()
# Record this command execution
self.record_command(message, 'stats', True)
return await self.send_response(message, response)
except Exception as e:
self.logger.error(f"Error executing stats command: {e}")
await self.send_response(message, self.translate('commands.stats.error', error=str(e)))
return False
async def _get_basic_stats(self) -> str:
"""Get basic bot statistics.
Returns:
str: Formatted string containing basic statistics (commands, top user, etc.).
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
day_ago = now - (24 * 60 * 60)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Bot commands received
cursor.execute('''
SELECT COUNT(*) FROM command_stats
WHERE timestamp >= ?
''', (day_ago,))
commands_received = cursor.fetchone()[0]
# Bot replies sent
cursor.execute('''
SELECT COUNT(*) FROM command_stats
WHERE timestamp >= ? AND response_sent = 1
''', (day_ago,))
bot_replies = cursor.fetchone()[0]
# Top command
cursor.execute('''
SELECT command_name, COUNT(*) as count
FROM command_stats
WHERE timestamp >= ?
GROUP BY command_name
ORDER BY count DESC
LIMIT 1
''', (day_ago,))
top_command_result = cursor.fetchone()
if top_command_result:
top_command = f"{top_command_result[0]} ({top_command_result[1]})"
else:
top_command = self.translate('commands.stats.basic.none')
# Top user
cursor.execute('''
SELECT sender_id, COUNT(*) as count
FROM command_stats
WHERE timestamp >= ?
GROUP BY sender_id
ORDER BY count DESC
LIMIT 1
''', (day_ago,))
top_user_result = cursor.fetchone()
if top_user_result:
top_user = f"{top_user_result[0]} ({top_user_result[1]})"
else:
top_user = self.translate('commands.stats.basic.none')
response = f"""{self.translate('commands.stats.basic.header')}
{self.translate('commands.stats.basic.commands', count=commands_received, replies=bot_replies)}
{self.translate('commands.stats.basic.top_command', command=top_command)}
{self.translate('commands.stats.basic.top_user', user=top_user)}"""
return response
except Exception as e:
self.logger.error(f"Error getting basic stats: {e}")
return self.translate('commands.stats.error', error=str(e))
async def _get_bot_user_leaderboard(self) -> str:
"""Get leaderboard for bot users (people who triggered bot responses).
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
day_ago = now - (24 * 60 * 60)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Top bot users (people who triggered commands)
cursor.execute('''
SELECT sender_id, COUNT(*) as count
FROM command_stats
WHERE timestamp >= ?
GROUP BY sender_id
ORDER BY count DESC
LIMIT 5
''', (day_ago,))
top_users = cursor.fetchall()
# Build response
response = self.translate('commands.stats.users.header') + "\n"
if top_users:
for i, (user, count) in enumerate(top_users, 1):
display_user = user[:12] + "..." if len(user) > 15 else user
response += f"{i}. {display_user}: {count}\n"
else:
response += self.translate('commands.stats.users.none') + "\n"
return response
except Exception as e:
self.logger.error(f"Error getting bot user leaderboard: {e}")
return self.translate('commands.stats.error_bot_users', error=str(e))
async def _get_channel_leaderboard(self) -> str:
"""Get leaderboard for channel message activity.
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
day_ago = now - (24 * 60 * 60)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Top channels by message count with unique user counts
cursor.execute('''
SELECT channel, COUNT(*) as message_count, COUNT(DISTINCT sender_id) as unique_users
FROM message_stats
WHERE timestamp >= ? AND channel IS NOT NULL
GROUP BY channel
ORDER BY message_count DESC
LIMIT 5
''', (day_ago,))
top_channels = cursor.fetchall()
# Build compact response
response = self.translate('commands.stats.channels.header') + "\n"
if top_channels:
for i, (channel, msg_count, unique_users) in enumerate(top_channels, 1):
display_channel = channel[:12] + "..." if len(channel) > 15 else channel
# Handle singular/plural for messages and users
msg_text = self.translate('commands.stats.channels.msg_singular') if msg_count == 1 else self.translate('commands.stats.channels.msg_plural')
user_text = self.translate('commands.stats.channels.user_singular') if unique_users == 1 else self.translate('commands.stats.channels.user_plural')
response += self.translate('commands.stats.channels.format', rank=i, channel=display_channel, msg_count=msg_count, msg_text=msg_text, user_count=unique_users, user_text=user_text) + "\n"
# Remove trailing newline
response = response.rstrip('\n')
else:
response += self.translate('commands.stats.channels.none')
return response
except Exception as e:
self.logger.error(f"Error getting channel leaderboard: {e}")
return self.translate('commands.stats.error_channels', error=str(e))
async def _get_path_leaderboard(self, message: Optional[MeshMessage] = None) -> str:
"""Get leaderboard for longest paths seen.
Returns:
str: Formatted leaderboard string.
"""
try:
# Get time window (24 hours ago)
now = int(time.time())
day_ago = now - (24 * 60 * 60)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Top longest paths (one per user, more results with compact format)
cursor.execute('''
SELECT sender_id, path_length, path_string
FROM path_stats p1
WHERE timestamp >= ?
AND path_length = (
SELECT MAX(path_length)
FROM path_stats p2
WHERE p2.sender_id = p1.sender_id
AND p2.timestamp >= ?
)
GROUP BY sender_id
ORDER BY path_length DESC
LIMIT 8
''', (day_ago, day_ago))
longest_paths = cursor.fetchall()
# Build compact response with length checking
response = ""
max_length = self.get_max_message_length(message) if message else 130
if longest_paths:
for i, (sender, path_len, path_str) in enumerate(longest_paths, 1):
# Truncate sender name to fit more data
display_sender = sender[:8] + "..." if len(sender) > 11 else sender
# Compact format: "1 Gundam 56,1c,98,1a,aa,cd,5f"
new_line = self.translate('commands.stats.paths.format', rank=i, sender=display_sender, path=path_str) + "\n"
# Check if adding this line would exceed the limit
if len(response + new_line) > max_length:
break
response += new_line
else:
response = self.translate('commands.stats.paths.none') + "\n"
return response
except Exception as e:
self.logger.error(f"Error getting path leaderboard: {e}")
return self.translate('commands.stats.error_paths', error=str(e))
async def _get_adverts_leaderboard(self, message: Optional[MeshMessage] = None, show_hashes: bool = False) -> str:
"""Get leaderboard for nodes with most unique advert packets in last 24 hours.
Args:
message: Optional message for dynamic length calculation.
show_hashes: If True, include packet hashes in output.
Returns:
str: Formatted leaderboard string.
"""
try:
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Check if daily_stats table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='daily_stats'")
has_daily_stats = cursor.fetchone() is not None
if has_daily_stats:
if show_hashes:
# Query with packet hashes for validation
# First get the top nodes, then get their hashes
cursor.execute('''
SELECT
c.public_key,
c.name,
COALESCE(
(SELECT advert_count FROM daily_stats
WHERE date = DATE(c.last_advert_timestamp)
AND public_key = c.public_key), 0
) as unique_adverts
FROM complete_contact_tracking c
WHERE c.last_advert_timestamp >= datetime('now', '-24 hours')
GROUP BY c.public_key, c.name
HAVING unique_adverts > 0
ORDER BY unique_adverts DESC
LIMIT 20
''')
top_nodes = cursor.fetchall()
# Now get packet hashes for each node
top_adverts = []
for public_key, name, count in top_nodes:
# Get packet hashes for this node in the last 24 hours
cursor.execute('''
SELECT packet_hash
FROM unique_advert_packets
WHERE public_key = ?
AND first_seen >= datetime('now', '-24 hours')
ORDER BY first_seen
''', (public_key,))
hash_rows = cursor.fetchall()
packet_hashes = ', '.join([row[0] for row in hash_rows]) if hash_rows else None
top_adverts.append((public_key, name, count, packet_hashes))
else:
# Query nodes that advertised in the last 24 hours
# Get advert_count from daily_stats for the day of last_advert_timestamp
# This gives us the count of unique adverts for that day
cursor.execute('''
SELECT
c.public_key,
c.name,
COALESCE(
(SELECT advert_count FROM daily_stats
WHERE date = DATE(c.last_advert_timestamp)
AND public_key = c.public_key), 0
) as unique_adverts
FROM complete_contact_tracking c
WHERE c.last_advert_timestamp >= datetime('now', '-24 hours')
GROUP BY c.public_key, c.name
HAVING unique_adverts > 0
ORDER BY unique_adverts DESC
LIMIT 20
''')
top_adverts = cursor.fetchall()
else:
# Fallback: use advert_count from complete_contact_tracking
# This is less accurate but works if daily_stats doesn't exist
if show_hashes:
# Get nodes first
cursor.execute('''
SELECT public_key, name, advert_count as unique_adverts
FROM complete_contact_tracking
WHERE last_advert_timestamp >= datetime('now', '-24 hours')
ORDER BY advert_count DESC
LIMIT 20
''')
top_nodes = cursor.fetchall()
# Get packet hashes for each node
top_adverts = []
for public_key, name, count in top_nodes:
cursor.execute('''
SELECT packet_hash
FROM unique_advert_packets
WHERE public_key = ?
AND first_seen >= datetime('now', '-24 hours')
ORDER BY first_seen
''', (public_key,))
hash_rows = cursor.fetchall()
packet_hashes = ', '.join([row[0] for row in hash_rows]) if hash_rows else None
top_adverts.append((public_key, name, count, packet_hashes))
else:
cursor.execute('''
SELECT public_key, name, advert_count as unique_adverts
FROM complete_contact_tracking
WHERE last_advert_timestamp >= datetime('now', '-24 hours')
ORDER BY advert_count DESC
LIMIT 20
''')
top_adverts = cursor.fetchall()
# Build compact response with length checking
response = self.translate('commands.stats.adverts.header') + "\n"
max_length = self.get_max_message_length(message) if message else 130
if top_adverts:
for i, row in enumerate(top_adverts, 1):
if show_hashes:
public_key, name, count, packet_hashes = row
else:
public_key, name, count = row
packet_hashes = None
# Truncate name if needed
display_name = name[:15] + "..." if len(name) > 18 else name
# Format: "1. NodeName: 42 adverts"
advert_text = self.translate('commands.stats.adverts.advert_singular') if count == 1 else self.translate('commands.stats.adverts.advert_plural')
if show_hashes and packet_hashes:
# Format with packet hashes: "1. NodeName: 42 adverts\n Hashes: abc123, def456, ..."
main_line = self.translate('commands.stats.adverts.format',
rank=i,
name=display_name,
count=count,
advert_text=advert_text)
# Split packet hashes and format them
hash_list = [h.strip() for h in packet_hashes.split(',') if h.strip()]
# Show first few hashes (truncate if too many)
if len(hash_list) > 10:
hash_display = ', '.join(hash_list[:10]) + f" ... ({len(hash_list)} total)"
else:
hash_display = ', '.join(hash_list)
new_line = f"{main_line}\n {self.translate('commands.stats.adverts.hashes_label', hashes=hash_display)}"
else:
new_line = self.translate('commands.stats.adverts.format',
rank=i,
name=display_name,
count=count,
advert_text=advert_text) + "\n"
# Check if adding this line would exceed the limit
if len(response + new_line.rstrip('\n')) > max_length:
break
response += new_line
# Remove trailing newline
response = response.rstrip('\n')
else:
response += self.translate('commands.stats.adverts.none')
return response
except Exception as e:
self.logger.error(f"Error getting adverts leaderboard: {e}")
return self.translate('commands.stats.error_adverts', error=str(e))
def cleanup_old_stats(self, days_to_keep: int = 7) -> None:
"""Clean up old stats data to prevent database bloat.
Args:
days_to_keep: Number of days of data to retain.
"""
try:
cutoff_time = int(time.time()) - (days_to_keep * 24 * 60 * 60)
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Clean up old message stats
cursor.execute('DELETE FROM message_stats WHERE timestamp < ?', (cutoff_time,))
messages_deleted = cursor.rowcount
# Clean up old command stats
cursor.execute('DELETE FROM command_stats WHERE timestamp < ?', (cutoff_time,))
commands_deleted = cursor.rowcount
# Clean up old path stats
cursor.execute('DELETE FROM path_stats WHERE timestamp < ?', (cutoff_time,))
paths_deleted = cursor.rowcount
conn.commit()
total_deleted = messages_deleted + commands_deleted + paths_deleted
if total_deleted > 0:
self.logger.info(f"Cleaned up {total_deleted} old stats entries ({messages_deleted} messages, {commands_deleted} commands, {paths_deleted} paths)")
except Exception as e:
self.logger.error(f"Error cleaning up old stats: {e}")
def get_stats_summary(self) -> Dict[str, Any]:
"""Get a summary of all stats data.
Returns:
Dict[str, Any]: Dictionary containing summary statistics.
"""
try:
with sqlite3.connect(self.bot.db_manager.db_path) as conn:
cursor = conn.cursor()
# Total messages
cursor.execute('SELECT COUNT(*) FROM message_stats')
total_messages = cursor.fetchone()[0]
# Total commands
cursor.execute('SELECT COUNT(*) FROM command_stats')
total_commands = cursor.fetchone()[0]
# Unique users
cursor.execute('SELECT COUNT(DISTINCT sender_id) FROM message_stats')
unique_users = cursor.fetchone()[0]
# Unique channels
cursor.execute('SELECT COUNT(DISTINCT channel) FROM message_stats WHERE channel IS NOT NULL')
unique_channels = cursor.fetchone()[0]
return {
'total_messages': total_messages,
'total_commands': total_commands,
'unique_users': unique_users,
'unique_channels': unique_channels
}
except Exception as e:
self.logger.error(f"Error getting stats summary: {e}")
return {}