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:
Lilac-Rose
2026-06-07 20:56:51 +02:00
parent e2e5ec6182
commit 4a06d80054
3 changed files with 138 additions and 33 deletions

View File

@@ -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))

View File

@@ -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):

View File

@@ -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):