mirror of
https://github.com/Lilac-Rose/Lacie.git
synced 2026-08-27 19:24:17 -05:00
feat: word stats pagination, reminder delivery fix, noping permitted
- /stats words now paginates (20 per page, two columns) using the same LeaderboardView pattern as /xp top; no longer capped at 10 results - Remove common word filter from word frequency recording so all words (including 'the', 'and', etc.) are now tracked going forward - Fix reminders silently disappearing: get_user() only checks cache, so added fetch_user() fallback for uncached users - Reminders are now deleted AFTER a successful DM send, not before, preventing data loss on transient errors (retries next 60s cycle) - Add /noping permitted command: shows which users have added you to their ping allowlist (i.e. who has given you permission to ping them)
This commit is contained in:
@@ -151,6 +151,22 @@ class PingProtect(commands.GroupCog, name="noping"):
|
||||
mentions = "\n".join(f"<@{row[0]}>" for row in rows)
|
||||
await interaction.response.send_message(f"**Your ping allowlist:**\n{mentions}", ephemeral=True)
|
||||
|
||||
@app_commands.command(name="permitted", description="See who has given you permission to ping them")
|
||||
async def permitted(self, interaction: discord.Interaction):
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT protected_user_id FROM allowlists WHERE allowed_user_id = ?",
|
||||
(interaction.user.id,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
await interaction.response.send_message("Nobody has given you permission to ping them yet.", ephemeral=True)
|
||||
return
|
||||
|
||||
mentions = "\n".join(f"<@{row[0]}>" for row in rows)
|
||||
await interaction.response.send_message(f"**Users who've allowed you to ping them:**\n{mentions}", ephemeral=True)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(PingProtect(bot))
|
||||
|
||||
@@ -299,17 +299,15 @@ class ReminderCog(commands.Cog):
|
||||
) as cursor:
|
||||
reminders_due = await cursor.fetchall()
|
||||
|
||||
if reminders_due:
|
||||
ids = [r[0] for r in reminders_due]
|
||||
await db.execute(
|
||||
f"DELETE FROM reminders WHERE id IN ({','.join('?' * len(ids))})",
|
||||
ids,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
sent_ids = []
|
||||
for reminder_id, user_id, message in reminders_due:
|
||||
decrypted = decrypt(message)
|
||||
user = self.bot.get_user(user_id)
|
||||
if user is None:
|
||||
try:
|
||||
user = await self.bot.fetch_user(user_id)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
user = None
|
||||
if user:
|
||||
try:
|
||||
embed = discord.Embed(
|
||||
@@ -319,8 +317,23 @@ class ReminderCog(commands.Cog):
|
||||
timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
await user.send(embed=embed)
|
||||
except (discord.Forbidden, discord.HTTPException):
|
||||
pass
|
||||
sent_ids.append(reminder_id)
|
||||
except discord.Forbidden:
|
||||
# Can't DM the user — still remove to avoid repeat attempts
|
||||
sent_ids.append(reminder_id)
|
||||
except discord.HTTPException:
|
||||
pass # Transient error — retry next cycle
|
||||
else:
|
||||
# User not found — remove to avoid piling up
|
||||
sent_ids.append(reminder_id)
|
||||
|
||||
if sent_ids:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
f"DELETE FROM reminders WHERE id IN ({','.join('?' * len(sent_ids))})",
|
||||
sent_ids,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@check_reminders.before_loop
|
||||
async def before_check_reminders(self):
|
||||
|
||||
122
stats/stats.py
122
stats/stats.py
@@ -1,8 +1,10 @@
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
from discord import app_commands
|
||||
from discord.ui import View, Button
|
||||
from datetime import datetime, timezone
|
||||
import sqlite3
|
||||
import math
|
||||
from embed.embed_color import get_embed_color
|
||||
import os
|
||||
import json
|
||||
@@ -19,6 +21,45 @@ logger = get_logger(__name__)
|
||||
BASE_DIR = Path(__file__).parent
|
||||
DB_PATH = Path(__file__).parent.parent / "data" / "stats.db"
|
||||
|
||||
|
||||
class WordsView(View):
|
||||
def __init__(self, embed_pages):
|
||||
super().__init__(timeout=60)
|
||||
self.embed_pages = embed_pages
|
||||
self.current_page = 0
|
||||
self.update_button_states()
|
||||
|
||||
async def update_message(self, interaction: discord.Interaction):
|
||||
self.update_button_states()
|
||||
await interaction.response.edit_message(embed=self.embed_pages[self.current_page], view=self)
|
||||
|
||||
def update_button_states(self):
|
||||
self.previous.disabled = self.current_page == 0
|
||||
self.next.disabled = self.current_page == len(self.embed_pages) - 1
|
||||
|
||||
@discord.ui.button(label="⬅️ Previous", style=discord.ButtonStyle.secondary)
|
||||
async def previous(self, interaction: discord.Interaction, button: Button):
|
||||
if self.current_page > 0:
|
||||
self.current_page -= 1
|
||||
await self.update_message(interaction)
|
||||
|
||||
@discord.ui.button(label="➡️ Next", style=discord.ButtonStyle.secondary)
|
||||
async def next(self, interaction: discord.Interaction, button: Button):
|
||||
if self.current_page < len(self.embed_pages) - 1:
|
||||
self.current_page += 1
|
||||
await self.update_message(interaction)
|
||||
|
||||
async def on_timeout(self):
|
||||
for child in self.children:
|
||||
if isinstance(child, Button):
|
||||
child.disabled = True
|
||||
if hasattr(self, "message") and self.message:
|
||||
try:
|
||||
await self.message.edit(view=self)
|
||||
except discord.NotFound:
|
||||
pass
|
||||
|
||||
|
||||
class Stats(commands.Cog):
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
@@ -152,7 +193,7 @@ class Stats(commands.Cog):
|
||||
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:
|
||||
if len(word) > 2:
|
||||
cursor.execute("""
|
||||
INSERT INTO word_frequency (word, count)
|
||||
VALUES (?, 1)
|
||||
@@ -242,15 +283,22 @@ class Stats(commands.Cog):
|
||||
return round(total_messages / days_count, 2)
|
||||
return 0.0
|
||||
|
||||
def get_top_words(self, limit: int = 10) -> List[Tuple[str, int]]:
|
||||
def get_top_words(self, limit: int = 0) -> 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}
|
||||
""")
|
||||
if limit > 0:
|
||||
cursor.execute(f"""
|
||||
SELECT word, count
|
||||
FROM word_frequency
|
||||
ORDER BY count DESC
|
||||
LIMIT {limit}
|
||||
""")
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT word, count
|
||||
FROM word_frequency
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
results = cursor.fetchall()
|
||||
db.close()
|
||||
return results
|
||||
@@ -445,26 +493,54 @@ class Stats(commands.Cog):
|
||||
|
||||
@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)
|
||||
all_words = self.get_top_words()
|
||||
|
||||
embed = discord.Embed(
|
||||
title="🔤 Word Frequency Statistics",
|
||||
color=get_embed_color(interaction.user.id)
|
||||
)
|
||||
if not all_words:
|
||||
embed = discord.Embed(
|
||||
title="🔤 Word Frequency Statistics",
|
||||
description="No word frequency data available yet. Start chatting to build up statistics!",
|
||||
color=get_embed_color(interaction.user.id)
|
||||
)
|
||||
await interaction.response.send_message(embed=embed)
|
||||
return
|
||||
|
||||
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:])])
|
||||
per_page = 20
|
||||
total_pages = math.ceil(len(all_words) / per_page)
|
||||
embeds = []
|
||||
|
||||
for page_num in range(total_pages):
|
||||
start_idx = page_num * per_page
|
||||
page_words = all_words[start_idx:start_idx + per_page]
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"🔤 Word Frequency Statistics (Page {page_num + 1}/{total_pages})",
|
||||
color=get_embed_color(interaction.user.id)
|
||||
)
|
||||
|
||||
half = math.ceil(len(page_words) / 2)
|
||||
left_column = "\n".join(
|
||||
f"{start_idx + i + 1}. **{word}** - {count}"
|
||||
for i, (word, count) in enumerate(page_words[:half])
|
||||
)
|
||||
right_column = "\n".join(
|
||||
f"{start_idx + half + i + 1}. **{word}** - {count}"
|
||||
for i, (word, count) in enumerate(page_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!"
|
||||
if right_column:
|
||||
embed.add_field(name="\u200b", value=right_column, inline=True)
|
||||
|
||||
embed.set_footer(text="Common words like 'the', 'and', etc. are excluded • Tracking since December 1st 2025")
|
||||
await interaction.response.send_message(embed=embed)
|
||||
embed.set_footer(text=f"Tracking since December 1st 2025 • {len(all_words):,} unique words tracked")
|
||||
embeds.append(embed)
|
||||
|
||||
if total_pages == 1:
|
||||
await interaction.response.send_message(embed=embeds[0])
|
||||
return
|
||||
|
||||
view = WordsView(embeds)
|
||||
await interaction.response.send_message(embed=embeds[0], view=view)
|
||||
view.message = await interaction.original_response()
|
||||
|
||||
@stats_group.command(name="channels", description="Show most active channels")
|
||||
async def stats_channels(self, interaction: discord.Interaction):
|
||||
|
||||
Reference in New Issue
Block a user