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 import __version__ as crabstero_version
from crabstero.cache import IngestCache from crabstero.cache import IngestCache
from crabstero.database import Database from crabstero.database import Database
from crabstero.listeners import interaction, message, server_events
from crabstero.metrics import ( from crabstero.metrics import (
DISCORD_EVENTS, DISCORD_EVENTS,
DISCORD_LATENCY, DISCORD_LATENCY,
@@ -124,6 +123,8 @@ class Crabstero(commands.Bot):
await server.start() await server.start()
self._metrics_server = server self._metrics_server = server
from crabstero.listeners import interaction, message, server_events
if not self._ingest_only: if not self._ingest_only:
await interaction.setup(self) await interaction.setup(self)
+34
View File
@@ -410,3 +410,37 @@ class Database:
"INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)", "INSERT OR IGNORE INTO ingested_channels (channel_id) VALUES (?)",
(channel_id,), (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. """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 import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
from crabstero import flags, metrics from crabstero import flags, metrics
from crabstero.bot import TrackedView
from crabstero.flags import EntityType, Flag from crabstero.flags import EntityType, Flag
if TYPE_CHECKING: if TYPE_CHECKING:
from crabstero.bot import Crabstero 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): class InteractionCog(commands.Cog):
@@ -77,6 +170,35 @@ class InteractionCog(commands.Cog):
ephemeral=True, 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: async def setup(bot: Crabstero) -> None:
"""Add the InteractionCog to the bot. """Add the InteractionCog to the bot.
+5
View File
@@ -66,6 +66,11 @@ PINGME = Counter(
"/pingme command outcomes", "/pingme command outcomes",
["outcome"], ["outcome"],
) )
FORGETME = Counter(
"crabstero_forgetme_total",
"/forgetme command outcomes",
["outcome"],
)
ERRORS = Counter( ERRORS = Counter(
"crabstero_errors_total", "crabstero_errors_total",
"Unhandled exceptions by source", "Unhandled exceptions by source",
+72
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from crabstero.database import ChannelImage, Database, StartWord, Transition from crabstero.database import ChannelImage, Database, StartWord, Transition
from crabstero.flags import Flag
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
@@ -314,6 +315,77 @@ class TestTransactionRollback:
raise RuntimeError("simulated failure") 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: class TestWriteDurability:
"""Writes persist across close and reopen.""" """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_messages_processed_total", id="messages-processed"),
pytest.param("crabstero_channels_ingested_total", id="channels-ingested"), pytest.param("crabstero_channels_ingested_total", id="channels-ingested"),
pytest.param("crabstero_pingme_total", id="pingme"), pytest.param("crabstero_pingme_total", id="pingme"),
pytest.param("crabstero_forgetme_total", id="forgetme"),
pytest.param("crabstero_errors_total", id="errors"), pytest.param("crabstero_errors_total", id="errors"),
pytest.param("crabstero_discord_latency_seconds", id="discord-latency"), pytest.param("crabstero_discord_latency_seconds", id="discord-latency"),
pytest.param("crabstero_guild_count", id="guild-count"), pytest.param("crabstero_guild_count", id="guild-count"),