Added /forgetme slash command with atomic user data deletion, confirmation UI, and Prometheus metrics.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 23s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 5s

This commit is contained in:
2026-03-23 10:06:55 -04:00
parent ca3011011a
commit 0fa51d6199
6 changed files with 238 additions and 3 deletions
+2 -1
View File
@@ -30,7 +30,6 @@ from discord.ext import commands
from crabstero import __version__ as crabstero_version
from crabstero.cache import IngestCache
from crabstero.database import Database
from crabstero.listeners import interaction, message, server_events
from crabstero.metrics import (
DISCORD_EVENTS,
DISCORD_LATENCY,
@@ -124,6 +123,8 @@ class Crabstero(commands.Bot):
await server.start()
self._metrics_server = server
from crabstero.listeners import interaction, message, server_events
if not self._ingest_only:
await interaction.setup(self)
+34
View File
@@ -410,3 +410,37 @@ class Database:
"INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)",
(channel_id,),
)
async def forget_user(self, user_id: int, no_ingest_flag: str) -> None:
"""Delete all user data, clear flags, and set noIngest atomically.
Sets the noIngest flag, clears all other user flags, and deletes
all Markov and image data in a single transaction.
:param user_id: The Discord user ID to forget.
:param no_ingest_flag: The flag name for noIngest.
"""
entity_id = str(user_id)
async with self._transaction():
await self._connection.execute(
"INSERT OR IGNORE INTO flags (entity_type, entity_id, flag_name)"
" VALUES ('user', ?, ?)",
(entity_id, no_ingest_flag),
)
await self._connection.execute(
"DELETE FROM flags WHERE entity_type = 'user'"
" AND entity_id = ? AND flag_name != ?",
(entity_id, no_ingest_flag),
)
await self._connection.execute(
"DELETE FROM markov_start_words WHERE user_id = ?",
(user_id,),
)
await self._connection.execute(
"DELETE FROM markov_transitions WHERE user_id = ?",
(user_id,),
)
await self._connection.execute(
"DELETE FROM channel_images WHERE user_id = ?",
(user_id,),
)
+124 -2
View File
@@ -14,20 +14,113 @@
"""Handles responding to interactions.
Provides the /pingme slash command as a Cog with an app command.
Provides the /pingme and /forgetme slash commands as a Cog with app commands.
"""
from typing import TYPE_CHECKING
import logging
from typing import TYPE_CHECKING, override
import discord
from discord import app_commands
from discord.ext import commands
from crabstero import flags, metrics
from crabstero.bot import TrackedView
from crabstero.flags import EntityType, Flag
if TYPE_CHECKING:
from crabstero.bot import Crabstero
from crabstero.database import Database
logger = logging.getLogger(__name__)
class ForgetMeView(TrackedView):
"""Confirmation view with Confirm/Cancel buttons for /forgetme."""
def __init__(
self, user_id: int, db: Database, interaction: discord.Interaction
) -> None:
"""Create a new ForgetMeView.
:param user_id: The Discord user ID who invoked the command.
:param db: The database instance.
:param interaction: The original interaction for editing on timeout.
"""
super().__init__()
self._user_id = user_id
self._db = db
self._responded = False
self._interaction = interaction
@override
async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Only allow the invoking user to interact with the buttons.
:param interaction: The interaction event.
:return: True if the user matches, False otherwise.
"""
return interaction.user.id == self._user_id
@discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
async def confirm(
self, interaction: discord.Interaction, button: discord.ui.Button[ForgetMeView]
) -> None:
"""Delete all user data, clear flags, and set noIngest.
:param interaction: The interaction event.
:param button: The button that was pressed.
"""
self._responded = True
await self._db.forget_user(self._user_id, Flag.NO_INGEST)
metrics.FORGETME.labels(outcome="completed").inc()
await interaction.response.edit_message(
content=(
"Your data has been deleted and your messages"
" will not be used going forward."
),
view=None,
)
self.stop()
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary)
async def cancel(
self, interaction: discord.Interaction, button: discord.ui.Button[ForgetMeView]
) -> None:
"""Cancel the /forgetme action.
:param interaction: The interaction event.
:param button: The button that was pressed.
"""
self._responded = True
metrics.FORGETME.labels(outcome="cancelled").inc()
await interaction.response.edit_message(
content="Action cancelled. Your data has not been modified.",
view=None,
)
self.stop()
@override
async def on_timeout(self) -> None:
"""Remove buttons and increment timeout metric."""
if self._responded:
return
metrics.FORGETME.labels(outcome="timeout").inc()
try:
await self._interaction.edit_original_response(
content="This request has timed out. Run `/forgetme` again if needed.",
view=None,
)
except discord.NotFound:
logger.debug(
"Original /forgetme response for user %s was already deleted.",
self._user_id,
)
except discord.HTTPException:
logger.debug(
"Failed to edit timed-out /forgetme response for user %s.",
self._user_id,
)
class InteractionCog(commands.Cog):
@@ -77,6 +170,35 @@ class InteractionCog(commands.Cog):
ephemeral=True,
)
@app_commands.command(
name="forgetme",
description="Deletes all your data and stops the bot from using your messages.",
)
async def forgetme(self, interaction: discord.Interaction) -> None:
"""Delete all user data and set the noIngest flag.
:param interaction: The interaction event.
"""
if await flags.is_flag_set(
self.bot.db, interaction.user, EntityType.USER, Flag.NO_INGEST
):
metrics.FORGETME.labels(outcome="already_forgotten").inc()
await interaction.response.send_message(
"Your data has already been removed"
" and your messages will not be used going forward.",
ephemeral=True,
)
return
view = ForgetMeView(interaction.user.id, self.bot.db, interaction)
await interaction.response.send_message(
"This will delete all data the bot has learned"
" from your messages and your messages will not"
" be used going forward. Would you like to proceed?",
view=view,
ephemeral=True,
)
async def setup(bot: Crabstero) -> None:
"""Add the InteractionCog to the bot.
+5
View File
@@ -66,6 +66,11 @@ PINGME = Counter(
"/pingme command outcomes",
["outcome"],
)
FORGETME = Counter(
"crabstero_forgetme_total",
"/forgetme command outcomes",
["outcome"],
)
ERRORS = Counter(
"crabstero_errors_total",
"Unhandled exceptions by source",
+72
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
import pytest
from crabstero.database import ChannelImage, Database, StartWord, Transition
from crabstero.flags import Flag
if TYPE_CHECKING:
from pathlib import Path
@@ -314,6 +315,77 @@ class TestTransactionRollback:
raise RuntimeError("simulated failure")
class TestForgetUser:
"""Atomic forget-user transaction across all tables."""
async def test_deletes_data_and_sets_no_ingest(self, db: Database) -> None:
"""All user data is removed and noIngest flag is set."""
await db.add_markov_data(
[StartWord(1, 100, "Hello")],
[Transition(1, 100, "Hello", "world.")],
)
await db.add_images([ChannelImage(1, 100, "https://example.com/a.png")])
await db.set_flag("user", "100", "allowPings")
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) is None
assert await db.get_random_next_word(1, "Hello") is None
assert await db.get_random_image(1) is None
assert await db.is_flag_set("user", "100", "allowPings") is False
assert await db.is_flag_set("user", "100", Flag.NO_INGEST) is True
async def test_preserves_other_users(self, db: Database) -> None:
"""Data belonging to other users is not affected."""
await db.add_markov_data(
[StartWord(1, 100, "Gone"), StartWord(1, 200, "Keep")],
[
Transition(1, 100, "Gone", "away."),
Transition(1, 200, "Keep", "this."),
],
)
await db.add_images(
[
ChannelImage(1, 100, "https://example.com/gone.png"),
ChannelImage(1, 200, "https://example.com/stay.png"),
]
)
await db.set_flag("user", "200", "allowPings")
await db.forget_user(100, Flag.NO_INGEST)
assert await db.get_random_start_word(1) == "Keep"
assert await db.get_random_next_word(1, "Keep") == "this."
assert await db.get_random_image(1) == "https://example.com/stay.png"
assert await db.is_flag_set("user", "200", "allowPings") is True
async def test_preserves_other_entity_type_flags(self, db: Database) -> None:
"""Flags on channels with the same entity ID are not affected."""
await db.set_flag("channel", "100", "noReply")
await db.set_flag("user", "100", "noReply")
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set("channel", "100", "noReply") is True
assert await db.is_flag_set("user", "100", "noReply") is False
async def test_clears_existing_flags_except_no_ingest(self, db: Database) -> None:
"""Existing user flags are cleared but noIngest remains."""
await db.set_flag("user", "100", "noReply")
await db.set_flag("user", "100", "allowPings")
await db.forget_user(100, Flag.NO_INGEST)
assert await db.is_flag_set("user", "100", "noReply") is False
assert await db.is_flag_set("user", "100", "allowPings") is False
assert await db.is_flag_set("user", "100", Flag.NO_INGEST) is True
async def test_noop_for_nonexistent_user(self, db: Database) -> None:
"""Forgetting a user with no data does not raise."""
await db.forget_user(999, Flag.NO_INGEST)
assert await db.is_flag_set("user", "999", Flag.NO_INGEST) is True
class TestWriteDurability:
"""Writes persist across close and reopen."""
+1
View File
@@ -42,6 +42,7 @@ class TestMetricObjects:
pytest.param("crabstero_messages_processed_total", id="messages-processed"),
pytest.param("crabstero_channels_ingested_total", id="channels-ingested"),
pytest.param("crabstero_pingme_total", id="pingme"),
pytest.param("crabstero_forgetme_total", id="forgetme"),
pytest.param("crabstero_errors_total", id="errors"),
pytest.param("crabstero_discord_latency_seconds", id="discord-latency"),
pytest.param("crabstero_guild_count", id="guild-count"),