Lacie/stats/stats.py

515 lines
20 KiB
Python

import discord
from discord.ext import commands
from discord import app_commands
from datetime import datetime, timezone
import sqlite3
import os
import json
import asyncio
import aiofiles
import re
from collections import Counter
from typing import Dict, List, Tuple
BASE_DIR = os.path.dirname(__file__)
DB_PATH = os.path.join(BASE_DIR, "stats.db")
class Stats(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.guild_id = 876772600704020530
# Bot start time
if not hasattr(self.bot, "start_time"):
self.bot.start_time = datetime.now(timezone.utc)
# Common words to exclude from word frequency analysis
self.common_words = {
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of',
'with', 'by', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'must', 'can', 'it', 'its', 'it\'s', 'i',
'you', 'he', 'she', 'they', 'we', 'me', 'him', 'her', 'them', 'us',
'my', 'your', 'his', 'her', 'their', 'our', 'this', 'that', 'these',
'those', 'am', 'not', 'so', 'too', 'very', 'just', 'now', 'then',
'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both',
'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor',
'only', 'own', 'same', 'than', 'too', 'very', 's', 't', 'don', 'don\'t', 'like',
'pop',
# URL-related words
'http', 'https', 'www', 'com', 'org', 'net', 'io', 'gif', 'png', 'jpg',
'jpeg', 'tenor', 'giphy', 'imgur', 'discord', 'cdn', 'media', 'attachments'
}
# Initialize the database
self.init_db()
# Stats file path (same directory as stats.py)
self.stats_file = os.path.join(BASE_DIR, "bot_stats.json")
# Start background task for stats file
self.bot.loop.create_task(self.update_stats_file())
# -----------------------------
# Database methods
# -----------------------------
def init_db(self):
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
# Command usage table
cursor.execute(
"CREATE TABLE IF NOT EXISTS command_usage (id INTEGER PRIMARY KEY, total INTEGER)"
)
cursor.execute("SELECT total FROM command_usage WHERE id=1")
if cursor.fetchone() is None:
cursor.execute("INSERT INTO command_usage (id, total) VALUES (1, 0)")
# Message statistics tables
cursor.execute("""
CREATE TABLE IF NOT EXISTS message_stats (
channel_id INTEGER PRIMARY KEY,
message_count INTEGER DEFAULT 0,
last_updated TEXT
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS daily_messages (
date TEXT PRIMARY KEY,
message_count INTEGER DEFAULT 0
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS word_frequency (
word TEXT PRIMARY KEY,
count INTEGER DEFAULT 0
)
""")
db.commit()
db.close()
def increment_usage(self):
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("UPDATE command_usage SET total = total + 1 WHERE id=1")
db.commit()
db.close()
def get_usage(self):
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("SELECT total FROM command_usage WHERE id=1")
total = cursor.fetchone()[0]
db.close()
return total
# -----------------------------
# Message statistics methods
# -----------------------------
async def record_message(self, message: discord.Message):
"""Record message statistics"""
if message.guild is None or message.guild.id != self.guild_id:
return
if message.author.bot:
return
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
# Update channel message count
cursor.execute("""
INSERT INTO message_stats (channel_id, message_count, last_updated)
VALUES (?, 1, ?)
ON CONFLICT(channel_id) DO UPDATE SET
message_count = message_count + 1,
last_updated = ?
""", (message.channel.id, datetime.now(timezone.utc).isoformat(), datetime.now(timezone.utc).isoformat()))
# Update daily message count
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
cursor.execute("""
INSERT INTO daily_messages (date, message_count)
VALUES (?, 1)
ON CONFLICT(date) DO UPDATE SET
message_count = message_count + 1
""", (today,))
# Update word frequency (only for messages with content)
if message.content:
words = self.extract_words(message.content)
for word in words:
if len(word) > 2 and word.lower() not in self.common_words:
cursor.execute("""
INSERT INTO word_frequency (word, count)
VALUES (?, 1)
ON CONFLICT(word) DO UPDATE SET
count = count + 1
""", (word.lower(),))
db.commit()
db.close()
def extract_words(self, text: str) -> List[str]:
"""Extract words from text, removing mentions, emojis, and URLs"""
# Remove URLs first (more aggressive pattern)
text = re.sub(r'https?://\S+', '', text)
text = re.sub(r'www\.\S+', '', text)
# Remove mentions
text = re.sub(r'<@!?&?\d+>', '', text)
# Remove channels
text = re.sub(r'<#\d+>', '', text)
# Remove emojis
text = re.sub(r'<:\w+:\d+>', '', text)
# Remove standalone domain-like patterns (e.g., "tenor.com", "giphy.gif")
text = re.sub(r'\b\w+\.(com|org|net|io|gif|png|jpg|jpeg)\b', '', text, flags=re.IGNORECASE)
# Remove punctuation
text = re.sub(r'[^\w\s]', ' ', text)
# Extract words
words = re.findall(r'\b\w+\b', text.lower())
return [word for word in words if len(word) > 2 and not self.is_url_segment(word)]
def is_url_segment(self, word: str) -> bool:
"""Check if a word is likely part of a URL"""
# Check if word contains common URL patterns
url_patterns = [
r'^\d+$', # Pure numbers (often IDs in URLs)
r'^[a-f0-9]{8,}$', # Hex strings (often hashes in URLs)
]
for pattern in url_patterns:
if re.match(pattern, word):
return True
return False
def get_most_active_channel(self) -> Tuple[str, int]:
"""Get the most active channel and its message count"""
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("""
SELECT channel_id, message_count
FROM message_stats
ORDER BY message_count DESC
LIMIT 1
""")
result = cursor.fetchone()
db.close()
if result:
channel_id, count = result
channel = self.bot.get_channel(channel_id)
channel_name = f"#{channel.name}" if channel else f"Channel {channel_id}"
return channel_name, count
return "No data", 0
def get_top_channels(self, limit: int = 10) -> List[Tuple[str, int]]:
"""Get top N most active channels"""
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute(f"""
SELECT channel_id, message_count
FROM message_stats
ORDER BY message_count DESC
LIMIT {limit}
""")
results = cursor.fetchall()
db.close()
channel_stats = []
for channel_id, count in results:
channel = self.bot.get_channel(channel_id)
channel_name = f"#{channel.name}" if channel else f"Unknown Channel"
channel_stats.append((channel_name, count))
return channel_stats
def get_average_messages_per_day(self) -> float:
"""Calculate average messages per day"""
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("SELECT SUM(message_count), COUNT(DISTINCT date) FROM daily_messages")
result = cursor.fetchone()
db.close()
if result and result[1] > 0:
total_messages, days_count = result
return round(total_messages / days_count, 2)
return 0.0
def get_top_words(self, limit: int = 10) -> List[Tuple[str, int]]:
"""Get top N most common words"""
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute(f"""
SELECT word, count
FROM word_frequency
ORDER BY count DESC
LIMIT {limit}
""")
results = cursor.fetchall()
db.close()
return results
def get_total_messages(self) -> int:
"""Get total message count across all channels"""
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("SELECT SUM(message_count) FROM message_stats")
result = cursor.fetchone()
db.close()
return result[0] if result and result[0] else 0
# -----------------------------
# Stats file management
# -----------------------------
async def cleanup_old_stats_files(self):
"""Remove any old stats files to prevent accumulation"""
try:
stats_dir = BASE_DIR
for filename in os.listdir(stats_dir):
if filename.startswith("bot_stats") and filename.endswith(".json"):
file_path = os.path.join(stats_dir, filename)
# Keep only the current stats file
if file_path != self.stats_file:
os.remove(file_path)
print(f"Removed old stats file: {filename}")
except Exception as e:
print(f"Error cleaning up old stats files: {e}")
async def update_stats_file(self):
"""Update stats JSON file every 30 seconds"""
await self.bot.wait_until_ready()
# Clean up old files on startup
await self.cleanup_old_stats_files()
while not self.bot.is_closed():
try:
stats = await self.gather_stats()
async with aiofiles.open(self.stats_file, 'w') as f:
await f.write(json.dumps(stats, indent=2, default=str))
except Exception as e:
print(f"Error updating stats file: {e}")
await asyncio.sleep(30) # Update every 30 seconds
async def gather_stats(self):
"""Gather real-time statistics"""
guild = self.bot.get_guild(self.guild_id)
if not guild:
return {'error': 'Guild not found', 'guild_id': self.guild_id}
# Calculate uptime
uptime = datetime.now(timezone.utc) - self.bot.start_time
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
# Count channels
text_channels = len([c for c in guild.channels if isinstance(c, discord.TextChannel)])
voice_channels = len([c for c in guild.channels if isinstance(c, discord.VoiceChannel)])
categories = len([c for c in guild.channels if isinstance(c, discord.CategoryChannel)])
# Get message statistics
most_active_channel, channel_msg_count = self.get_most_active_channel()
avg_messages_per_day = self.get_average_messages_per_day()
total_messages = self.get_total_messages()
top_words = self.get_top_words(10)
return {
'server': {
'memberCount': guild.member_count,
'createdDate': guild.created_at.strftime('%b %d, %Y'),
'textChannels': text_channels,
'voiceChannels': voice_channels,
'categories': categories,
'boostLevel': guild.premium_tier,
'boostCount': guild.premium_subscription_count,
'iconUrl': str(guild.icon.url) if guild.icon else None,
'bannerUrl': str(guild.banner.url) if guild.banner else None,
'guildName': guild.name
},
'bot': {
'uptime': f"{hours}h {minutes}m {seconds}s",
'totalCommands': self.get_usage(),
'serverCount': len(self.bot.guilds),
'botUsers': len(self.bot.users),
'latency': round(self.bot.latency * 1000),
'developer': 'Lilac Aria Rose',
'lastUpdated': datetime.now(timezone.utc).isoformat()
},
'messageStats': {
'totalMessages': total_messages,
'mostActiveChannel': {
'name': most_active_channel,
'messageCount': channel_msg_count
},
'averageMessagesPerDay': avg_messages_per_day,
'topWords': [{'word': word, 'count': count} for word, count in top_words]
}
}
# -----------------------------
# Discord event listeners
# -----------------------------
@commands.Cog.listener()
async def on_command(self, ctx):
self.increment_usage()
@commands.Cog.listener()
async def on_interaction(self, interaction: discord.Interaction):
# Only count application commands (slash commands)
if interaction.type == discord.InteractionType.application_command:
self.increment_usage()
@commands.Cog.listener()
async def on_message(self, message):
await self.record_message(message)
# -----------------------------
# Discord slash command group
# -----------------------------
stats_group = app_commands.Group(name="stats", description="Server and bot statistics")
@stats_group.command(name="server", description="Shows server and bot statistics")
async def stats_server(self, interaction: discord.Interaction):
guild = interaction.guild
# Bot uptime
uptime = datetime.now(timezone.utc) - self.bot.start_time
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
uptime_str = f"{hours}h {minutes}m {seconds}s"
# Total commands
total_commands = self.get_usage()
# Count channels separately
text_channels = len([c for c in guild.channels if isinstance(c, discord.TextChannel)])
voice_channels = len([c for c in guild.channels if isinstance(c, discord.VoiceChannel)])
categories = len([c for c in guild.channels if isinstance(c, discord.CategoryChannel)])
# Embed
embed = discord.Embed(
title=f"🌟 {guild.name} Statistics 🌟",
color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user),
timestamp=datetime.now(timezone.utc)
)
if guild.icon:
embed.set_thumbnail(url=guild.icon.url)
if guild.banner:
embed.set_image(url=guild.banner.url)
# Server stats grid
embed.add_field(name="👥 Members", value=f"**{guild.member_count}**", inline=True)
embed.add_field(name="📅 Created On", value=f"**{guild.created_at.strftime('%b %d, %Y')}**", inline=True)
embed.add_field(name="💬 Text Channels", value=f"**{text_channels}**", inline=True)
embed.add_field(name="🔊 Voice Channels", value=f"**{voice_channels}**", inline=True)
embed.add_field(name="📂 Categories", value=f"**{categories}**", inline=True)
embed.add_field(name="🚀 Boost Level", value=f"**{guild.premium_tier}**", inline=True)
embed.add_field(name="✨ Boosts", value=f"**{guild.premium_subscription_count}**", inline=True)
embed.add_field(name="\u200b", value="\u200b", inline=False)
# Bot stats
embed.add_field(
name="🤖 Bot Stats",
value=(
f"**Developer:** Lilac Aria Rose\n"
f"**Uptime:** {uptime_str}\n"
f"**Total Commands Used:** {total_commands}\n"
f"**Servers:** {len(self.bot.guilds)}\n"
f"**Bot Users:** {len(self.bot.users)}"
),
inline=False
)
embed.set_footer(text=f"Server ID: {guild.id}")
await interaction.response.send_message(embed=embed)
@stats_group.command(name="messages", description="Show message statistics")
async def stats_messages(self, interaction: discord.Interaction):
"""Show detailed message statistics"""
most_active_channel, channel_msg_count = self.get_most_active_channel()
avg_messages_per_day = self.get_average_messages_per_day()
total_messages = self.get_total_messages()
embed = discord.Embed(
title="📊 Message Statistics",
color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user),
timestamp=datetime.now(timezone.utc)
)
# Overall stats
embed.add_field(
name="Overall Statistics",
value=(
f"**Total Messages:** {total_messages:,}\n"
f"**Average Messages/Day:** {avg_messages_per_day}\n"
f"**Most Active Channel:** {most_active_channel}\n"
f"**Messages in Most Active:** {channel_msg_count:,}"
),
inline=False
)
embed.set_footer(text="Use /stats channels for channel breakdown")
await interaction.response.send_message(embed=embed)
@stats_group.command(name="words", description="Show detailed word frequency statistics")
async def stats_words(self, interaction: discord.Interaction):
"""Show top 10 most common words"""
top_words = self.get_top_words(10)
embed = discord.Embed(
title="🔤 Word Frequency Statistics",
color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user),
timestamp=datetime.now(timezone.utc)
)
if top_words:
# Split into two columns for better readability
half = len(top_words) // 2
left_column = "\n".join([f"{i+1}. **{word}** - {count}" for i, (word, count) in enumerate(top_words[:half])])
right_column = "\n".join([f"{i+half+1}. **{word}** - {count}" for i, (word, count) in enumerate(top_words[half:])])
embed.add_field(name="Most Common Words", value=left_column, inline=True)
embed.add_field(name="\u200b", value=right_column, inline=True)
else:
embed.description = "No word frequency data available yet. Start chatting to build up statistics!"
embed.set_footer(text="Common words like 'the', 'and', etc. are excluded")
await interaction.response.send_message(embed=embed)
@stats_group.command(name="channels", description="Show most active channels")
async def stats_channels(self, interaction: discord.Interaction):
"""Show top 10 most active channels"""
top_channels = self.get_top_channels(10)
embed = discord.Embed(
title="📊 Most Active Channels",
color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user),
timestamp=datetime.now(timezone.utc)
)
if top_channels:
# Split into two columns
half = len(top_channels) // 2
left_column = "\n".join([f"{i+1}. **{channel}** - {count} msgs" for i, (channel, count) in enumerate(top_channels[:half])])
right_column = "\n".join([f"{i+half+1}. **{channel}** - {count} msgs" for i, (channel, count) in enumerate(top_channels[half:])])
embed.add_field(name="Top Channels", value=left_column, inline=True)
if right_column:
embed.add_field(name="\u200b", value=right_column, inline=True)
else:
embed.description = "No channel activity data available yet."
total_messages = self.get_total_messages()
embed.set_footer(text=f"Total messages tracked: {total_messages}")
await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot):
await bot.add_cog(Stats(bot))