Added /forgetme slash command with atomic user data deletion, confirmation UI, and Prometheus metrics.
This commit is contained in:
+2
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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,),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user