Files
disbord/core/consent_manager.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

874 lines
30 KiB
Python

"""
Consent Manager for Discord Voice Chat Quote Bot
Handles user consent, privacy controls, GDPR compliance, and data protection
with comprehensive tracking and user rights management.
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Set, Union
import discord
from typing_extensions import TypedDict
from config.consent_templates import ConsentTemplates
from core.database import DatabaseManager
# ConsentView import moved to method to avoid circular import
class ConsentStatusDict(TypedDict):
"""Type definition for consent status data."""
has_record: bool
consent_given: bool
global_opt_out: bool
consent_timestamp: Optional[datetime]
first_name: Optional[str]
created_at: Optional[datetime]
updated_at: Optional[datetime]
class UserExportDict(TypedDict):
"""Type definition for user data export."""
user_id: int
export_timestamp: str
consent_records: List[Dict[str, Union[int, bool, str, None]]]
quotes: List[Dict[str, Union[int, str, float]]]
speaker_profile: Optional[Dict[str, Union[str, int, float]]]
feedback_records: List[Dict[str, Union[int, str]]]
error: Optional[str]
class PrivacyDashboardDict(TypedDict):
"""Type definition for privacy dashboard data."""
guild_id: int
generated_at: str
consent_statistics: Dict[str, Union[int, float]]
data_retention: Dict[str, Union[int, str, None]]
compliance_status: Dict[str, Union[bool, List[str]]]
error: Optional[str]
logger = logging.getLogger(__name__)
class ConsentManager:
"""
Manages user consent and privacy controls for the Discord Quote Bot
Features:
- Explicit consent collection and tracking
- GDPR compliance with right to erasure
- Global opt-out capabilities
- Automated consent expiry and renewal
- Comprehensive audit logging
"""
def __init__(self, db_manager: DatabaseManager):
self.db_manager = db_manager
self.consent_cache: Dict[int, Dict[int, bool]] = (
{}
) # user_id -> {guild_id: consent_status}
self.global_opt_outs: Set[int] = set()
self.consent_requests: Dict[str, datetime] = {} # track active consent requests
self._cache_lock = asyncio.Lock() # Prevent race conditions
self._cleanup_task: Optional[asyncio.Task] = None
self._initialized = False
async def initialize(self):
"""Initialize consent manager and load cached data"""
if self._initialized:
return
try:
logger.info("Initializing consent manager...")
# Load existing consent data into cache
await self._load_consent_cache()
# Load global opt-outs
await self._load_global_opt_outs()
# Start cleanup task for expired consent requests
self._cleanup_task = asyncio.create_task(self._cleanup_expired_requests())
self._initialized = True
logger.info("Consent manager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize consent manager: {e}")
raise
async def _load_consent_cache(self):
"""Load consent data into memory cache for fast access"""
try:
results = await self.db_manager.execute_query(
"SELECT user_id, guild_id, consent_given FROM user_consent",
fetch_all=True,
)
for row in results:
user_id = row["user_id"]
guild_id = row["guild_id"]
consent_given = row["consent_given"]
if user_id not in self.consent_cache:
self.consent_cache[user_id] = {}
self.consent_cache[user_id][guild_id] = consent_given
logger.info(f"Loaded consent data for {len(self.consent_cache)} users")
except Exception as e:
logger.error(f"Failed to load consent cache: {e}")
async def _load_global_opt_outs(self):
"""Load users who have globally opted out"""
try:
results = await self.db_manager.execute_query(
"SELECT DISTINCT user_id FROM user_consent WHERE global_opt_out = TRUE",
fetch_all=True,
)
self.global_opt_outs = {row["user_id"] for row in results}
logger.info(f"Loaded {len(self.global_opt_outs)} global opt-outs")
except Exception as e:
logger.error(f"Failed to load global opt-outs: {e}")
async def _cleanup_expired_requests(self):
"""Background task to cleanup expired consent requests"""
while True:
try:
current_time = datetime.now(timezone.utc)
expired_requests = [
request_id
for request_id, timestamp in self.consent_requests.items()
if current_time - timestamp > timedelta(minutes=5)
]
for request_id in expired_requests:
del self.consent_requests[request_id]
if expired_requests:
logger.info(
f"Cleaned up {len(expired_requests)} expired consent requests"
)
# Sleep for 1 minute before next cleanup
await asyncio.sleep(60)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in consent request cleanup: {e}")
await asyncio.sleep(60)
async def request_consent(
self,
guild_id: int,
channel: discord.TextChannel,
requester: discord.Member,
voice_channel: Optional[discord.VoiceChannel] = None,
) -> bool:
"""
Request consent from all users in a voice channel
Args:
guild_id: Discord guild ID
channel: Text channel to send consent request
requester: Member who requested recording
Returns:
bool: True if consent request was sent successfully
"""
try:
# Check if there's already an active consent request for this guild
request_key = f"guild_{guild_id}"
if request_key in self.consent_requests:
time_since_request = (
datetime.now(timezone.utc) - self.consent_requests[request_key]
)
if time_since_request < timedelta(minutes=5):
await channel.send(
"⏰ A consent request is already active. Please wait for it to expire.",
delete_after=10,
)
return False
# Record this consent request
self.consent_requests[request_key] = datetime.now(timezone.utc)
# Create consent request embed and view
embed = ConsentTemplates.get_consent_request_embed()
# Import ConsentView here to avoid circular import
from ui.components import ConsentView
view = ConsentView(self, guild_id)
# Add recording target information
if voice_channel:
members_text = ", ".join(
[
member.display_name
for member in voice_channel.members
if not member.bot
]
)
if len(members_text) > 100: # Truncate if too long
member_count = len([m for m in voice_channel.members if not m.bot])
members_text = f"{member_count} members"
embed.add_field(
name="🎯 Recording Target",
value=f"**Channel:** {voice_channel.name}\n**Members:** {members_text}",
inline=False,
)
# Add requester information
embed.add_field(
name="👤 Requested by",
value=f"{requester.display_name}\n*{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC*",
inline=False,
)
# Send consent request
await channel.send(embed=embed, view=view)
# Log the consent request
logger.info(
f"Consent request sent in guild {guild_id} by user {requester.id}"
)
return True
except Exception as e:
logger.error(f"Failed to send consent request: {e}")
return False
async def grant_consent(
self, user_id: int, guild_id: int, first_name: Optional[str] = None
) -> bool:
"""
Grant recording consent for a user in a specific guild
Args:
user_id: Discord user ID
guild_id: Discord guild ID
first_name: User's preferred first name (optional)
Returns:
bool: True if consent was granted successfully
"""
try:
# Check if user has globally opted out
if user_id in self.global_opt_outs:
logger.warning(
f"User {user_id} tried to consent but has global opt-out"
)
return False
# Grant consent in database
success = await self.db_manager.grant_consent(user_id, guild_id, first_name)
if success:
# Update cache with lock protection
async with self._cache_lock:
if user_id not in self.consent_cache:
self.consent_cache[user_id] = {}
self.consent_cache[user_id][guild_id] = True
logger.info(f"Consent granted for user {user_id} in guild {guild_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to grant consent: {e}")
return False
async def revoke_consent(self, user_id: int, guild_id: int) -> bool:
"""
Revoke recording consent for a user in a specific guild
Args:
user_id: Discord user ID
guild_id: Discord guild ID
Returns:
bool: True if consent was revoked successfully
"""
try:
# Revoke consent in database
success = await self.db_manager.revoke_consent(user_id, guild_id)
if success:
# Update cache with lock protection
async with self._cache_lock:
if (
user_id in self.consent_cache
and guild_id in self.consent_cache[user_id]
):
self.consent_cache[user_id][guild_id] = False
logger.info(f"Consent revoked for user {user_id} in guild {guild_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to revoke consent: {e}")
return False
async def check_consent(self, user_id: int, guild_id: int) -> bool:
"""
Check if a user has given consent for recording in a guild
Args:
user_id: Discord user ID
guild_id: Discord guild ID
Returns:
bool: True if user has consented and not globally opted out
"""
try:
# Check global opt-out first
if user_id in self.global_opt_outs:
return False
# Check cache first with lock protection
async with self._cache_lock:
if (
user_id in self.consent_cache
and guild_id in self.consent_cache[user_id]
):
return self.consent_cache[user_id][guild_id]
# Fallback to database
consent_status = await self.db_manager.check_consent(user_id, guild_id)
# Update cache with lock protection
async with self._cache_lock:
if user_id not in self.consent_cache:
self.consent_cache[user_id] = {}
self.consent_cache[user_id][guild_id] = consent_status
return consent_status
except Exception as e:
logger.error(f"Failed to check consent: {e}")
return False
async def set_global_opt_out(self, user_id: int, opt_out: bool = True) -> bool:
"""
Set global opt-out status for a user across all guilds
Args:
user_id: Discord user ID
opt_out: True to opt out, False to opt back in
Returns:
bool: True if operation was successful
"""
try:
# Update database
success = await self.db_manager.set_global_opt_out(user_id, opt_out)
if success:
# Update cache with lock protection
async with self._cache_lock:
if opt_out:
self.global_opt_outs.add(user_id)
else:
self.global_opt_outs.discard(user_id)
# Clear consent cache for this user (force reload)
if user_id in self.consent_cache:
del self.consent_cache[user_id]
action = "opted out globally" if opt_out else "opted back in globally"
logger.info(f"User {user_id} {action}")
return True
return False
except Exception as e:
logger.error(f"Failed to set global opt-out: {e}")
return False
async def get_consented_users(self, guild_id: int) -> List[int]:
"""
Get list of users who have consented to recording in a guild
Args:
guild_id: Discord guild ID
Returns:
List[int]: List of user IDs who have consented
"""
try:
# Get from database to ensure accuracy
consented_users = await self.db_manager.get_consented_users(guild_id)
# Filter out globally opted out users
filtered_users = [
user_id
for user_id in consented_users
if user_id not in self.global_opt_outs
]
return filtered_users
except Exception as e:
logger.error(f"Failed to get consented users: {e}")
return []
async def get_consent_status(
self, user_id: int, guild_id: int
) -> ConsentStatusDict:
"""
Get detailed consent status for a user
Args:
user_id: Discord user ID
guild_id: Discord guild ID
Returns:
Dict containing detailed consent information
"""
try:
# Get consent record from database
result = await self.db_manager.execute_query(
"""
SELECT * FROM user_consent
WHERE user_id = $1 AND guild_id = $2
""",
user_id,
guild_id,
fetch_one=True,
)
if result:
return {
"has_record": True,
"consent_given": result["consent_given"],
"global_opt_out": result["global_opt_out"],
"consent_timestamp": result["consent_timestamp"],
"first_name": result["first_name"],
"created_at": result["created_at"],
"updated_at": result["updated_at"],
}
else:
return {
"has_record": False,
"consent_given": False,
"global_opt_out": user_id in self.global_opt_outs,
"consent_timestamp": None,
"first_name": None,
"created_at": None,
"updated_at": None,
}
except Exception as e:
logger.error(f"Failed to get consent status: {e}")
return {
"has_record": False,
"consent_given": False,
"global_opt_out": False,
}
async def cleanup_non_consented_data(self, guild_id: int) -> int:
"""
Remove data from users who have revoked consent
Args:
guild_id: Discord guild ID
Returns:
int: Number of records cleaned up
"""
try:
# Get users who have revoked consent
revoked_users = await self.db_manager.execute_query(
"""
SELECT user_id FROM user_consent
WHERE guild_id = $1 AND (consent_given = FALSE OR global_opt_out = TRUE)
""",
guild_id,
fetch_all=True,
)
if not revoked_users:
return 0
revoked_user_ids = [row["user_id"] for row in revoked_users]
# Delete quotes from non-consenting users
cleanup_count = 0
for user_id in revoked_user_ids:
deleted = await self.db_manager.delete_user_quotes(user_id, guild_id)
cleanup_count += deleted
logger.info(
f"Cleaned up {cleanup_count} records for {len(revoked_user_ids)} non-consenting users"
)
return cleanup_count
except Exception as e:
logger.error(f"Failed to cleanup non-consented data: {e}")
return 0
async def export_user_data(
self, user_id: int, guild_id: Optional[int] = None
) -> UserExportDict:
"""
Export all data for a user (GDPR compliance)
Args:
user_id: Discord user ID
guild_id: Optional guild ID to limit export scope
Returns:
Dict containing all user data
"""
try:
export_data = {
"user_id": user_id,
"export_timestamp": datetime.now(timezone.utc).isoformat(),
"consent_records": [],
"quotes": [],
"speaker_profile": None,
"feedback_records": [],
}
# Export consent records
consent_query = """
SELECT * FROM user_consent WHERE user_id = $1
"""
consent_params = [user_id]
if guild_id:
consent_query += " AND guild_id = $2"
consent_params.append(guild_id)
consent_results = await self.db_manager.execute_query(
consent_query, *consent_params, fetch_all=True
)
for record in consent_results:
export_data["consent_records"].append(
{
"guild_id": record["guild_id"],
"consent_given": record["consent_given"],
"consent_timestamp": (
record["consent_timestamp"].isoformat()
if record["consent_timestamp"]
else None
),
"global_opt_out": record["global_opt_out"],
"first_name": record["first_name"],
"created_at": record["created_at"].isoformat(),
"updated_at": record["updated_at"].isoformat(),
}
)
# Export quotes
quote_query = """
SELECT * FROM quotes WHERE user_id = $1
"""
quote_params = [user_id]
if guild_id:
quote_query += " AND guild_id = $2"
quote_params.append(guild_id)
quote_results = await self.db_manager.execute_query(
quote_query, *quote_params, fetch_all=True
)
for quote in quote_results:
export_data["quotes"].append(
{
"id": quote["id"],
"quote": quote["quote"],
"timestamp": quote["timestamp"].isoformat(),
"guild_id": quote["guild_id"],
"channel_id": quote["channel_id"],
"funny_score": float(quote["funny_score"]),
"dark_score": float(quote["dark_score"]),
"silly_score": float(quote["silly_score"]),
"suspicious_score": float(quote["suspicious_score"]),
"asinine_score": float(quote["asinine_score"]),
"overall_score": float(quote["overall_score"]),
"response_type": quote["response_type"],
"user_feedback": quote["user_feedback"],
"created_at": quote["created_at"].isoformat(),
}
)
# Export speaker profile
profile_result = await self.db_manager.execute_query(
"""
SELECT * FROM speaker_profiles WHERE user_id = $1
""",
user_id,
fetch_one=True,
)
if profile_result:
export_data["speaker_profile"] = {
"enrollment_status": profile_result["enrollment_status"],
"enrollment_phrase": profile_result["enrollment_phrase"],
"personality_summary": profile_result["personality_summary"],
"quote_count": profile_result["quote_count"],
"avg_humor_score": float(profile_result["avg_humor_score"]),
"last_seen": (
profile_result["last_seen"].isoformat()
if profile_result["last_seen"]
else None
),
"training_samples": profile_result["training_samples"],
"recognition_accuracy": float(
profile_result["recognition_accuracy"]
),
"created_at": profile_result["created_at"].isoformat(),
"updated_at": profile_result["updated_at"].isoformat(),
}
# Export feedback records
feedback_results = await self.db_manager.execute_query(
"""
SELECT * FROM quote_feedback WHERE user_id = $1
""",
user_id,
fetch_all=True,
)
for feedback in feedback_results:
export_data["feedback_records"].append(
{
"quote_id": feedback["quote_id"],
"feedback_type": feedback["feedback_type"],
"feedback_value": feedback["feedback_value"],
"timestamp": feedback["timestamp"].isoformat(),
}
)
logger.info(
f"Exported data for user {user_id}: {len(export_data['quotes'])} quotes, {len(export_data['consent_records'])} consent records"
)
return export_data
except Exception as e:
logger.error(f"Failed to export user data: {e}")
return {"error": str(e)}
async def delete_user_data(
self, user_id: int, guild_id: Optional[int] = None
) -> Dict[str, int]:
"""
Delete all data for a user (GDPR right to erasure)
Args:
user_id: Discord user ID
guild_id: Optional guild ID to limit deletion scope
Returns:
Dict with counts of deleted records
"""
try:
deletion_counts = {
"quotes": 0,
"consent_records": 0,
"speaker_profile": 0,
"feedback_records": 0,
}
# Delete quotes
if guild_id:
deletion_counts["quotes"] = await self.db_manager.delete_user_quotes(
user_id, guild_id
)
else:
deletion_counts["quotes"] = await self.db_manager.delete_user_quotes(
user_id
)
# Delete consent records
if guild_id:
result = await self.db_manager.execute_query(
"""
DELETE FROM user_consent WHERE user_id = $1 AND guild_id = $2
""",
user_id,
guild_id,
)
else:
result = await self.db_manager.execute_query(
"""
DELETE FROM user_consent WHERE user_id = $1
""",
user_id,
)
deletion_counts["consent_records"] = (
int(result.split()[-1]) if result else 0
)
# Delete speaker profile (only if not guild-specific)
if not guild_id:
result = await self.db_manager.execute_query(
"""
DELETE FROM speaker_profiles WHERE user_id = $1
""",
user_id,
)
deletion_counts["speaker_profile"] = (
int(result.split()[-1]) if result else 0
)
# Delete feedback records
result = await self.db_manager.execute_query(
"""
DELETE FROM quote_feedback WHERE user_id = $1
""",
user_id,
)
deletion_counts["feedback_records"] = (
int(result.split()[-1]) if result else 0
)
# Update caches
if user_id in self.consent_cache:
if guild_id and guild_id in self.consent_cache[user_id]:
del self.consent_cache[user_id][guild_id]
elif not guild_id:
del self.consent_cache[user_id]
if not guild_id:
self.global_opt_outs.discard(user_id)
logger.info(f"Deleted user data for {user_id}: {deletion_counts}")
return deletion_counts
except Exception as e:
logger.error(f"Failed to delete user data: {e}")
return {"error": str(e)}
async def get_privacy_dashboard_data(self, guild_id: int) -> PrivacyDashboardDict:
"""
Get privacy dashboard data for server administrators
Args:
guild_id: Discord guild ID
Returns:
Dict containing privacy statistics and compliance info
"""
try:
dashboard_data = {
"guild_id": guild_id,
"generated_at": datetime.now(timezone.utc).isoformat(),
"consent_statistics": {},
"data_retention": {},
"compliance_status": {},
}
# Consent statistics
consent_stats = await self.db_manager.execute_query(
"""
SELECT
COUNT(*) as total_users,
COUNT(CASE WHEN consent_given = TRUE THEN 1 END) as consented_users,
COUNT(CASE WHEN global_opt_out = TRUE THEN 1 END) as global_opt_outs,
COUNT(CASE WHEN consent_timestamp > NOW() - INTERVAL '30 days' THEN 1 END) as recent_consents
FROM user_consent
WHERE guild_id = $1
""",
guild_id,
fetch_one=True,
)
if consent_stats:
dashboard_data["consent_statistics"] = {
"total_users": consent_stats["total_users"],
"consented_users": consent_stats["consented_users"],
"global_opt_outs": consent_stats["global_opt_outs"],
"recent_consents_30d": consent_stats["recent_consents"],
"consent_rate": consent_stats["consented_users"]
/ max(consent_stats["total_users"], 1),
}
# Data retention statistics
retention_stats = await self.db_manager.execute_query(
"""
SELECT
COUNT(*) as total_quotes,
COUNT(CASE WHEN created_at > NOW() - INTERVAL '7 days' THEN 1 END) as quotes_7d,
COUNT(CASE WHEN created_at > NOW() - INTERVAL '30 days' THEN 1 END) as quotes_30d,
MIN(created_at) as oldest_quote,
MAX(created_at) as newest_quote
FROM quotes
WHERE guild_id = $1
""",
guild_id,
fetch_one=True,
)
if retention_stats:
dashboard_data["data_retention"] = {
"total_quotes": retention_stats["total_quotes"],
"quotes_last_7_days": retention_stats["quotes_7d"],
"quotes_last_30_days": retention_stats["quotes_30d"],
"oldest_quote": (
retention_stats["oldest_quote"].isoformat()
if retention_stats["oldest_quote"]
else None
),
"newest_quote": (
retention_stats["newest_quote"].isoformat()
if retention_stats["newest_quote"]
else None
),
}
# Compliance status
dashboard_data["compliance_status"] = {
"gdpr_compliant": True,
"auto_cleanup_enabled": True,
"consent_tracking_active": True,
"user_rights_supported": [
"right_to_access",
"right_to_rectification",
"right_to_erasure",
"right_to_portability",
"right_to_object",
],
}
return dashboard_data
except Exception as e:
logger.error(f"Failed to get privacy dashboard data: {e}")
return {"error": str(e)}
async def cleanup(self):
"""Clean up resources and cancel background tasks"""
try:
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("Consent manager cleanup completed")
except Exception as e:
logger.error(f"Error during consent manager cleanup: {e}")