Files
disbord/cogs/consent_cog.py
Travis Vasceannie 3acb779569 chore: remove .env.example and add new files for project structure
- Deleted .env.example file as it is no longer needed.
- Added .gitignore to manage ignored files and directories.
- Introduced CLAUDE.md for AI provider integration documentation.
- Created dev.sh for development setup and scripts.
- Updated Dockerfile and Dockerfile.production for improved build processes.
- Added multiple test files and directories for comprehensive testing.
- Introduced new utility and service files for enhanced functionality.
- Organized codebase with new directories and files for better maintainability.
2025-08-27 23:00:19 -04:00

719 lines
28 KiB
Python

"""
Consent Cog for Discord Voice Chat Quote Bot
Handles all consent-related slash commands, privacy controls, and GDPR compliance
including consent management, data export, deletion, and user rights.
"""
import io
import json
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
import discord
from discord import app_commands
from discord.ext import commands
from config.consent_templates import ConsentMessages, ConsentTemplates
from core.consent_manager import ConsentManager
from ui.components import DataDeletionView, EmbedBuilder
if TYPE_CHECKING:
from main import QuoteBot
logger = logging.getLogger(__name__)
class ConsentCog(commands.Cog):
"""
Comprehensive consent and privacy management for the Discord Quote Bot
Commands:
- /give_consent - Grant recording consent
- /revoke_consent - Revoke consent for current server
- /opt_out - Global opt-out from all recording
- /opt_in - Re-enable recording after opt-out
- /privacy_info - Show detailed privacy information
- /consent_status - Check your consent status
- /delete_my_quotes - Delete your quote data
- /export_my_data - Export your data (GDPR)
- /gdpr_info - GDPR compliance information
"""
def __init__(self, bot: "QuoteBot") -> None:
self.bot = bot
self.consent_manager: ConsentManager = bot.consent_manager # type: ignore[assignment]
self.db_manager = bot.db_manager
@app_commands.command(
name="give_consent",
description="Give consent for voice recording in this server",
)
@app_commands.describe(
first_name="Optional: Your preferred first name for quotes (instead of username)"
)
async def give_consent(
self, interaction: discord.Interaction, first_name: Optional[str] = None
):
"""Grant recording consent for the current server"""
try:
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Check if user has global opt-out
if user_id in self.consent_manager.global_opt_outs:
embed = EmbedBuilder.error_embed(
"Global Opt-Out Active", ConsentMessages.GLOBAL_OPT_OUT, "warning"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Check current consent status
current_consent = await self.consent_manager.check_consent(
user_id, guild_id
)
if current_consent:
embed = EmbedBuilder.error_embed(
"Already Consented", ConsentMessages.ALREADY_CONSENTED, "info"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Grant consent
success = await self.consent_manager.grant_consent(
user_id, guild_id, first_name
)
if success:
embed = EmbedBuilder.success_embed(
"Consent Granted", ConsentMessages.CONSENT_GRANTED
)
if first_name:
embed.add_field(
name="Preferred Name",
value=f"Your quotes will be attributed to: **{first_name}**",
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Log consent action
if self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{"action": "granted", "guild_id": str(guild_id)},
)
logger.info(f"Consent granted by user {user_id} in guild {guild_id}")
else:
embed = EmbedBuilder.error_embed(
"Consent Failed",
"Failed to grant consent. Please try again or contact an administrator.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in give_consent command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "An error occurred while processing your consent."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="revoke_consent", description="Revoke recording consent for this server"
)
async def revoke_consent(self, interaction: discord.Interaction):
"""Revoke recording consent for the current server"""
try:
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Check current consent status
current_consent = await self.consent_manager.check_consent(
user_id, guild_id
)
if not current_consent:
embed = EmbedBuilder.error_embed(
"No Consent to Revoke", ConsentMessages.NOT_CONSENTED, "info"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Revoke consent
success = await self.consent_manager.revoke_consent(user_id, guild_id)
if success:
embed = EmbedBuilder.success_embed(
"Consent Revoked", ConsentMessages.CONSENT_REVOKED
)
embed.add_field(
name="What's Next?",
value=(
"• Your voice will no longer be recorded\n"
"• Existing quotes remain (use `/delete_my_quotes` to remove)\n"
"• You can re-consent anytime with `/give_consent`"
),
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Log consent action
if self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{"action": "revoked", "guild_id": str(guild_id)},
)
logger.info(f"Consent revoked by user {user_id} in guild {guild_id}")
else:
embed = EmbedBuilder.error_embed(
"Revocation Failed",
"Failed to revoke consent. Please try again or contact an administrator.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in revoke_consent command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "An error occurred while revoking your consent."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="opt_out", description="Globally opt out from all voice recording"
)
@app_commands.describe(
global_opt_out="True for global opt-out across all servers, False for this server only"
)
async def opt_out(
self, interaction: discord.Interaction, global_opt_out: bool = True
):
"""Global opt-out from all voice recording"""
try:
user_id = interaction.user.id
if global_opt_out:
# Global opt-out
success = await self.consent_manager.set_global_opt_out(user_id, True)
if success:
embed = EmbedBuilder.success_embed(
"Global Opt-Out Enabled", ConsentMessages.OPT_OUT_MESSAGE
)
embed.add_field(
name="📊 Data Management",
value=(
"Use these commands to manage your data:\n"
"• `/delete_my_quotes` - Remove quotes from specific servers\n"
"• `/export_my_data` - Download your data\n"
"• `/opt_in` - Re-enable recording in the future"
),
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Log opt-out action
if interaction.guild is not None and self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{
"action": "global_opt_out",
"guild_id": str(interaction.guild.id),
},
)
logger.info(f"Global opt-out by user {user_id}")
else:
embed = EmbedBuilder.error_embed(
"Opt-Out Failed",
"Failed to set global opt-out. Please try again.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
else:
# Server-specific opt-out (same as revoke consent)
await self._handle_server_consent_revoke(interaction)
except Exception as e:
logger.error(f"Error in opt_out command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "An error occurred while processing your opt-out."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="opt_in", description="Re-enable voice recording after global opt-out"
)
async def opt_in(self, interaction: discord.Interaction):
"""Re-enable recording after global opt-out"""
try:
user_id = interaction.user.id
# Check if user has global opt-out
if user_id not in self.consent_manager.global_opt_outs:
embed = EmbedBuilder.error_embed(
"Not Opted Out",
"You haven't globally opted out. Use `/give_consent` to enable recording in this server.",
"info",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Remove global opt-out
success = await self.consent_manager.set_global_opt_out(user_id, False)
if success:
embed = EmbedBuilder.success_embed(
"Global Opt-Out Disabled",
"✅ **You've opted back into voice recording!**\n\n"
"You can now give consent in individual servers using `/give_consent`.",
)
embed.add_field(
name="Next Steps",
value=(
"• Use `/give_consent` to enable recording in this server\n"
"• Your previous consent settings may need to be renewed\n"
"• Use `/consent_status` to check your current status"
),
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Log opt-in action
if interaction.guild is not None and self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{
"action": "global_opt_in",
"guild_id": str(interaction.guild.id),
},
)
logger.info(f"Global opt-in by user {user_id}")
else:
embed = EmbedBuilder.error_embed(
"Opt-In Failed", "Failed to re-enable recording. Please try again."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in opt_in command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "An error occurred while processing your opt-in."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="privacy_info",
description="View detailed privacy and data handling information",
)
async def privacy_info(self, interaction: discord.Interaction):
"""Show detailed privacy information"""
try:
embed = ConsentTemplates.get_privacy_info_embed()
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in privacy_info command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "Failed to load privacy information."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="consent_status",
description="Check your current consent and privacy status",
)
async def consent_status(self, interaction: discord.Interaction):
"""Show user's current consent status"""
try:
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Get detailed consent status
status = await self.consent_manager.get_consent_status(user_id, guild_id)
# Build status embed
embed = discord.Embed(
title="🔒 Your Privacy Status",
description=f"Consent and privacy settings for {interaction.user.display_name}",
color=0x0099FF,
)
# Current consent status
if status["consent_given"]:
consent_status = "✅ **Consented** - Voice recording enabled"
consent_color = "🟢"
else:
consent_status = "❌ **Not Consented** - Voice recording disabled"
consent_color = "🔴"
embed.add_field(
name=f"{consent_color} Recording Consent",
value=consent_status,
inline=False,
)
# Global opt-out status
if status["global_opt_out"]:
global_status = (
"🔴 **Global Opt-Out Active** - Recording disabled on all servers"
)
else:
global_status = "🟢 **Global Recording Enabled** - Can consent on individual servers"
embed.add_field(name="🌐 Global Status", value=global_status, inline=False)
# Consent details
if status["has_record"]:
details = []
if status["consent_timestamp"]:
consent_date = status["consent_timestamp"].strftime(
"%Y-%m-%d %H:%M UTC"
)
details.append(f"**Consent Given:** {consent_date}")
if status["first_name"]:
details.append(f"**Preferred Name:** {status['first_name']}")
if status["created_at"]:
created_date = status["created_at"].strftime("%Y-%m-%d")
details.append(f"**First Interaction:** {created_date}")
if details:
embed.add_field(
name="📊 Account Details",
value="\n".join(details),
inline=False,
)
# Quick actions
actions = []
if not status["global_opt_out"]:
if status["consent_given"]:
actions.extend(
[
"`/revoke_consent` - Stop recording in this server",
"`/opt_out` - Stop recording globally",
]
)
else:
actions.append("`/give_consent` - Enable recording in this server")
else:
actions.append("`/opt_in` - Re-enable recording globally")
actions.extend(
[
"`/delete_my_quotes` - Remove your quote data",
"`/export_my_data` - Download your data",
]
)
embed.add_field(
name="⚡ Quick Actions", value="\n".join(actions), inline=False
)
embed.set_footer(
text="Your privacy matters • Use /privacy_info for more details"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in consent_status command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "Failed to retrieve consent status."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="delete_my_quotes", description="Delete your quote data from this server"
)
@app_commands.describe(confirm="Type 'CONFIRM' to proceed with data deletion")
async def delete_my_quotes(
self, interaction: discord.Interaction, confirm: Optional[str] = None
):
"""Delete user's quote data with confirmation"""
try:
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Get user's quote count
quotes = await self.db_manager.get_user_quotes(
user_id, guild_id, limit=1000
)
quote_count = len(quotes)
if quote_count == 0:
embed = EmbedBuilder.error_embed(
"No Data to Delete",
"You don't have any quotes stored in this server.",
"info",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# If no confirmation provided, show confirmation dialog
if not confirm or confirm.upper() != "CONFIRM":
embed = ConsentTemplates.get_data_deletion_confirmation(quote_count)
view = DataDeletionView(
user_id, guild_id, quote_count, self.consent_manager
)
await interaction.response.send_message(
embed=embed, view=view, ephemeral=True
)
return
# Execute deletion
deletion_counts = await self.consent_manager.delete_user_data(
user_id, guild_id
)
if "error" not in deletion_counts:
embed = EmbedBuilder.success_embed(
"Data Deleted Successfully",
f"✅ **{deletion_counts.get('quotes', 0)} quotes** and related data have been permanently removed.",
)
embed.add_field(
name="What was deleted",
value=f"• **{deletion_counts.get('quotes', 0)}** quotes\n"
f"• **{deletion_counts.get('feedback_records', 0)}** feedback records\n"
f"• Associated metadata and timestamps",
inline=False,
)
embed.add_field(
name="What's Next?",
value="You can continue using the bot normally. Give consent again anytime with `/give_consent`.",
inline=False,
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Log deletion action
if self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{"action": "data_deleted", "guild_id": str(guild_id)},
)
logger.info(
f"Data deleted for user {user_id} in guild {guild_id}: {deletion_counts}"
)
else:
embed = EmbedBuilder.error_embed(
"Deletion Failed", f"An error occurred: {deletion_counts['error']}"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in delete_my_quotes command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "An error occurred during data deletion."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
@app_commands.command(
name="export_my_data",
description="Export your data for download (GDPR compliance)",
)
async def export_my_data(self, interaction: discord.Interaction):
"""Export user data for GDPR compliance"""
try:
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Initial response
embed = EmbedBuilder.success_embed(
"Data Export Started", ConsentMessages.DATA_EXPORT_STARTED
)
await interaction.response.send_message(embed=embed, ephemeral=True)
# Export data
export_data = await self.consent_manager.export_user_data(user_id, guild_id)
if "error" in export_data:
error_embed = EmbedBuilder.error_embed(
"Export Failed", f"Failed to export data: {export_data['error']}"
)
await interaction.followup.send(embed=error_embed, ephemeral=True)
return
# Create JSON file
json_data = json.dumps(export_data, indent=2, ensure_ascii=False)
json_bytes = json_data.encode("utf-8")
# Create file
filename = f"discord_quote_data_{user_id}_{guild_id}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json"
file = discord.File(io.BytesIO(json_bytes), filename=filename)
# Send file via DM
try:
dm_embed = discord.Embed(
title="📤 Your Data Export",
description=(
f"Here's your exported data from **{interaction.guild.name if interaction.guild else 'Unknown Server'}**.\n\n"
f"**Contents:**\n"
f"{len(export_data.get('quotes', []))} quotes\n"
f"{len(export_data.get('consent_records', []))} consent records\n"
f"{len(export_data.get('feedback_records', []))} feedback records\n"
f"• Speaker profile data (if available)\n\n"
f"This data is provided in JSON format for GDPR compliance."
),
color=0x00FF00,
)
dm_embed.add_field(
name="🔒 Privacy Note",
value="This file contains your personal data. Please store it securely and delete it when no longer needed.",
inline=False,
)
dm_embed.set_footer(
text=f"Exported on {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC"
)
await interaction.user.send(embed=dm_embed, file=file)
# Confirm successful DM
success_embed = EmbedBuilder.success_embed(
"Export Complete",
"✅ Your data has been sent to your DMs! Check your direct messages for the download file.",
)
await interaction.followup.send(embed=success_embed, ephemeral=True)
except discord.Forbidden:
# Can't send DM, offer alternative
dm_error_embed = EmbedBuilder.error_embed(
"DM Failed",
"❌ Couldn't send the file via DM (DMs might be disabled).\n\n"
"Please enable DMs from server members temporarily and try again, "
"or contact a server administrator for assistance.",
"warning",
)
await interaction.followup.send(embed=dm_error_embed, ephemeral=True)
# Log export action
if self.bot.metrics:
self.bot.metrics.increment(
"consent_actions",
{"action": "data_exported", "guild_id": str(guild_id)},
)
logger.info(f"Data exported for user {user_id} in guild {guild_id}")
except Exception as e:
logger.error(f"Error in export_my_data command: {e}")
embed = EmbedBuilder.error_embed(
"Export Error",
"An error occurred during data export. Please try again or contact an administrator.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
@app_commands.command(
name="gdpr_info",
description="View GDPR compliance and data protection information",
)
async def gdpr_info(self, interaction: discord.Interaction):
"""Show GDPR compliance information"""
try:
embed = ConsentTemplates.get_gdpr_compliance_embed()
await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e:
logger.error(f"Error in gdpr_info command: {e}")
embed = EmbedBuilder.error_embed(
"Command Error", "Failed to load GDPR information."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
async def _handle_server_consent_revoke(
self, interaction: discord.Interaction
) -> None:
"""Helper method to handle server-specific consent revocation."""
if interaction.guild is None:
embed = EmbedBuilder.error_embed(
"Guild Error", "This command can only be used in a server."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
user_id = interaction.user.id
guild_id = interaction.guild.id
# Check current consent status
current_consent = await self.consent_manager.check_consent(user_id, guild_id)
if not current_consent:
embed = EmbedBuilder.error_embed(
"No Consent to Revoke", ConsentMessages.NOT_CONSENTED, "info"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Revoke consent
success = await self.consent_manager.revoke_consent(user_id, guild_id)
if success:
embed = EmbedBuilder.success_embed(
"Consent Revoked", ConsentMessages.CONSENT_REVOKED
)
await interaction.response.send_message(embed=embed, ephemeral=True)
else:
embed = EmbedBuilder.error_embed(
"Revoke Failed",
"Failed to revoke consent. Please try again or contact an administrator.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
async def setup(bot: "QuoteBot") -> None:
"""Setup function for the cog"""
await bot.add_cog(ConsentCog(bot))