Lacie/stats/stats.py
Lilac-Rose a990cb0b2e feat: add April Fools Johnification, giveaway command, and misc fixes
- events/april_fools.py: add /johnify and /unjohnify admin commands;
  renames all channels/roles to John-themed names and auto-reverts at
  12pm EDT April 2nd, with backup persisted to survive bot restarts
- commands/giveaway.py: new giveaway command
- events/git_webhook.py: truncate commit messages >1024 chars to avoid
  embed field overflow
- events/daily_fractal.py: change daily post time from 12:00 to 10:00 UTC
- stats/stats.py: replace serverCount/botUsers with memberCount, restrict
  command/interaction tracking to the configured guild only
- bot.py: add "arg" cog folder, exclude constants.py from cog loading
2026-04-01 19:03:55 +02:00

493 lines
20 KiB
Python

import discord
from discord.ext import commands
from discord import app_commands
from datetime import datetime, timezone
import sqlite3
from embed.embed_color import get_embed_color
import os
import json
from pathlib import Path
import asyncio
import aiofiles
import re
from collections import Counter
from typing import Dict, List, Tuple
from utils.logger import get_logger
logger = get_logger(__name__)
BASE_DIR = Path(__file__).parent
DB_PATH = Path(__file__).parent.parent / "data" / "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'
}
self.init_db()
self.stats_file = Path(__file__).parent.parent / "data" / "bot_stats.json"
async def cog_load(self):
asyncio.create_task(self.update_stats_file())
def init_db(self):
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
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)")
cursor.execute("""
CREATE TABLE IF NOT EXISTS per_command_usage (
command_name TEXT PRIMARY KEY,
count INTEGER DEFAULT 0
)
""")
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, command_name: str = None):
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("UPDATE command_usage SET total = total + 1 WHERE id=1")
if command_name:
cursor.execute("""
INSERT INTO per_command_usage (command_name, count)
VALUES (?, 1)
ON CONFLICT(command_name) DO UPDATE SET count = count + 1
""", (command_name,))
db.commit()
db.close()
def get_per_command_usage(self) -> List[Tuple[str, int]]:
db = sqlite3.connect(DB_PATH)
cursor = db.cursor()
cursor.execute("SELECT command_name, count FROM per_command_usage ORDER BY count DESC")
results = cursor.fetchall()
db.close()
return results
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
async def record_message(self, message: discord.Message):
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()
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()))
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,))
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)
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:
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]:
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]]:
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:
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]]:
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:
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
async def cleanup_old_stats_files(self):
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)
if file_path != self.stats_file:
os.remove(file_path)
logger.info(f"Removed old stats file: {filename}")
except Exception as e:
logger.error(f"Error cleaning up old stats files: {e}", exc_info=True)
async def update_stats_file(self):
await self.bot.wait_until_ready()
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:
logger.error(f"Error updating stats file: {e}", exc_info=True)
await asyncio.sleep(30) # Update every 30 seconds
async def gather_stats(self):
guild = self.bot.get_guild(self.guild_id)
if not guild:
return {'error': 'Guild not found', 'guild_id': self.guild_id}
uptime = datetime.now(timezone.utc) - self.bot.start_time
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
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)])
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)
per_command = self.get_per_command_usage()
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(),
'memberCount': guild.member_count,
'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]
},
'commandStats': { # Tracking since 27th March 2026
'perCommand': [{'command': name, 'count': count} for name, count in per_command]
}
}
@commands.Cog.listener()
async def on_command(self, ctx):
if ctx.guild is None or ctx.guild.id != self.guild_id:
return
self.increment_usage(ctx.command.qualified_name if ctx.command else None)
@commands.Cog.listener()
async def on_interaction(self, interaction: discord.Interaction):
# Only count application commands (slash commands) from the tracked guild
if interaction.guild_id != self.guild_id:
return
if interaction.type == discord.InteractionType.application_command:
name = interaction.data.get("name") if interaction.data else None
self.increment_usage(name)
@commands.Cog.listener()
async def on_message(self, message):
await self.record_message(message)
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
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 = self.get_usage()
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 = discord.Embed(
title=f"🌟 {guild.name} Statistics 🌟",
color=get_embed_color(interaction.user.id)
)
if guild.icon:
embed.set_thumbnail(url=guild.icon.url)
if guild.banner:
embed.set_image(url=guild.banner.url)
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)
embed.add_field(
name="🤖 Bot Stats",
value=(
f"**Developer:** Lilac Aria Rose\n"
f"**Uptime:** {uptime_str}\n"
f"**Total Commands Used:** {total_commands}"
),
inline=False
)
embed.set_footer(text=f"Server ID: {guild.id} • Tracking total commands since October 25th 2025")
await interaction.response.send_message(embed=embed)
@stats_group.command(name="messages", description="Show message statistics")
async def stats_messages(self, interaction: discord.Interaction):
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=get_embed_color(interaction.user.id)
)
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 • Tracking since December 1st 2025")
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):
top_words = self.get_top_words(10)
embed = discord.Embed(
title="🔤 Word Frequency Statistics",
color=get_embed_color(interaction.user.id)
)
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 • Tracking since December 1st 2025")
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):
top_channels = self.get_top_channels(10)
embed = discord.Embed(
title="📊 Most Active Channels",
color=get_embed_color(interaction.user.id)
)
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. Start chatting to build up statistics!"
total_messages = self.get_total_messages()
embed.set_footer(text=f"Total messages tracked: {total_messages} • Tracking since December 1st 2025")
await interaction.response.send_message(embed=embed)
async def setup(bot: commands.Bot):
await bot.add_cog(Stats(bot))