From 8ff51ef9a12c725d9d45127b80519506f81c63ce Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 6 Jan 2026 19:48:19 +0530 Subject: [PATCH 01/18] updated .gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 716b5d33..8b529fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -88,7 +88,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -108,7 +108,7 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. From ee7347de2c54756a7906a956163fc6686584adcd Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 6 Jan 2026 19:50:30 +0530 Subject: [PATCH 02/18] feat(discord): implement admin permission system --- backend/app/core/config/settings.py | 3 + backend/integrations/discord/permissions.py | 154 +++++++++++++ env.example | 3 + tests/test_admin_permissions.py | 243 ++++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 backend/integrations/discord/permissions.py create mode 100644 tests/test_admin_permissions.py diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index 1349a02f..2fd4927a 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -16,6 +16,9 @@ class Settings(BaseSettings): github_token: str = "" discord_bot_token: str = "" + # Discord Bot Configuration + bot_owner_id: Optional[int] = None # Discord user ID of the bot owner (for admin commands) + # DB configuration supabase_url: str supabase_key: str diff --git a/backend/integrations/discord/permissions.py b/backend/integrations/discord/permissions.py new file mode 100644 index 00000000..922bc9bf --- /dev/null +++ b/backend/integrations/discord/permissions.py @@ -0,0 +1,154 @@ +"""Permission management system for Discord bot admin commands.""" + +import logging +from functools import wraps +from typing import Callable, Optional + +import discord +from discord import app_commands, Interaction + +from backend.app.core.config import settings + +logger = logging.getLogger(__name__) + + +def is_bot_owner(user_id: int) -> bool: + """Check if a user is the bot owner.""" + if not settings.bot_owner_id: + logger.warning("BOT_OWNER_ID not configured - owner checks will fail") + return False + return user_id == settings.bot_owner_id + + +def is_admin(interaction: Interaction) -> bool: + """Check if a user has administrator permissions in the guild.""" + if not interaction.guild: + logger.debug(f"Admin check failed for user {interaction.user.id}: No guild context") + return False + + member = interaction.guild.get_member(interaction.user.id) + if not member: + logger.debug(f"Admin check failed for user {interaction.user.id}: Member not found in guild") + return False + + return member.guild_permissions.administrator + + +def get_permission_embed( + title: str = "❌ Permission Denied", + description: str = "You don't have permission to use this command.", + required_permission: str = "Administrator or Bot Owner" +) -> discord.Embed: + """Create a standardized permission denied embed.""" + embed = discord.Embed( + title=title, + description=description, + color=discord.Color.red() + ) + embed.add_field( + name="Required Permission", + value=f"`{required_permission}`", + inline=False + ) + embed.add_field( + name="💡 Need Access?", + value="Contact your server administrator or the bot owner.", + inline=False + ) + embed.set_footer(text="This action has been logged for security purposes.") + return embed + + +def require_admin(func: Callable) -> Callable: + @wraps(func) + async def wrapper(self, interaction: Interaction, *args, **kwargs): + user_id = interaction.user.id + command_name = interaction.command.name if interaction.command else "unknown" + + # Check if user is bot owner + if is_bot_owner(user_id): + logger.info( + f"Admin command authorized: user={user_id} (BOT OWNER), " + f"command=/{command_name}" + ) + return await func(self, interaction, *args, **kwargs) + + # Check if user is administrator in guild + if is_admin(interaction): + logger.info( + f"Admin command authorized: user={user_id} (ADMINISTRATOR), " + f"command=/{command_name}, guild={interaction.guild_id}" + ) + return await func(self, interaction, *args, **kwargs) + + # Permission denied + logger.warning( + f"Admin command denied: user={user_id}, command=/{command_name}, " + f"guild={interaction.guild_id}, reason=insufficient_permissions" + ) + + embed = get_permission_embed( + description="This command is restricted to server administrators and the bot owner.", + required_permission="Administrator or Bot Owner" + ) + + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await interaction.response.send_message(embed=embed, ephemeral=True) + + return None + + return wrapper + + +def require_bot_owner(func: Callable) -> Callable: + """Decorator to restrict command access to bot owner only.""" + @wraps(func) + async def wrapper(self, interaction: Interaction, *args, **kwargs): + user_id = interaction.user.id + command_name = interaction.command.name if interaction.command else "unknown" + + # Check if user is bot owner + if is_bot_owner(user_id): + logger.info( + f"Owner command authorized: user={user_id}, command=/{command_name}" + ) + return await func(self, interaction, *args, **kwargs) + + # Permission denied + logger.warning( + f"Owner command denied: user={user_id}, command=/{command_name}, " + f"guild={interaction.guild_id}, reason=not_bot_owner" + ) + + embed = get_permission_embed( + title="❌ Bot Owner Only", + description="This command is restricted to the bot owner only.", + required_permission="Bot Owner" + ) + + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await interaction.response.send_message(embed=embed, ephemeral=True) + + return None + + return wrapper + + +def check_permissions(interaction: Interaction) -> tuple[bool, Optional[str]]: + """Check user permissions and return status with reason.""" + user_id = interaction.user.id + + # Check bot owner + if is_bot_owner(user_id): + return True, None + + # Check administrator + if is_admin(interaction): + return True, None + + # Permission denied + return False, "User is neither bot owner nor server administrator" diff --git a/env.example b/env.example index 6ed55bcc..f2bc99a1 100644 --- a/env.example +++ b/env.example @@ -6,6 +6,9 @@ TAVILY_API_KEY=your_tavily_api_key_here DISCORD_BOT_TOKEN=your_discord_bot_token_here GITHUB_TOKEN=your_github_token_here +# Discord Bot Configuration (for admin commands) +BOT_OWNER_ID=your_discord_user_id_here + # Database SUPABASE_URL=your_supabase_url_here SUPABASE_KEY=your_supabase_anon_key_here diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py new file mode 100644 index 00000000..40827a08 --- /dev/null +++ b/tests/test_admin_permissions.py @@ -0,0 +1,243 @@ +"""Tests for Discord bot admin permission system - Simple version without pytest/asyncio.""" + +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from unittest.mock import Mock, patch +import discord +from discord import Interaction, Member, Guild, User + +from backend.integrations.discord.permissions import ( + is_bot_owner, + is_admin, + get_permission_embed, + check_permissions +) + + +def create_mock_interaction(user_id=999999999, guild_id=987654321, command_name="test_command"): + """Create a mock Discord interaction.""" + interaction = Mock(spec=Interaction) + interaction.user = Mock(spec=User) + interaction.user.id = user_id + interaction.guild = Mock(spec=Guild) + interaction.guild_id = guild_id + interaction.command = Mock() + interaction.command.name = command_name + interaction.response = Mock() + interaction.response.is_done = Mock(return_value=False) + interaction.response.send_message = Mock() + interaction.followup = Mock() + interaction.followup.send = Mock() + return interaction + + +def create_mock_admin_member(): + """Create a mock member with administrator permissions.""" + member = Mock(spec=Member) + member.guild_permissions = Mock() + member.guild_permissions.administrator = True + return member + + +def create_mock_regular_member(): + """Create a mock member without administrator permissions.""" + member = Mock(spec=Member) + member.guild_permissions = Mock() + member.guild_permissions.administrator = False + return member + + +# Test is_bot_owner function +def test_bot_owner_returns_true(): + """Test that bot owner ID returns True.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = 123456789 + result = is_bot_owner(123456789) + assert result is True, "Bot owner should return True" + print("✓ test_bot_owner_returns_true") + + +def test_non_owner_returns_false(): + """Test that non-owner ID returns False.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = 123456789 + result = is_bot_owner(999999999) + assert result is False, "Non-owner should return False" + print("✓ test_non_owner_returns_false") + + +def test_no_bot_owner_configured(): + """Test behavior when BOT_OWNER_ID is not configured.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = None + result = is_bot_owner(123456789) + assert result is False, "Should return False when owner not configured" + print("✓ test_no_bot_owner_configured") + + +# Test is_admin function +def test_admin_member_returns_true(): + """Test that administrator member returns True.""" + interaction = create_mock_interaction(user_id=111111111) + admin_member = create_mock_admin_member() + interaction.guild.get_member = Mock(return_value=admin_member) + + result = is_admin(interaction) + assert result is True, "Admin member should return True" + print("✓ test_admin_member_returns_true") + + +def test_regular_member_returns_false(): + """Test that regular member returns False.""" + interaction = create_mock_interaction(user_id=222222222) + regular_member = create_mock_regular_member() + interaction.guild.get_member = Mock(return_value=regular_member) + + result = is_admin(interaction) + assert result is False, "Regular member should return False" + print("✓ test_regular_member_returns_false") + + +def test_no_guild_returns_false(): + """Test that DM context (no guild) returns False.""" + interaction = create_mock_interaction() + interaction.guild = None + + result = is_admin(interaction) + assert result is False, "No guild should return False" + print("✓ test_no_guild_returns_false") + + +def test_member_not_found_returns_false(): + """Test that missing member returns False.""" + interaction = create_mock_interaction() + interaction.guild.get_member = Mock(return_value=None) + + result = is_admin(interaction) + assert result is False, "Missing member should return False" + print("✓ test_member_not_found_returns_false") + + +# Test get_permission_embed function +def test_default_embed_creation(): + """Test creating embed with default parameters.""" + embed = get_permission_embed() + + assert embed.title == "❌ Permission Denied", "Default title should match" + assert "don't have permission" in embed.description, "Default description should match" + assert embed.color == discord.Color.red(), "Color should be red" + assert len(embed.fields) == 2, "Should have 2 fields" + print("✓ test_default_embed_creation") + + +def test_custom_embed_creation(): + """Test creating embed with custom parameters.""" + embed = get_permission_embed( + title="Custom Title", + description="Custom description", + required_permission="Custom Permission" + ) + + assert embed.title == "Custom Title", "Custom title should match" + assert embed.description == "Custom description", "Custom description should match" + assert "Custom Permission" in embed.fields[0].value, "Custom permission should be in field" + print("✓ test_custom_embed_creation") + + +# Test check_permissions function +def test_check_permissions_bot_owner(): + """Test permission check for bot owner.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = 123456789 + + interaction = create_mock_interaction(user_id=123456789) + has_permission, reason = check_permissions(interaction) + + assert has_permission is True, "Bot owner should have permission" + assert reason is None, "No reason for allowed access" + print("✓ test_check_permissions_bot_owner") + + +def test_check_permissions_admin(): + """Test permission check for administrator.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = 123456789 + + interaction = create_mock_interaction(user_id=999999999) + admin_member = create_mock_admin_member() + interaction.guild.get_member = Mock(return_value=admin_member) + + has_permission, reason = check_permissions(interaction) + + assert has_permission is True, "Admin should have permission" + assert reason is None, "No reason for allowed access" + print("✓ test_check_permissions_admin") + + +def test_check_permissions_denied(): + """Test permission check for regular user.""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings: + mock_settings.bot_owner_id = 123456789 + + interaction = create_mock_interaction(user_id=999999999) + regular_member = create_mock_regular_member() + interaction.guild.get_member = Mock(return_value=regular_member) + + has_permission, reason = check_permissions(interaction) + + assert has_permission is False, "Regular user should be denied" + assert reason is not None, "Should have denial reason" + assert "neither bot owner nor server administrator" in reason, "Reason should explain denial" + print("✓ test_check_permissions_denied") + + +# Run all tests +def run_all_tests(): + """Run all permission system tests.""" + print("\n=== Running Permission System Tests ===\n") + + tests = [ + test_bot_owner_returns_true, + test_non_owner_returns_false, + test_no_bot_owner_configured, + test_admin_member_returns_true, + test_regular_member_returns_false, + test_no_guild_returns_false, + test_member_not_found_returns_false, + test_default_embed_creation, + test_custom_embed_creation, + test_check_permissions_bot_owner, + test_check_permissions_admin, + test_check_permissions_denied, + ] + + failed_tests = [] + + for test in tests: + try: + test() + except AssertionError as e: + print(f"✗ {test.__name__}: {e}") + failed_tests.append(test.__name__) + except Exception as e: + print(f"✗ {test.__name__}: Unexpected error - {e}") + failed_tests.append(test.__name__) + + print(f"\n=== Test Results ===") + print(f"Passed: {len(tests) - len(failed_tests)}/{len(tests)}") + + if failed_tests: + print(f"Failed: {', '.join(failed_tests)}") + return False + else: + print("All tests passed! ✓") + return True + + +if __name__ == "__main__": + success = run_all_tests() + exit(0 if success else 1) From 312cac2fe6903eb1e4f0baeb07b113ae265c8545 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Tue, 6 Jan 2026 19:59:31 +0530 Subject: [PATCH 03/18] feat(discord): implement admin permission system --- backend/app/core/config/settings.py | 4 +- backend/integrations/discord/permissions.py | 141 +++++++------------- env.example | 2 - tests/test_admin_permissions.py | 137 +++++++++---------- 4 files changed, 119 insertions(+), 165 deletions(-) diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index 2fd4927a..4d3eff9c 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -15,9 +15,7 @@ class Settings(BaseSettings): # Platforms github_token: str = "" discord_bot_token: str = "" - - # Discord Bot Configuration - bot_owner_id: Optional[int] = None # Discord user ID of the bot owner (for admin commands) + bot_owner_id: Optional[int] = None # DB configuration supabase_url: str diff --git a/backend/integrations/discord/permissions.py b/backend/integrations/discord/permissions.py index 922bc9bf..3b4521be 100644 --- a/backend/integrations/discord/permissions.py +++ b/backend/integrations/discord/permissions.py @@ -1,36 +1,24 @@ -"""Permission management system for Discord bot admin commands.""" - -import logging from functools import wraps -from typing import Callable, Optional - +from typing import Optional, Tuple import discord -from discord import app_commands, Interaction +from discord import Interaction +import logging -from backend.app.core.config import settings +from backend.app.core.config.settings import settings logger = logging.getLogger(__name__) def is_bot_owner(user_id: int) -> bool: - """Check if a user is the bot owner.""" - if not settings.bot_owner_id: - logger.warning("BOT_OWNER_ID not configured - owner checks will fail") - return False - return user_id == settings.bot_owner_id + return settings.bot_owner_id is not None and user_id == settings.bot_owner_id def is_admin(interaction: Interaction) -> bool: - """Check if a user has administrator permissions in the guild.""" if not interaction.guild: - logger.debug(f"Admin check failed for user {interaction.user.id}: No guild context") return False - member = interaction.guild.get_member(interaction.user.id) if not member: - logger.debug(f"Admin check failed for user {interaction.user.id}: Member not found in guild") return False - return member.guild_permissions.administrator @@ -39,116 +27,83 @@ def get_permission_embed( description: str = "You don't have permission to use this command.", required_permission: str = "Administrator or Bot Owner" ) -> discord.Embed: - """Create a standardized permission denied embed.""" embed = discord.Embed( title=title, description=description, color=discord.Color.red() ) - embed.add_field( - name="Required Permission", - value=f"`{required_permission}`", - inline=False - ) - embed.add_field( - name="💡 Need Access?", - value="Contact your server administrator or the bot owner.", - inline=False - ) - embed.set_footer(text="This action has been logged for security purposes.") + embed.add_field(name="Required Permission", value=required_permission, inline=False) + embed.add_field(name="Contact", value="Contact the bot owner if you believe this is an error.", inline=False) return embed -def require_admin(func: Callable) -> Callable: +def require_admin(func): @wraps(func) async def wrapper(self, interaction: Interaction, *args, **kwargs): - user_id = interaction.user.id - command_name = interaction.command.name if interaction.command else "unknown" - - # Check if user is bot owner - if is_bot_owner(user_id): - logger.info( - f"Admin command authorized: user={user_id} (BOT OWNER), " - f"command=/{command_name}" - ) - return await func(self, interaction, *args, **kwargs) + has_permission, reason = check_permissions(interaction) - # Check if user is administrator in guild - if is_admin(interaction): - logger.info( - f"Admin command authorized: user={user_id} (ADMINISTRATOR), " - f"command=/{command_name}, guild={interaction.guild_id}" + if not has_permission: + logger.warning( + f"Admin command denied: user={interaction.user.id} " + f"command={interaction.command.name if interaction.command else 'unknown'} " + f"reason={reason}" ) - return await func(self, interaction, *args, **kwargs) - # Permission denied - logger.warning( - f"Admin command denied: user={user_id}, command=/{command_name}, " - f"guild={interaction.guild_id}, reason=insufficient_permissions" - ) + embed = get_permission_embed() - embed = get_permission_embed( - description="This command is restricted to server administrators and the bot owner.", - required_permission="Administrator or Bot Owner" - ) + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await interaction.response.send_message(embed=embed, ephemeral=True) + return None - if interaction.response.is_done(): - await interaction.followup.send(embed=embed, ephemeral=True) - else: - await interaction.response.send_message(embed=embed, ephemeral=True) + logger.info( + f"Admin command authorized: user={interaction.user.id} " + f"is_owner={is_bot_owner(interaction.user.id)} " + f"command={interaction.command.name if interaction.command else 'unknown'}" + ) - return None + return await func(self, interaction, *args, **kwargs) return wrapper -def require_bot_owner(func: Callable) -> Callable: - """Decorator to restrict command access to bot owner only.""" +def require_bot_owner(func): @wraps(func) async def wrapper(self, interaction: Interaction, *args, **kwargs): - user_id = interaction.user.id - command_name = interaction.command.name if interaction.command else "unknown" + if not is_bot_owner(interaction.user.id): + logger.warning( + f"Bot owner command denied: user={interaction.user.id} " + f"command={interaction.command.name if interaction.command else 'unknown'}" + ) - # Check if user is bot owner - if is_bot_owner(user_id): - logger.info( - f"Owner command authorized: user={user_id}, command=/{command_name}" + embed = get_permission_embed( + title="❌ Bot Owner Only", + description="This command can only be used by the bot owner.", + required_permission="Bot Owner" ) - return await func(self, interaction, *args, **kwargs) - # Permission denied - logger.warning( - f"Owner command denied: user={user_id}, command=/{command_name}, " - f"guild={interaction.guild_id}, reason=not_bot_owner" - ) + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await interaction.response.send_message(embed=embed, ephemeral=True) + return None - embed = get_permission_embed( - title="❌ Bot Owner Only", - description="This command is restricted to the bot owner only.", - required_permission="Bot Owner" + logger.info( + f"Bot owner command authorized: user={interaction.user.id} " + f"command={interaction.command.name if interaction.command else 'unknown'}" ) - if interaction.response.is_done(): - await interaction.followup.send(embed=embed, ephemeral=True) - else: - await interaction.response.send_message(embed=embed, ephemeral=True) - - return None + return await func(self, interaction, *args, **kwargs) return wrapper -def check_permissions(interaction: Interaction) -> tuple[bool, Optional[str]]: - """Check user permissions and return status with reason.""" - user_id = interaction.user.id - - # Check bot owner - if is_bot_owner(user_id): +def check_permissions(interaction: Interaction) -> Tuple[bool, Optional[str]]: + if is_bot_owner(interaction.user.id): return True, None - # Check administrator if is_admin(interaction): return True, None - # Permission denied - return False, "User is neither bot owner nor server administrator" + return False, f"User {interaction.user.id} is neither bot owner nor server administrator" diff --git a/env.example b/env.example index f2bc99a1..bda25ebf 100644 --- a/env.example +++ b/env.example @@ -5,8 +5,6 @@ TAVILY_API_KEY=your_tavily_api_key_here # Platform Integrations DISCORD_BOT_TOKEN=your_discord_bot_token_here GITHUB_TOKEN=your_github_token_here - -# Discord Bot Configuration (for admin commands) BOT_OWNER_ID=your_discord_user_id_here # Database diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index 40827a08..ccfae9ac 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,14 +1,3 @@ -"""Tests for Discord bot admin permission system - Simple version without pytest/asyncio.""" - -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from unittest.mock import Mock, patch -import discord -from discord import Interaction, Member, Guild, User from backend.integrations.discord.permissions import ( is_bot_owner, @@ -16,6 +5,13 @@ get_permission_embed, check_permissions ) +from discord import Interaction, Member, Guild, User +import discord +from unittest.mock import Mock, patch +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) def create_mock_interaction(user_id=999999999, guild_id=987654321, command_name="test_command"): @@ -53,7 +49,6 @@ def create_mock_regular_member(): # Test is_bot_owner function def test_bot_owner_returns_true(): - """Test that bot owner ID returns True.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = 123456789 result = is_bot_owner(123456789) @@ -62,7 +57,6 @@ def test_bot_owner_returns_true(): def test_non_owner_returns_false(): - """Test that non-owner ID returns False.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = 123456789 result = is_bot_owner(999999999) @@ -71,7 +65,6 @@ def test_non_owner_returns_false(): def test_no_bot_owner_configured(): - """Test behavior when BOT_OWNER_ID is not configured.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = None result = is_bot_owner(123456789) @@ -81,42 +74,38 @@ def test_no_bot_owner_configured(): # Test is_admin function def test_admin_member_returns_true(): - """Test that administrator member returns True.""" interaction = create_mock_interaction(user_id=111111111) admin_member = create_mock_admin_member() interaction.guild.get_member = Mock(return_value=admin_member) - + result = is_admin(interaction) assert result is True, "Admin member should return True" print("✓ test_admin_member_returns_true") def test_regular_member_returns_false(): - """Test that regular member returns False.""" interaction = create_mock_interaction(user_id=222222222) regular_member = create_mock_regular_member() interaction.guild.get_member = Mock(return_value=regular_member) - + result = is_admin(interaction) assert result is False, "Regular member should return False" print("✓ test_regular_member_returns_false") def test_no_guild_returns_false(): - """Test that DM context (no guild) returns False.""" interaction = create_mock_interaction() interaction.guild = None - + result = is_admin(interaction) assert result is False, "No guild should return False" print("✓ test_no_guild_returns_false") def test_member_not_found_returns_false(): - """Test that missing member returns False.""" interaction = create_mock_interaction() interaction.guild.get_member = Mock(return_value=None) - + result = is_admin(interaction) assert result is False, "Missing member should return False" print("✓ test_member_not_found_returns_false") @@ -124,9 +113,8 @@ def test_member_not_found_returns_false(): # Test get_permission_embed function def test_default_embed_creation(): - """Test creating embed with default parameters.""" embed = get_permission_embed() - + assert embed.title == "❌ Permission Denied", "Default title should match" assert "don't have permission" in embed.description, "Default description should match" assert embed.color == discord.Color.red(), "Color should be red" @@ -135,13 +123,12 @@ def test_default_embed_creation(): def test_custom_embed_creation(): - """Test creating embed with custom parameters.""" embed = get_permission_embed( title="Custom Title", description="Custom description", required_permission="Custom Permission" ) - + assert embed.title == "Custom Title", "Custom title should match" assert embed.description == "Custom description", "Custom description should match" assert "Custom Permission" in embed.fields[0].value, "Custom permission should be in field" @@ -150,45 +137,42 @@ def test_custom_embed_creation(): # Test check_permissions function def test_check_permissions_bot_owner(): - """Test permission check for bot owner.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = 123456789 - + interaction = create_mock_interaction(user_id=123456789) has_permission, reason = check_permissions(interaction) - + assert has_permission is True, "Bot owner should have permission" assert reason is None, "No reason for allowed access" print("✓ test_check_permissions_bot_owner") def test_check_permissions_admin(): - """Test permission check for administrator.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = 123456789 - + interaction = create_mock_interaction(user_id=999999999) admin_member = create_mock_admin_member() interaction.guild.get_member = Mock(return_value=admin_member) - + has_permission, reason = check_permissions(interaction) - + assert has_permission is True, "Admin should have permission" assert reason is None, "No reason for allowed access" print("✓ test_check_permissions_admin") def test_check_permissions_denied(): - """Test permission check for regular user.""" with patch("backend.integrations.discord.permissions.settings") as mock_settings: mock_settings.bot_owner_id = 123456789 - + interaction = create_mock_interaction(user_id=999999999) regular_member = create_mock_regular_member() interaction.guild.get_member = Mock(return_value=regular_member) - + has_permission, reason = check_permissions(interaction) - + assert has_permission is False, "Regular user should be denied" assert reason is not None, "Should have denial reason" assert "neither bot owner nor server administrator" in reason, "Reason should explain denial" @@ -198,43 +182,62 @@ def test_check_permissions_denied(): # Run all tests def run_all_tests(): """Run all permission system tests.""" - print("\n=== Running Permission System Tests ===\n") - + print("\n" + "="*50) + print("Permission System Tests") + print("="*50 + "\n") + tests = [ - test_bot_owner_returns_true, - test_non_owner_returns_false, - test_no_bot_owner_configured, - test_admin_member_returns_true, - test_regular_member_returns_false, - test_no_guild_returns_false, - test_member_not_found_returns_false, - test_default_embed_creation, - test_custom_embed_creation, - test_check_permissions_bot_owner, - test_check_permissions_admin, - test_check_permissions_denied, + ("Bot Owner Checks", [ + test_bot_owner_returns_true, + test_non_owner_returns_false, + test_no_bot_owner_configured, + ]), + ("Admin Checks", [ + test_admin_member_returns_true, + test_regular_member_returns_false, + test_no_guild_returns_false, + test_member_not_found_returns_false, + ]), + ("Embed Generation", [ + test_default_embed_creation, + test_custom_embed_creation, + ]), + ("Permission Checks", [ + test_check_permissions_bot_owner, + test_check_permissions_admin, + test_check_permissions_denied, + ]), ] - + + total_tests = 0 failed_tests = [] - - for test in tests: - try: - test() - except AssertionError as e: - print(f"✗ {test.__name__}: {e}") - failed_tests.append(test.__name__) - except Exception as e: - print(f"✗ {test.__name__}: Unexpected error - {e}") - failed_tests.append(test.__name__) - - print(f"\n=== Test Results ===") - print(f"Passed: {len(tests) - len(failed_tests)}/{len(tests)}") - + + for category, category_tests in tests: + print(f"{category}:") + for test in category_tests: + total_tests += 1 + try: + test() + except AssertionError as e: + print(f" ✗ {test.__name__}: {e}") + failed_tests.append((category, test.__name__, str(e))) + except Exception as e: + print(f" ✗ {test.__name__}: Unexpected error - {e}") + failed_tests.append((category, test.__name__, f"Unexpected error: {e}")) + print() + + print("="*50) + print(f"Results: {total_tests - len(failed_tests)}/{total_tests} tests passed") + if failed_tests: - print(f"Failed: {', '.join(failed_tests)}") + print(f"\nFailed tests ({len(failed_tests)}):") + for category, test_name, error in failed_tests: + print(f" [{category}] {test_name}: {error}") + print("="*50) return False else: print("All tests passed! ✓") + print("="*50) return True From e1484712beac2284968c9230d7cc4a03e4465d70 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 14 Jan 2026 19:31:04 +0530 Subject: [PATCH 04/18] Add admin action logging system --- backend/app/utils/admin_logger.py | 216 ++++++++++++++++++ .../database/02_create_admin_logs_table.sql | 52 +++++ .../integrations/discord/admin_cog_example.py | 67 ++++++ backend/integrations/discord/permissions.py | 175 +++++++++++++- 4 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 backend/app/utils/admin_logger.py create mode 100644 backend/database/02_create_admin_logs_table.sql create mode 100644 backend/integrations/discord/admin_cog_example.py diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py new file mode 100644 index 00000000..73ae2c21 --- /dev/null +++ b/backend/app/utils/admin_logger.py @@ -0,0 +1,216 @@ +import logging +from typing import Dict, Any, Optional, List +from datetime import datetime +import uuid + +from app.database.supabase.client import get_supabase_client + +logger = logging.getLogger(__name__) + + +async def log_admin_action( + executor_id: str, + executor_username: str, + command_name: str, + server_id: str, + action_result: str = "success", + command_args: Optional[Dict[str, Any]] = None, + target_id: Optional[str] = None, + error_message: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + """Log admin command execution to database. Returns log UUID or None if failed.""" + try: + supabase = get_supabase_client() + + # Validate action_result + if action_result not in ["success", "failure", "error"]: + logger.warning(f"Invalid action_result '{action_result}', defaulting to 'error'") + action_result = "error" + + # Prepare log entry + log_entry = { + "id": str(uuid.uuid4()), + "timestamp": datetime.now().isoformat(), + "executor_id": executor_id, + "executor_username": executor_username, + "command_name": command_name, + "command_args": command_args or {}, + "target_id": target_id, + "action_result": action_result, + "error_message": error_message, + "server_id": server_id, + "metadata": metadata or {}, + } + + # Insert log entry + response = await supabase.table("admin_logs").insert(log_entry).execute() + + if response.data: + log_id = response.data[0]["id"] + logger.info( + f"Admin action logged: id={log_id}, executor={executor_username}, " + f"command={command_name}, result={action_result}" + ) + return log_id + else: + logger.error(f"Failed to log admin action: {response}") + return None + + except Exception as e: + logger.error(f"Error logging admin action: {str(e)}", exc_info=True) + return None + + +async def get_admin_logs( + executor_id: Optional[str] = None, + command_name: Optional[str] = None, + server_id: Optional[str] = None, + action_result: Optional[str] = None, + target_id: Optional[str] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + limit: int = 100, + offset: int = 0, +) -> List[Dict[str, Any]]: + """Get admin logs with optional filtering. Supports pagination via limit/offset.""" + try: + supabase = get_supabase_client() + + # Enforce maximum limit + limit = min(limit, 1000) + + # Build query + query = supabase.table("admin_logs").select("*") + + # Apply filters + if executor_id: + query = query.eq("executor_id", executor_id) + + if command_name: + query = query.eq("command_name", command_name) + + if server_id: + query = query.eq("server_id", server_id) + + if action_result: + if action_result not in ["success", "failure", "error"]: + logger.warning(f"Invalid action_result filter: {action_result}") + else: + query = query.eq("action_result", action_result) + + if target_id: + query = query.eq("target_id", target_id) + + if start_time: + query = query.gte("timestamp", start_time.isoformat()) + + if end_time: + query = query.lte("timestamp", end_time.isoformat()) + + # Order by timestamp (newest first) and apply pagination + query = query.order("timestamp", desc=True).range(offset, offset + limit - 1) + + # Execute query + response = await query.execute() + + if response.data: + logger.info( + f"Retrieved {len(response.data)} admin logs " + f"(limit={limit}, offset={offset})" + ) + return response.data + else: + logger.info("No admin logs found matching the criteria") + return [] + + except Exception as e: + logger.error(f"Error retrieving admin logs: {str(e)}", exc_info=True) + return [] + + +async def get_admin_log_stats( + server_id: Optional[str] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, +) -> Dict[str, Any]: + """Get usage stats for admin commands including success rates and top executors.""" + try: + supabase = get_supabase_client() + + # Build base query + query = supabase.table("admin_logs").select("*") + + if server_id: + query = query.eq("server_id", server_id) + + if start_time: + query = query.gte("timestamp", start_time.isoformat()) + + if end_time: + query = query.lte("timestamp", end_time.isoformat()) + + # Get all matching logs + response = await query.execute() + + if not response.data: + return { + "total_commands": 0, + "success_count": 0, + "failure_count": 0, + "error_count": 0, + "commands_by_type": {}, + "top_executors": [], + } + + logs = response.data + + # Calculate statistics + total_commands = len(logs) + success_count = sum(1 for log in logs if log["action_result"] == "success") + failure_count = sum(1 for log in logs if log["action_result"] == "failure") + error_count = sum(1 for log in logs if log["action_result"] == "error") + + # Count commands by type + commands_by_type = {} + for log in logs: + cmd = log["command_name"] + commands_by_type[cmd] = commands_by_type.get(cmd, 0) + 1 + + # Count by executor + executor_counts = {} + for log in logs: + executor = log["executor_username"] + executor_counts[executor] = executor_counts.get(executor, 0) + 1 + + # Sort executors by count + top_executors = [ + {"username": username, "count": count} + for username, count in sorted( + executor_counts.items(), key=lambda x: x[1], reverse=True + )[:10] + ] + + stats = { + "total_commands": total_commands, + "success_count": success_count, + "failure_count": failure_count, + "error_count": error_count, + "success_rate": round((success_count / total_commands * 100), 2) if total_commands > 0 else 0, + "commands_by_type": commands_by_type, + "top_executors": top_executors, + } + + logger.info(f"Generated admin log statistics: {total_commands} total commands") + return stats + + except Exception as e: + logger.error(f"Error getting admin log stats: {str(e)}", exc_info=True) + return { + "total_commands": 0, + "success_count": 0, + "failure_count": 0, + "error_count": 0, + "commands_by_type": {}, + "top_executors": [], + } diff --git a/backend/database/02_create_admin_logs_table.sql b/backend/database/02_create_admin_logs_table.sql new file mode 100644 index 00000000..e7b1a62b --- /dev/null +++ b/backend/database/02_create_admin_logs_table.sql @@ -0,0 +1,52 @@ +-- Table for storing admin action logs +CREATE TABLE IF NOT EXISTS admin_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + executor_id TEXT NOT NULL, -- Discord user ID + executor_username TEXT NOT NULL, + command_name TEXT NOT NULL, + command_args JSONB DEFAULT '{}', -- Store command parameters + target_id TEXT, -- Target user/queue if applicable + action_result TEXT NOT NULL CHECK (action_result IN ('success', 'failure', 'error')), + error_message TEXT, + server_id TEXT NOT NULL, -- Discord server/guild ID + metadata JSONB DEFAULT '{}' -- Additional context (e.g., queue state, bot status, etc.) +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_admin_logs_timestamp ON admin_logs(timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_admin_logs_executor_id ON admin_logs(executor_id); +CREATE INDEX IF NOT EXISTS idx_admin_logs_command_name ON admin_logs(command_name); +CREATE INDEX IF NOT EXISTS idx_admin_logs_action_result ON admin_logs(action_result); +CREATE INDEX IF NOT EXISTS idx_admin_logs_server_id ON admin_logs(server_id); +CREATE INDEX IF NOT EXISTS idx_admin_logs_target_id ON admin_logs(target_id) WHERE target_id IS NOT NULL; + +-- Composite index for common query patterns +CREATE INDEX IF NOT EXISTS idx_admin_logs_executor_timestamp ON admin_logs(executor_id, timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_admin_logs_command_timestamp ON admin_logs(command_name, timestamp DESC); + +-- Enable Row Level Security (RLS) +ALTER TABLE admin_logs ENABLE ROW LEVEL SECURITY; + +-- Create RLS policies for admin_logs +-- Only authenticated users can view logs (typically admin dashboard access) +CREATE POLICY "Authenticated users can view admin logs" + ON admin_logs + FOR SELECT + USING (auth.role() = 'authenticated'); + +-- Service role can insert logs (for bot to write logs) +CREATE POLICY "Service role can insert admin logs" + ON admin_logs + FOR INSERT + WITH CHECK (auth.role() = 'service_role' OR auth.role() = 'authenticated'); + +-- Add helpful comments +COMMENT ON TABLE admin_logs IS 'Tracks all admin command executions for audit trail'; +COMMENT ON COLUMN admin_logs.executor_id IS 'Discord user ID of the admin who executed the command'; +COMMENT ON COLUMN admin_logs.command_name IS 'Name of the admin command (e.g., /admin stats, /admin pause_queue)'; +COMMENT ON COLUMN admin_logs.command_args IS 'JSON object containing command parameters and arguments'; +COMMENT ON COLUMN admin_logs.target_id IS 'ID of the target entity if applicable (user ID, queue name, etc.)'; +COMMENT ON COLUMN admin_logs.action_result IS 'Result of the command execution: success, failure, or error'; +COMMENT ON COLUMN admin_logs.server_id IS 'Discord server/guild ID where the command was executed'; +COMMENT ON COLUMN admin_logs.metadata IS 'Additional contextual information (e.g., system state before/after)'; diff --git a/backend/integrations/discord/admin_cog_example.py b/backend/integrations/discord/admin_cog_example.py new file mode 100644 index 00000000..84f2270e --- /dev/null +++ b/backend/integrations/discord/admin_cog_example.py @@ -0,0 +1,67 @@ +import discord +from discord import app_commands, Interaction +from discord.ext import commands + +from integrations.discord.permissions import require_admin, require_bot_owner + + +class AdminCommands(commands.GroupCog, name="admin"): + """Admin command group for bot management and monitoring""" + + def __init__(self, bot): + self.bot = bot + super().__init__() + + @app_commands.command(name="stats", description="Display bot statistics") + @require_admin + async def stats(self, interaction: Interaction): + """Show bot stats like server count and latency.""" + try: + # Gather stats + guild_count = len(self.bot.guilds) + total_members = sum(guild.member_count for guild in self.bot.guilds) + + embed = discord.Embed( + title="Bot Statistics", + color=discord.Color.blue() + ) + embed.add_field(name="Servers", value=str(guild_count), inline=True) + embed.add_field(name="Total Members", value=str(total_members), inline=True) + embed.add_field(name="Latency", value=f"{round(self.bot.latency * 1000)}ms", inline=True) + + await interaction.response.send_message(embed=embed) + + except Exception as e: + await interaction.response.send_message( + f"Error gathering statistics: {str(e)}", + ephemeral=True + ) + raise # Re-raise for logging + + @app_commands.command(name="test", description="Test admin command with parameters") + @require_admin + async def test( + self, + interaction: Interaction, + message: str, + count: int = 1 + ): + """Test command to verify parameter logging works correctly.""" + await interaction.response.send_message( + f"Test executed! Message: {message}, Count: {count}", + ephemeral=True + ) + + @app_commands.command(name="owner_only", description="Bot owner only command") + @require_bot_owner + async def owner_only(self, interaction: Interaction): + """Example command restricted to bot owner only.""" + await interaction.response.send_message( + "This command can only be run by the bot owner!", + ephemeral=True + ) + + +async def setup(bot): + """Register admin commands cog.""" + await bot.add_cog(AdminCommands(bot)) diff --git a/backend/integrations/discord/permissions.py b/backend/integrations/discord/permissions.py index 3b4521be..cdd0ce67 100644 --- a/backend/integrations/discord/permissions.py +++ b/backend/integrations/discord/permissions.py @@ -1,10 +1,12 @@ from functools import wraps -from typing import Optional, Tuple +from typing import Optional, Tuple, Dict, Any import discord from discord import Interaction import logging +import inspect from backend.app.core.config.settings import settings +from backend.app.utils.admin_logger import log_admin_action logger = logging.getLogger(__name__) @@ -23,7 +25,7 @@ def is_admin(interaction: Interaction) -> bool: def get_permission_embed( - title: str = "❌ Permission Denied", + title: str = "Permission Denied", description: str = "You don't have permission to use this command.", required_permission: str = "Administrator or Bot Owner" ) -> discord.Embed: @@ -49,6 +51,20 @@ async def wrapper(self, interaction: Interaction, *args, **kwargs): f"reason={reason}" ) + # Log failed permission check + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result='failure', + error_message=f"Permission denied: {reason}", + metadata={ + 'is_owner': is_bot_owner(interaction.user.id), + 'is_admin': is_admin(interaction), + } + ) + embed = get_permission_embed() if interaction.response.is_done(): @@ -63,7 +79,56 @@ async def wrapper(self, interaction: Interaction, *args, **kwargs): f"command={interaction.command.name if interaction.command else 'unknown'}" ) - return await func(self, interaction, *args, **kwargs) + # Extract command arguments for logging + command_args = _extract_command_args(func, args, kwargs) + + # Execute the command and track success/failure + action_result = 'success' + error_message = None + metadata = { + 'is_owner': is_bot_owner(interaction.user.id), + 'is_admin': is_admin(interaction), + } + + try: + result = await func(self, interaction, *args, **kwargs) + + # Log successful execution + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result=action_result, + command_args=command_args, + metadata=metadata + ) + + return result + + except Exception as e: + action_result = 'error' + error_message = str(e) + logger.error( + f"Admin command error: user={interaction.user.id} " + f"command={interaction.command.name if interaction.command else 'unknown'} " + f"error={error_message}" + ) + + # Log error + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result=action_result, + command_args=command_args, + error_message=error_message, + metadata=metadata + ) + + # Re-raise the exception + raise return wrapper @@ -77,8 +142,22 @@ async def wrapper(self, interaction: Interaction, *args, **kwargs): f"command={interaction.command.name if interaction.command else 'unknown'}" ) + # Log failed permission check + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result='failure', + error_message="Permission denied: Bot owner only", + metadata={ + 'is_owner': False, + 'required_permission': 'bot_owner', + } + ) + embed = get_permission_embed( - title="❌ Bot Owner Only", + title="Bot Owner Only", description="This command can only be used by the bot owner.", required_permission="Bot Owner" ) @@ -94,7 +173,56 @@ async def wrapper(self, interaction: Interaction, *args, **kwargs): f"command={interaction.command.name if interaction.command else 'unknown'}" ) - return await func(self, interaction, *args, **kwargs) + # Extract command arguments for logging + command_args = _extract_command_args(func, args, kwargs) + + # Execute the command and track success/failure + action_result = 'success' + error_message = None + metadata = { + 'is_owner': True, + 'required_permission': 'bot_owner', + } + + try: + result = await func(self, interaction, *args, **kwargs) + + # Log successful execution + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result=action_result, + command_args=command_args, + metadata=metadata + ) + + return result + + except Exception as e: + action_result = 'error' + error_message = str(e) + logger.error( + f"Bot owner command error: user={interaction.user.id} " + f"command={interaction.command.name if interaction.command else 'unknown'} " + f"error={error_message}" + ) + + # Log error + await log_admin_action( + executor_id=str(interaction.user.id), + executor_username=interaction.user.name, + command_name=interaction.command.name if interaction.command else 'unknown', + server_id=str(interaction.guild.id) if interaction.guild else 'dm', + action_result=action_result, + command_args=command_args, + error_message=error_message, + metadata=metadata + ) + + # Re-raise the exception + raise return wrapper @@ -107,3 +235,40 @@ def check_permissions(interaction: Interaction) -> Tuple[bool, Optional[str]]: return True, None return False, f"User {interaction.user.id} is neither bot owner nor server administrator" + + +def _extract_command_args(func, args: tuple, kwargs: dict) -> Dict[str, Any]: + """Extract command arguments from function call for logging.""" + try: + sig = inspect.signature(func) + param_names = list(sig.parameters.keys()) + + # Skip 'self' and 'interaction' parameters + param_names = [p for p in param_names if p not in ['self', 'interaction']] + + # Build argument dictionary + command_args = {} + + # Add positional arguments (skip first two: self, interaction) + for i, value in enumerate(args[2:] if len(args) > 2 else []): + if i < len(param_names): + # Convert to string for JSON serialization + serializable_types = (str, int, float, bool, type(None)) + command_args[param_names[i]] = ( + value if isinstance(value, serializable_types) else str(value) + ) + + # Add keyword arguments + for key, value in kwargs.items(): + if key not in ['self', 'interaction']: + # Convert to string for JSON serialization + serializable_types = (str, int, float, bool, type(None)) + command_args[key] = ( + value if isinstance(value, serializable_types) else str(value) + ) + + return command_args + + except Exception as e: + logger.warning(f"Failed to extract command arguments: {e}") + return {} From 2a5b35383f6406d8a57a2eae7794ea87bebea6d3 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 14 Jan 2026 19:47:45 +0530 Subject: [PATCH 05/18] feat(discord): add admin command group structure --- backend/integrations/discord/admin_cog.py | 237 ++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 backend/integrations/discord/admin_cog.py diff --git a/backend/integrations/discord/admin_cog.py b/backend/integrations/discord/admin_cog.py new file mode 100644 index 00000000..5649dd7b --- /dev/null +++ b/backend/integrations/discord/admin_cog.py @@ -0,0 +1,237 @@ +import logging +import discord +from discord import app_commands, Interaction +from discord.ext import commands + +from integrations.discord.bot import DiscordBot +from integrations.discord.permissions import require_admin +from app.core.orchestration.queue_manager import AsyncQueueManager + +logger = logging.getLogger(__name__) + + +class AdminCommands(commands.GroupCog, name="admin"): + """Bot management and monitoring commands""" + + def __init__(self, bot: DiscordBot, queue_manager: AsyncQueueManager): + self.bot = bot + self.queue = queue_manager + super().__init__() + + async def cog_command_error(self, interaction: Interaction, error: Exception): + """Handle errors for admin commands.""" + logger.error(f"Admin command error: {error}", exc_info=True) + + if isinstance(error, app_commands.MissingPermissions): + error_message = "You don't have permission to use this command." + elif isinstance(error, app_commands.CommandInvokeError): + error_message = f"Command failed: {str(error.original)}" + else: + error_message = "Something went wrong executing that command." + + try: + if interaction.response.is_done(): + await interaction.followup.send(error_message, ephemeral=True) + else: + await interaction.response.send_message(error_message, ephemeral=True) + except Exception as e: + logger.error(f"Failed to send error message: {e}") + + @app_commands.command(name="stats", description="Display bot statistics and metrics") + @require_admin + async def stats(self, interaction: Interaction): + """Show current bot stats.""" + await interaction.response.defer(ephemeral=True) + + try: + guild_count = len(self.bot.guilds) + total_members = sum(guild.member_count or 0 for guild in self.bot.guilds) + active_threads = len(self.bot.active_threads) + + embed = discord.Embed(title="Bot Statistics", color=discord.Color.blue()) + embed.add_field(name="Servers", value=str(guild_count), inline=True) + embed.add_field(name="Total Members", value=str(total_members), inline=True) + embed.add_field(name="Active Threads", value=str(active_threads), inline=True) + embed.add_field(name="Latency", value=f"{round(self.bot.latency * 1000)}ms", inline=True) + embed.set_footer(text="More detailed stats coming soon") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"Stats command failed: {e}", exc_info=True) + await interaction.followup.send(f"Failed to get stats: {str(e)}", ephemeral=True) + + @app_commands.command(name="health", description="Check system health and service status") + @require_admin + async def health(self, interaction: Interaction): + """Check if all services are running.""" + await interaction.response.defer(ephemeral=True) + + try: + # TODO: Add actual health checks for Supabase, Weaviate, RabbitMQ, etc + embed = discord.Embed(title="System Health", color=discord.Color.green()) + embed.add_field(name="Discord API", value="Healthy", inline=True) + embed.add_field(name="Bot Status", value="Running", inline=True) + embed.set_footer(text="Full service checks coming in next update") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"Health check error: {e}", exc_info=True) + await interaction.followup.send(f"Health check failed: {str(e)}", ephemeral=True) + + @app_commands.command(name="user_info", description="Get info about a user") + @app_commands.describe(user="User to look up") + @require_admin + async def user_info(self, interaction: Interaction, user: discord.User): + """Look up user details.""" + await interaction.response.defer(ephemeral=True) + + try: + member = interaction.guild.get_member(user.id) if interaction.guild else None + created = user.created_at.strftime("%Y-%m-%d") + + embed = discord.Embed(title="User Information", color=discord.Color.blue()) + embed.set_thumbnail(url=user.display_avatar.url) + embed.add_field(name="Username", value=user.name, inline=True) + embed.add_field(name="ID", value=str(user.id), inline=True) + embed.add_field(name="Created", value=created, inline=True) + + if member: + embed.add_field(name="Nickname", value=member.display_name or "None", inline=True) + embed.add_field(name="Roles", value=str(len(member.roles) - 1), inline=True) + + # TODO: Add verification status, message count, etc from database + embed.set_footer(text="More details coming soon") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"User info lookup failed: {e}", exc_info=True) + await interaction.followup.send(f"Couldn't get user info: {str(e)}", ephemeral=True) + + @app_commands.command(name="user_reset", description="Reset user state") + @app_commands.describe( + user="User to reset", + reset_memory="Clear conversation memory", + reset_thread="Close active thread", + reset_verification="Clear GitHub verification" + ) + @require_admin + async def user_reset( + self, + interaction: Interaction, + user: discord.User, + reset_memory: bool = False, + reset_thread: bool = False, + reset_verification: bool = False + ): + """Reset various aspects of user state.""" + await interaction.response.defer(ephemeral=True) + + if not any([reset_memory, reset_thread, reset_verification]): + await interaction.followup.send("Select at least one thing to reset.", ephemeral=True) + return + + try: + # TODO: Implement actual reset logic with confirmation dialog + actions = [] + if reset_memory: + actions.append("memory") + if reset_thread: + actions.append("thread") + if reset_verification: + actions.append("verification") + + embed = discord.Embed( + title="User Reset", + description=f"Would reset {', '.join(actions)} for {user.mention}", + color=discord.Color.orange() + ) + embed.set_footer(text="Need to add confirmation dialog first") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"User reset failed: {e}", exc_info=True) + await interaction.followup.send(f"Reset failed: {str(e)}", ephemeral=True) + + @app_commands.command(name="queue_status", description="Check queue status") + @require_admin + async def queue_status(self, interaction: Interaction): + """See what's in the queue.""" + await interaction.response.defer(ephemeral=True) + + try: + # TODO: Get actual queue stats from RabbitMQ + embed = discord.Embed(title="Queue Status", color=discord.Color.blue()) + embed.add_field(name="High", value="0 pending", inline=True) + embed.add_field(name="Medium", value="0 pending", inline=True) + embed.add_field(name="Low", value="0 pending", inline=True) + embed.set_footer(text="Need to wire up RabbitMQ stats") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"Queue status check failed: {e}", exc_info=True) + await interaction.followup.send(f"Couldn't get queue status: {str(e)}", ephemeral=True) + + @app_commands.command(name="queue_clear", description="Clear queue messages") + @app_commands.describe(priority="Which queue to clear") + @app_commands.choices(priority=[ + app_commands.Choice(name="All", value="all"), + app_commands.Choice(name="High", value="high"), + app_commands.Choice(name="Medium", value="medium"), + app_commands.Choice(name="Low", value="low") + ]) + @require_admin + async def queue_clear(self, interaction: Interaction, priority: str = "all"): + """Clear stuck messages from queue.""" + await interaction.response.defer(ephemeral=True) + + try: + # TODO: Add confirmation buttons before actually clearing + embed = discord.Embed( + title="Queue Clear", + description=f"This would clear {priority} priority queue(s).", + color=discord.Color.orange() + ) + embed.add_field(name="Warning", value="Can't undo this. Need confirmation dialog first.", inline=False) + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"Queue clear failed: {e}", exc_info=True) + await interaction.followup.send(f"Clear failed: {str(e)}", ephemeral=True) + + @app_commands.command(name="cache_clear", description="Clear cached data") + @app_commands.describe(cache_type="What to clear") + @app_commands.choices(cache_type=[ + app_commands.Choice(name="All", value="all"), + app_commands.Choice(name="Embeddings", value="embeddings"), + app_commands.Choice(name="Memories", value="memories") + ]) + @require_admin + async def cache_clear(self, interaction: Interaction, cache_type: str = "all"): + """Clear various caches.""" + await interaction.response.defer(ephemeral=True) + + try: + # TODO: Implement actual cache clearing + embed = discord.Embed( + title="Cache Clear", + description=f"Would clear {cache_type} cache.", + color=discord.Color.blue() + ) + embed.set_footer(text="Still need to implement this") + + await interaction.followup.send(embed=embed, ephemeral=True) + + except Exception as e: + logger.error(f"Cache clear failed: {e}", exc_info=True) + await interaction.followup.send(f"Clear failed: {str(e)}", ephemeral=True) + + +async def setup(bot: DiscordBot): + """Register the admin cog.""" + await bot.add_cog(AdminCommands(bot, bot.queue_manager)) From b657630911f511a8706eefffe62019c8d24c9a60 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Wed, 14 Jan 2026 20:13:16 +0530 Subject: [PATCH 06/18] fix: correct import paths in admin logging system --- backend/app/database/supabase/client.py | 2 +- backend/app/utils/admin_logger.py | 2 +- tests/test_admin_permissions.py | 456 +++++++++++++----------- 3 files changed, 240 insertions(+), 220 deletions(-) diff --git a/backend/app/database/supabase/client.py b/backend/app/database/supabase/client.py index b0f5fa7c..09868886 100644 --- a/backend/app/database/supabase/client.py +++ b/backend/app/database/supabase/client.py @@ -1,4 +1,4 @@ -from app.core.config import settings +from backend.app.core.config import settings from supabase._async.client import AsyncClient supabase_client: AsyncClient = AsyncClient( diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index 73ae2c21..00069f04 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -3,7 +3,7 @@ from datetime import datetime import uuid -from app.database.supabase.client import get_supabase_client +from backend.app.database.supabase.client import get_supabase_client logger = logging.getLogger(__name__) diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index ccfae9ac..c1d08f0f 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,246 +1,266 @@ +import sys +from pathlib import Path +import asyncio + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import discord +from discord import Interaction, Member, Guild, User +from unittest.mock import Mock, AsyncMock, patch from backend.integrations.discord.permissions import ( - is_bot_owner, - is_admin, + require_admin, + require_bot_owner, get_permission_embed, - check_permissions ) -from discord import Interaction, Member, Guild, User -import discord -from unittest.mock import Mock, patch -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) +class MockCog: + def __init__(self): + self.bot = Mock() -def create_mock_interaction(user_id=999999999, guild_id=987654321, command_name="test_command"): - """Create a mock Discord interaction.""" + +def create_mock_interaction(user_id=999999999, guild_id=987654321, command_name="test_command", is_admin_user=False): interaction = Mock(spec=Interaction) interaction.user = Mock(spec=User) interaction.user.id = user_id + interaction.user.name = "test_user" + interaction.guild = Mock(spec=Guild) - interaction.guild_id = guild_id + interaction.guild.id = guild_id + + member = Mock(spec=Member) + member.guild_permissions = Mock() + member.guild_permissions.administrator = is_admin_user + interaction.guild.get_member = Mock(return_value=member) + interaction.command = Mock() interaction.command.name = command_name + interaction.response = Mock() interaction.response.is_done = Mock(return_value=False) - interaction.response.send_message = Mock() + interaction.response.send_message = AsyncMock() + interaction.followup = Mock() - interaction.followup.send = Mock() + interaction.followup.send = AsyncMock() + return interaction -def create_mock_admin_member(): - """Create a mock member with administrator permissions.""" - member = Mock(spec=Member) - member.guild_permissions = Mock() - member.guild_permissions.administrator = True - return member - - -def create_mock_regular_member(): - """Create a mock member without administrator permissions.""" - member = Mock(spec=Member) - member.guild_permissions = Mock() - member.guild_permissions.administrator = False - return member - - -# Test is_bot_owner function -def test_bot_owner_returns_true(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: +async def test_admin_decorator_allows_administrator(): + """Admin decorator should allow users with ADMINISTRATOR permission""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - result = is_bot_owner(123456789) - assert result is True, "Bot owner should return True" - print("✓ test_bot_owner_returns_true") - - -def test_non_owner_returns_false(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: + + interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) + cog = MockCog() + + called = False + + @require_admin + async def test_command(self, interaction: Interaction): + nonlocal called + called = True + + await test_command(cog, interaction) + + assert called, "Command should execute for admin user" + assert not interaction.response.send_message.called, "Should not send error message" + print("✓ test_admin_decorator_allows_administrator passed") + + +async def test_admin_decorator_allows_bot_owner(): + """Bot owner should pass through admin decorator""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - result = is_bot_owner(999999999) - assert result is False, "Non-owner should return False" - print("✓ test_non_owner_returns_false") - - -def test_no_bot_owner_configured(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: - mock_settings.bot_owner_id = None - result = is_bot_owner(123456789) - assert result is False, "Should return False when owner not configured" - print("✓ test_no_bot_owner_configured") - - -# Test is_admin function -def test_admin_member_returns_true(): - interaction = create_mock_interaction(user_id=111111111) - admin_member = create_mock_admin_member() - interaction.guild.get_member = Mock(return_value=admin_member) - - result = is_admin(interaction) - assert result is True, "Admin member should return True" - print("✓ test_admin_member_returns_true") - - -def test_regular_member_returns_false(): - interaction = create_mock_interaction(user_id=222222222) - regular_member = create_mock_regular_member() - interaction.guild.get_member = Mock(return_value=regular_member) - - result = is_admin(interaction) - assert result is False, "Regular member should return False" - print("✓ test_regular_member_returns_false") - - -def test_no_guild_returns_false(): - interaction = create_mock_interaction() - interaction.guild = None - - result = is_admin(interaction) - assert result is False, "No guild should return False" - print("✓ test_no_guild_returns_false") - - -def test_member_not_found_returns_false(): - interaction = create_mock_interaction() - interaction.guild.get_member = Mock(return_value=None) - - result = is_admin(interaction) - assert result is False, "Missing member should return False" - print("✓ test_member_not_found_returns_false") - - -# Test get_permission_embed function -def test_default_embed_creation(): - embed = get_permission_embed() - - assert embed.title == "❌ Permission Denied", "Default title should match" - assert "don't have permission" in embed.description, "Default description should match" - assert embed.color == discord.Color.red(), "Color should be red" - assert len(embed.fields) == 2, "Should have 2 fields" - print("✓ test_default_embed_creation") - - -def test_custom_embed_creation(): - embed = get_permission_embed( - title="Custom Title", - description="Custom description", - required_permission="Custom Permission" - ) - - assert embed.title == "Custom Title", "Custom title should match" - assert embed.description == "Custom description", "Custom description should match" - assert "Custom Permission" in embed.fields[0].value, "Custom permission should be in field" - print("✓ test_custom_embed_creation") - - -# Test check_permissions function -def test_check_permissions_bot_owner(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: + + interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) + cog = MockCog() + + called = False + + @require_admin + async def test_command(self, interaction: Interaction): + nonlocal called + called = True + + await test_command(cog, interaction) + + assert called, "Command should execute for bot owner" + assert not interaction.response.send_message.called, "Should not send error message" + print("✓ test_admin_decorator_allows_bot_owner passed") + + +async def test_admin_decorator_denies_regular_user(): + """Regular users should be denied by admin decorator""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - - interaction = create_mock_interaction(user_id=123456789) - has_permission, reason = check_permissions(interaction) - - assert has_permission is True, "Bot owner should have permission" - assert reason is None, "No reason for allowed access" - print("✓ test_check_permissions_bot_owner") - - -def test_check_permissions_admin(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: + + interaction = create_mock_interaction(user_id=999999999, is_admin_user=False) + cog = MockCog() + + called = False + + @require_admin + async def test_command(self, interaction: Interaction): + nonlocal called + called = True + + await test_command(cog, interaction) + + assert not called, "Command should not execute" + assert interaction.response.send_message.called, "Should send error message" + call_kwargs = interaction.response.send_message.call_args.kwargs + assert call_kwargs["ephemeral"] is True + assert isinstance(call_kwargs["embed"], discord.Embed) + print("✓ test_admin_decorator_denies_regular_user passed") + + +async def test_bot_owner_decorator_denies_admin(): + """Admin users without bot owner status should be denied""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - - interaction = create_mock_interaction(user_id=999999999) - admin_member = create_mock_admin_member() - interaction.guild.get_member = Mock(return_value=admin_member) - - has_permission, reason = check_permissions(interaction) - - assert has_permission is True, "Admin should have permission" - assert reason is None, "No reason for allowed access" - print("✓ test_check_permissions_admin") - - -def test_check_permissions_denied(): - with patch("backend.integrations.discord.permissions.settings") as mock_settings: + + interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) + cog = MockCog() + + called = False + + @require_bot_owner + async def test_command(self, interaction: Interaction): + nonlocal called + called = True + + await test_command(cog, interaction) + + assert not called, "Command should not execute for non-owner" + assert interaction.response.send_message.called, "Should send error message" + print("✓ test_bot_owner_decorator_denies_admin passed") + + +async def test_bot_owner_decorator_allows_owner(): + """Bot owner should pass through bot owner decorator""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - - interaction = create_mock_interaction(user_id=999999999) - regular_member = create_mock_regular_member() - interaction.guild.get_member = Mock(return_value=regular_member) - - has_permission, reason = check_permissions(interaction) - - assert has_permission is False, "Regular user should be denied" - assert reason is not None, "Should have denial reason" - assert "neither bot owner nor server administrator" in reason, "Reason should explain denial" - print("✓ test_check_permissions_denied") - - -# Run all tests -def run_all_tests(): - """Run all permission system tests.""" - print("\n" + "="*50) - print("Permission System Tests") - print("="*50 + "\n") - - tests = [ - ("Bot Owner Checks", [ - test_bot_owner_returns_true, - test_non_owner_returns_false, - test_no_bot_owner_configured, - ]), - ("Admin Checks", [ - test_admin_member_returns_true, - test_regular_member_returns_false, - test_no_guild_returns_false, - test_member_not_found_returns_false, - ]), - ("Embed Generation", [ - test_default_embed_creation, - test_custom_embed_creation, - ]), - ("Permission Checks", [ - test_check_permissions_bot_owner, - test_check_permissions_admin, - test_check_permissions_denied, - ]), + + interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) + cog = MockCog() + + called = False + + @require_bot_owner + async def test_command(self, interaction: Interaction): + nonlocal called + called = True + + await test_command(cog, interaction) + + assert called, "Command should execute for bot owner" + assert not interaction.response.send_message.called, "Should not send error message" + print("✓ test_bot_owner_decorator_allows_owner passed") + + +async def test_permission_check_logging(): + """All permission checks should be logged to database""" + with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ + patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: + + mock_settings.bot_owner_id = 123456789 + + interaction = create_mock_interaction(user_id=999999999, is_admin_user=False, command_name="stats") + cog = MockCog() + + @require_admin + async def test_command(self, interaction: Interaction): + pass + + await test_command(cog, interaction) + + assert mock_log.called, "Should log permission check" + call_kwargs = mock_log.call_args.kwargs + assert call_kwargs["executor_id"] == "999999999" + assert call_kwargs["command_name"] == "stats" + assert call_kwargs["action_result"] == "failure" + assert "Permission denied" in call_kwargs["error_message"] + print("✓ test_permission_check_logging passed") + + +def test_error_embed_generation(): + """Error embeds should have helpful messages""" + embed = get_permission_embed() + + assert embed.title == "Permission Denied" + assert "don't have permission" in embed.description + assert embed.color == discord.Color.red() + assert len(embed.fields) >= 2 + + required_field = embed.fields[0] + assert required_field.name == "Required Permission" + assert "Administrator" in required_field.value or "Bot Owner" in required_field.value + + contact_field = embed.fields[1] + assert contact_field.name == "Contact" + assert "bot owner" in contact_field.value.lower() + print("✓ test_error_embed_generation passed") + + +async def run_all_tests(): + """Run all tests""" + print("\n" + "="*60) + print("Running Admin Permissions Tests") + print("="*60 + "\n") + + tests_passed = 0 + tests_failed = 0 + + test_functions = [ + test_admin_decorator_allows_administrator, + test_admin_decorator_allows_bot_owner, + test_admin_decorator_denies_regular_user, + test_bot_owner_decorator_denies_admin, + test_bot_owner_decorator_allows_owner, + test_permission_check_logging, ] - - total_tests = 0 - failed_tests = [] - - for category, category_tests in tests: - print(f"{category}:") - for test in category_tests: - total_tests += 1 - try: - test() - except AssertionError as e: - print(f" ✗ {test.__name__}: {e}") - failed_tests.append((category, test.__name__, str(e))) - except Exception as e: - print(f" ✗ {test.__name__}: Unexpected error - {e}") - failed_tests.append((category, test.__name__, f"Unexpected error: {e}")) - print() - - print("="*50) - print(f"Results: {total_tests - len(failed_tests)}/{total_tests} tests passed") - - if failed_tests: - print(f"\nFailed tests ({len(failed_tests)}):") - for category, test_name, error in failed_tests: - print(f" [{category}] {test_name}: {error}") - print("="*50) - return False - else: - print("All tests passed! ✓") - print("="*50) - return True + + for test_func in test_functions: + try: + await test_func() + tests_passed += 1 + except AssertionError as e: + tests_failed += 1 + print(f"✗ {test_func.__name__} failed: {e}") + except Exception as e: + tests_failed += 1 + print(f"✗ {test_func.__name__} error: {e}") + + # Run sync test + try: + test_error_embed_generation() + tests_passed += 1 + except AssertionError as e: + tests_failed += 1 + print(f"✗ test_error_embed_generation failed: {e}") + except Exception as e: + tests_failed += 1 + print(f"✗ test_error_embed_generation error: {e}") + + print("\n" + "="*60) + print(f"Results: {tests_passed} passed, {tests_failed} failed") + print("="*60 + "\n") + + return tests_failed == 0 if __name__ == "__main__": - success = run_all_tests() - exit(0 if success else 1) + success = asyncio.run(run_all_tests()) + sys.exit(0 if success else 1) From 7c6e1279a3318dc7c2773e1f5a1f7ab518fde861 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 13:59:58 +0530 Subject: [PATCH 07/18] feat: add the health checks --- backend/app/services/admin/__init__.py | 15 ++ .../app/services/admin/bot_stats_service.py | 111 ++++++++ backend/app/services/admin/cache_service.py | 60 +++++ .../services/admin/health_check_service.py | 153 +++++++++++ backend/app/services/admin/queue_service.py | 98 +++++++ .../app/services/admin/user_info_service.py | 104 ++++++++ .../services/admin/user_management_service.py | 117 +++++++++ .../integrations/discord/admin_cog_example.py | 67 ----- backend/integrations/discord/bot.py | 11 +- backend/integrations/discord/permissions.py | 4 +- backend/integrations/discord/views.py | 29 +++ backend/main.py | 6 + tests/test_admin_integration.py | 219 ++++++++++++++++ tests/test_admin_management.py | 190 ++++++++++++++ tests/test_admin_monitoring.py | 197 ++++++++++++++ tests/test_admin_permissions.py | 245 ++++++++---------- 16 files changed, 1418 insertions(+), 208 deletions(-) create mode 100644 backend/app/services/admin/__init__.py create mode 100644 backend/app/services/admin/bot_stats_service.py create mode 100644 backend/app/services/admin/cache_service.py create mode 100644 backend/app/services/admin/health_check_service.py create mode 100644 backend/app/services/admin/queue_service.py create mode 100644 backend/app/services/admin/user_info_service.py create mode 100644 backend/app/services/admin/user_management_service.py delete mode 100644 backend/integrations/discord/admin_cog_example.py create mode 100644 tests/test_admin_integration.py create mode 100644 tests/test_admin_management.py create mode 100644 tests/test_admin_monitoring.py diff --git a/backend/app/services/admin/__init__.py b/backend/app/services/admin/__init__.py new file mode 100644 index 00000000..e70ec3b0 --- /dev/null +++ b/backend/app/services/admin/__init__.py @@ -0,0 +1,15 @@ +from .bot_stats_service import BotStatsService +from .health_check_service import HealthCheckService +from .user_info_service import UserInfoService +from .queue_service import QueueService +from .cache_service import CacheService +from .user_management_service import UserManagementService + +__all__ = [ + "BotStatsService", + "HealthCheckService", + "UserInfoService", + "QueueService", + "CacheService", + "UserManagementService", +] diff --git a/backend/app/services/admin/bot_stats_service.py b/backend/app/services/admin/bot_stats_service.py new file mode 100644 index 00000000..a0a04633 --- /dev/null +++ b/backend/app/services/admin/bot_stats_service.py @@ -0,0 +1,111 @@ +import logging +import psutil +import os +from datetime import datetime, timedelta +from typing import Dict, Any, Optional +from dataclasses import dataclass + +from app.database.supabase.client import get_supabase_client + +logger = logging.getLogger(__name__) + + +@dataclass +class BotStats: + guild_count: int + total_members: int + active_threads: int + latency_ms: int + uptime_seconds: float + memory_mb: float + messages_today: int + messages_week: int + queue_high: int + queue_medium: int + queue_low: int + + +class BotStatsService: + def __init__(self, bot=None, queue_manager=None): + self.bot = bot + self.queue_manager = queue_manager + self._start_time = datetime.now() + + async def get_message_stats(self) -> Dict[str, int]: + try: + supabase = get_supabase_client() + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_start = today_start - timedelta(days=7) + + today_res = await supabase.table("message_logs").select( + "id", count="exact" + ).gte("created_at", today_start.isoformat()).execute() + + week_res = await supabase.table("message_logs").select( + "id", count="exact" + ).gte("created_at", week_start.isoformat()).execute() + + return { + "today": today_res.count or 0, + "week": week_res.count or 0, + } + except Exception as e: + logger.warning(f"Could not get message stats: {e}") + return {"today": 0, "week": 0} + + async def get_queue_stats(self) -> Dict[str, int]: + try: + if not self.queue_manager or not self.queue_manager.channel: + return {"high": 0, "medium": 0, "low": 0} + + stats = {} + for priority, queue_name in self.queue_manager.queues.items(): + try: + queue = await self.queue_manager.channel.declare_queue( + queue_name, durable=True, passive=True + ) + stats[priority.value] = queue.declaration_result.message_count + except Exception: + stats[priority.value] = 0 + return stats + except Exception as e: + logger.warning(f"Could not get queue stats: {e}") + return {"high": 0, "medium": 0, "low": 0} + + def get_system_stats(self) -> Dict[str, Any]: + try: + process = psutil.Process(os.getpid()) + memory_mb = process.memory_info().rss / 1024 / 1024 + uptime = (datetime.now() - self._start_time).total_seconds() + return { + "memory_mb": round(memory_mb, 2), + "uptime_seconds": uptime, + } + except Exception as e: + logger.warning(f"Could not get system stats: {e}") + return {"memory_mb": 0, "uptime_seconds": 0} + + async def get_all_stats(self) -> BotStats: + message_stats = await self.get_message_stats() + queue_stats = await self.get_queue_stats() + system_stats = self.get_system_stats() + + guild_count = len(self.bot.guilds) if self.bot else 0 + total_members = sum(g.member_count or 0 for g in self.bot.guilds) if self.bot else 0 + active_threads = len(self.bot.active_threads) if self.bot else 0 + latency_ms = round(self.bot.latency * 1000) if self.bot else 0 + + return BotStats( + guild_count=guild_count, + total_members=total_members, + active_threads=active_threads, + latency_ms=latency_ms, + uptime_seconds=system_stats["uptime_seconds"], + memory_mb=system_stats["memory_mb"], + messages_today=message_stats["today"], + messages_week=message_stats["week"], + queue_high=queue_stats.get("high", 0), + queue_medium=queue_stats.get("medium", 0), + queue_low=queue_stats.get("low", 0), + ) diff --git a/backend/app/services/admin/cache_service.py b/backend/app/services/admin/cache_service.py new file mode 100644 index 00000000..ae6e6cd1 --- /dev/null +++ b/backend/app/services/admin/cache_service.py @@ -0,0 +1,60 @@ +import logging +from typing import Dict, Any, Optional +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class CacheInfo: + name: str + size: int + cleared: bool + + +class CacheService: + def __init__(self, bot=None): + self.bot = bot + + async def get_cache_sizes(self) -> Dict[str, int]: + sizes = { + "active_threads": 0, + "embeddings": 0, + "memories": 0, + } + + if self.bot: + sizes["active_threads"] = len(self.bot.active_threads) + + return sizes + + async def clear_active_threads(self) -> int: + if not self.bot: + return 0 + + count = len(self.bot.active_threads) + self.bot.active_threads.clear() + logger.info(f"Cleared {count} active threads from cache") + return count + + async def clear_embeddings(self) -> int: + logger.info("Embeddings cache clear requested (no-op for now)") + return 0 + + async def clear_memories(self) -> int: + logger.info("Memories cache clear requested (no-op for now)") + return 0 + + async def clear_cache(self, cache_type: str = "all") -> Dict[str, int]: + cleared = {} + + if cache_type in ("all", "active_threads"): + cleared["active_threads"] = await self.clear_active_threads() + + if cache_type in ("all", "embeddings"): + cleared["embeddings"] = await self.clear_embeddings() + + if cache_type in ("all", "memories"): + cleared["memories"] = await self.clear_memories() + + return cleared diff --git a/backend/app/services/admin/health_check_service.py b/backend/app/services/admin/health_check_service.py new file mode 100644 index 00000000..749829b5 --- /dev/null +++ b/backend/app/services/admin/health_check_service.py @@ -0,0 +1,153 @@ +import logging +import asyncio +import aiohttp +from datetime import datetime +from typing import Dict, Any, List +from dataclasses import dataclass, field + +from app.core.config import settings +from app.database.supabase.client import get_supabase_client + +logger = logging.getLogger(__name__) + + +@dataclass +class ServiceHealth: + name: str + status: str # healthy, unhealthy, degraded + latency_ms: float + error: str = "" + + +@dataclass +class SystemHealth: + overall_status: str + services: List[ServiceHealth] = field(default_factory=list) + timestamp: str = "" + + def __post_init__(self): + if not self.timestamp: + self.timestamp = datetime.now().isoformat() + + +class HealthCheckService: + def __init__(self, queue_manager=None): + self.queue_manager = queue_manager + self.timeout = 10.0 + + async def check_supabase(self) -> ServiceHealth: + start = datetime.now() + try: + supabase = get_supabase_client() + await asyncio.wait_for( + supabase.table("users").select("id").limit(1).execute(), + timeout=self.timeout + ) + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("Supabase", "healthy", round(latency, 2)) + except asyncio.TimeoutError: + return ServiceHealth("Supabase", "unhealthy", self.timeout * 1000, "Timeout") + except Exception as e: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("Supabase", "unhealthy", round(latency, 2), str(e)[:100]) + + async def check_rabbitmq(self) -> ServiceHealth: + start = datetime.now() + try: + if not self.queue_manager or not self.queue_manager.connection: + return ServiceHealth("RabbitMQ", "unhealthy", 0, "Not connected") + + if self.queue_manager.connection.is_closed: + return ServiceHealth("RabbitMQ", "unhealthy", 0, "Connection closed") + + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("RabbitMQ", "healthy", round(latency, 2)) + except Exception as e: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("RabbitMQ", "unhealthy", round(latency, 2), str(e)[:100]) + + async def check_weaviate(self) -> ServiceHealth: + start = datetime.now() + try: + weaviate_url = getattr(settings, 'weaviate_url', 'http://localhost:8080') + async with aiohttp.ClientSession() as session: + async with session.get( + f"{weaviate_url}/v1/.well-known/ready", + timeout=aiohttp.ClientTimeout(total=self.timeout) + ) as resp: + latency = (datetime.now() - start).total_seconds() * 1000 + if resp.status == 200: + return ServiceHealth("Weaviate", "healthy", round(latency, 2)) + return ServiceHealth("Weaviate", "unhealthy", round(latency, 2), f"Status {resp.status}") + except asyncio.TimeoutError: + return ServiceHealth("Weaviate", "unhealthy", self.timeout * 1000, "Timeout") + except Exception as e: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("Weaviate", "unhealthy", round(latency, 2), str(e)[:100]) + + async def check_falkordb(self) -> ServiceHealth: + start = datetime.now() + try: + falkor_url = getattr(settings, 'falkordb_url', 'http://localhost:6379') + async with aiohttp.ClientSession() as session: + async with session.get( + falkor_url, + timeout=aiohttp.ClientTimeout(total=self.timeout) + ) as resp: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("FalkorDB", "healthy", round(latency, 2)) + except asyncio.TimeoutError: + return ServiceHealth("FalkorDB", "degraded", self.timeout * 1000, "Timeout") + except Exception as e: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("FalkorDB", "degraded", round(latency, 2), str(e)[:50]) + + async def check_gemini_api(self) -> ServiceHealth: + start = datetime.now() + try: + if not getattr(settings, 'gemini_api_key', None): + return ServiceHealth("Gemini API", "unhealthy", 0, "No API key") + + async with aiohttp.ClientSession() as session: + async with session.get( + "https://generativelanguage.googleapis.com/v1/models", + params={"key": settings.gemini_api_key}, + timeout=aiohttp.ClientTimeout(total=self.timeout) + ) as resp: + latency = (datetime.now() - start).total_seconds() * 1000 + if resp.status == 200: + return ServiceHealth("Gemini API", "healthy", round(latency, 2)) + return ServiceHealth("Gemini API", "unhealthy", round(latency, 2), f"Status {resp.status}") + except asyncio.TimeoutError: + return ServiceHealth("Gemini API", "degraded", self.timeout * 1000, "Timeout") + except Exception as e: + latency = (datetime.now() - start).total_seconds() * 1000 + return ServiceHealth("Gemini API", "unhealthy", round(latency, 2), str(e)[:100]) + + async def get_all_health(self) -> SystemHealth: + checks = await asyncio.gather( + self.check_supabase(), + self.check_rabbitmq(), + self.check_weaviate(), + self.check_gemini_api(), + return_exceptions=True + ) + + services = [] + for check in checks: + if isinstance(check, Exception): + services.append(ServiceHealth("Unknown", "unhealthy", 0, str(check)[:100])) + else: + services.append(check) + + unhealthy = sum(1 for s in services if s.status == "unhealthy") + degraded = sum(1 for s in services if s.status == "degraded") + + if unhealthy > 0: + overall = "unhealthy" + elif degraded > 0: + overall = "degraded" + else: + overall = "healthy" + + return SystemHealth(overall_status=overall, services=services) diff --git a/backend/app/services/admin/queue_service.py b/backend/app/services/admin/queue_service.py new file mode 100644 index 00000000..66bade8b --- /dev/null +++ b/backend/app/services/admin/queue_service.py @@ -0,0 +1,98 @@ +import logging +from typing import Dict, Any, List +from dataclasses import dataclass +from datetime import datetime + +from app.core.orchestration.queue_manager import QueuePriority + +logger = logging.getLogger(__name__) + + +@dataclass +class QueueStats: + priority: str + pending: int + consumers: int + + +@dataclass +class FullQueueStatus: + high: QueueStats + medium: QueueStats + low: QueueStats + total_pending: int + + +class QueueService: + def __init__(self, queue_manager=None): + self.queue_manager = queue_manager + + async def get_queue_stats(self) -> FullQueueStatus: + high = QueueStats("high", 0, 0) + medium = QueueStats("medium", 0, 0) + low = QueueStats("low", 0, 0) + + if not self.queue_manager or not self.queue_manager.channel: + return FullQueueStatus(high, medium, low, 0) + + try: + for priority, queue_name in self.queue_manager.queues.items(): + try: + queue = await self.queue_manager.channel.declare_queue( + queue_name, durable=True, passive=True + ) + count = queue.declaration_result.message_count + consumers = queue.declaration_result.consumer_count + + if priority == QueuePriority.HIGH: + high = QueueStats("high", count, consumers) + elif priority == QueuePriority.MEDIUM: + medium = QueueStats("medium", count, consumers) + elif priority == QueuePriority.LOW: + low = QueueStats("low", count, consumers) + except Exception as e: + logger.warning(f"Could not get stats for {queue_name}: {e}") + + total = high.pending + medium.pending + low.pending + return FullQueueStatus(high, medium, low, total) + except Exception as e: + logger.error(f"Error getting queue stats: {e}") + return FullQueueStatus(high, medium, low, 0) + + async def clear_queue(self, priority: str = "all") -> Dict[str, int]: + if not self.queue_manager or not self.queue_manager.channel: + raise RuntimeError("Queue manager not available") + + cleared = {} + + try: + priorities_to_clear = [] + if priority == "all": + priorities_to_clear = list(self.queue_manager.queues.keys()) + else: + priority_map = { + "high": QueuePriority.HIGH, + "medium": QueuePriority.MEDIUM, + "low": QueuePriority.LOW, + } + if priority in priority_map: + priorities_to_clear = [priority_map[priority]] + + for p in priorities_to_clear: + queue_name = self.queue_manager.queues[p] + try: + queue = await self.queue_manager.channel.declare_queue( + queue_name, durable=True, passive=True + ) + count = queue.declaration_result.message_count + await queue.purge() + cleared[p.value] = count + logger.info(f"Cleared {count} messages from {queue_name}") + except Exception as e: + logger.error(f"Failed to clear {queue_name}: {e}") + cleared[p.value] = 0 + + return cleared + except Exception as e: + logger.error(f"Error clearing queues: {e}") + raise diff --git a/backend/app/services/admin/user_info_service.py b/backend/app/services/admin/user_info_service.py new file mode 100644 index 00000000..f6bd8a19 --- /dev/null +++ b/backend/app/services/admin/user_info_service.py @@ -0,0 +1,104 @@ +import logging +from typing import Dict, Any, Optional +from dataclasses import dataclass +from datetime import datetime + +from app.database.supabase.client import get_supabase_client + +logger = logging.getLogger(__name__) + + +@dataclass +class UserInfo: + discord_id: str + discord_username: str + display_name: str + avatar_url: Optional[str] + created_at: str + is_verified: bool + github_username: Optional[str] + message_count: int + has_active_thread: bool + roles_count: int + last_message_at: Optional[str] + + +class UserInfoService: + def __init__(self, bot=None): + self.bot = bot + + async def get_user_profile(self, discord_id: str) -> Optional[Dict[str, Any]]: + try: + supabase = get_supabase_client() + res = await supabase.table("users").select("*").eq( + "discord_id", discord_id + ).limit(1).execute() + if res.data: + return res.data[0] + return None + except Exception as e: + logger.error(f"Error getting user profile: {e}") + return None + + async def get_user_message_count(self, discord_id: str) -> int: + try: + supabase = get_supabase_client() + res = await supabase.table("message_logs").select( + "id", count="exact" + ).eq("user_id", discord_id).execute() + return res.count or 0 + except Exception as e: + logger.warning(f"Could not get message count: {e}") + return 0 + + async def get_last_message(self, discord_id: str) -> Optional[str]: + try: + supabase = get_supabase_client() + res = await supabase.table("message_logs").select( + "created_at" + ).eq("user_id", discord_id).order( + "created_at", desc=True + ).limit(1).execute() + if res.data: + return res.data[0]["created_at"] + return None + except Exception as e: + logger.warning(f"Could not get last message: {e}") + return None + + def has_active_thread(self, discord_id: str) -> bool: + if not self.bot: + return False + return discord_id in self.bot.active_threads + + async def get_full_user_info( + self, + discord_user, + member=None + ) -> UserInfo: + discord_id = str(discord_user.id) + profile = await self.get_user_profile(discord_id) + message_count = await self.get_user_message_count(discord_id) + last_message = await self.get_last_message(discord_id) + + is_verified = False + github_username = None + if profile: + is_verified = profile.get("is_verified", False) + github_username = profile.get("github_username") + + roles_count = len(member.roles) - 1 if member else 0 + + return UserInfo( + discord_id=discord_id, + discord_username=discord_user.name, + display_name=discord_user.display_name if hasattr(discord_user, 'display_name') else discord_user.name, + avatar_url=str(discord_user.avatar.url) if discord_user.avatar else None, + created_at=discord_user.created_at.strftime("%Y-%m-%d"), + is_verified=is_verified, + github_username=github_username, + message_count=message_count, + has_active_thread=self.has_active_thread(discord_id), + roles_count=roles_count, + last_message_at=last_message, + ) diff --git a/backend/app/services/admin/user_management_service.py b/backend/app/services/admin/user_management_service.py new file mode 100644 index 00000000..5f9ba86b --- /dev/null +++ b/backend/app/services/admin/user_management_service.py @@ -0,0 +1,117 @@ +import logging +from typing import Dict, Any, List, Optional +from dataclasses import dataclass +from datetime import datetime + +from app.database.supabase.client import get_supabase_client +from app.core.orchestration.queue_manager import AsyncQueueManager, QueuePriority + +logger = logging.getLogger(__name__) + + +@dataclass +class ResetResult: + user_id: str + memory_cleared: bool + thread_closed: bool + verification_reset: bool + errors: List[str] + + +class UserManagementService: + def __init__(self, bot=None, queue_manager: AsyncQueueManager = None): + self.bot = bot + self.queue_manager = queue_manager + + async def clear_user_memory(self, user_id: str) -> bool: + try: + if not self.queue_manager: + logger.warning("Queue manager not available for memory clear") + return False + + cleanup_msg = { + "type": "clear_thread_memory", + "memory_thread_id": user_id, + "user_id": user_id, + "cleanup_reason": "admin_reset" + } + await self.queue_manager.enqueue(cleanup_msg, QueuePriority.HIGH) + logger.info(f"Queued memory clear for user {user_id}") + return True + except Exception as e: + logger.error(f"Failed to clear memory for {user_id}: {e}") + return False + + async def close_user_thread(self, user_id: str) -> bool: + try: + if not self.bot: + return False + + if user_id not in self.bot.active_threads: + return True + + thread_id = self.bot.active_threads.pop(user_id, None) + if thread_id: + try: + thread = self.bot.get_channel(int(thread_id)) + if thread: + await thread.edit(archived=True) + logger.info(f"Archived thread {thread_id} for user {user_id}") + except Exception as e: + logger.warning(f"Could not archive thread {thread_id}: {e}") + + return True + except Exception as e: + logger.error(f"Failed to close thread for {user_id}: {e}") + return False + + async def reset_verification(self, user_id: str) -> bool: + try: + supabase = get_supabase_client() + await supabase.table("users").update({ + "github_id": None, + "github_username": None, + "is_verified": False, + "verification_token": None, + "updated_at": datetime.now().isoformat() + }).eq("discord_id", user_id).execute() + logger.info(f"Reset verification for user {user_id}") + return True + except Exception as e: + logger.error(f"Failed to reset verification for {user_id}: {e}") + return False + + async def reset_user( + self, + user_id: str, + reset_memory: bool = False, + reset_thread: bool = False, + reset_verification: bool = False + ) -> ResetResult: + errors = [] + memory_cleared = False + thread_closed = False + verification_reset = False + + if reset_memory: + memory_cleared = await self.clear_user_memory(user_id) + if not memory_cleared: + errors.append("Failed to clear memory") + + if reset_thread: + thread_closed = await self.close_user_thread(user_id) + if not thread_closed: + errors.append("Failed to close thread") + + if reset_verification: + verification_reset = await self.reset_verification(user_id) + if not verification_reset: + errors.append("Failed to reset verification") + + return ResetResult( + user_id=user_id, + memory_cleared=memory_cleared, + thread_closed=thread_closed, + verification_reset=verification_reset, + errors=errors + ) diff --git a/backend/integrations/discord/admin_cog_example.py b/backend/integrations/discord/admin_cog_example.py deleted file mode 100644 index 84f2270e..00000000 --- a/backend/integrations/discord/admin_cog_example.py +++ /dev/null @@ -1,67 +0,0 @@ -import discord -from discord import app_commands, Interaction -from discord.ext import commands - -from integrations.discord.permissions import require_admin, require_bot_owner - - -class AdminCommands(commands.GroupCog, name="admin"): - """Admin command group for bot management and monitoring""" - - def __init__(self, bot): - self.bot = bot - super().__init__() - - @app_commands.command(name="stats", description="Display bot statistics") - @require_admin - async def stats(self, interaction: Interaction): - """Show bot stats like server count and latency.""" - try: - # Gather stats - guild_count = len(self.bot.guilds) - total_members = sum(guild.member_count for guild in self.bot.guilds) - - embed = discord.Embed( - title="Bot Statistics", - color=discord.Color.blue() - ) - embed.add_field(name="Servers", value=str(guild_count), inline=True) - embed.add_field(name="Total Members", value=str(total_members), inline=True) - embed.add_field(name="Latency", value=f"{round(self.bot.latency * 1000)}ms", inline=True) - - await interaction.response.send_message(embed=embed) - - except Exception as e: - await interaction.response.send_message( - f"Error gathering statistics: {str(e)}", - ephemeral=True - ) - raise # Re-raise for logging - - @app_commands.command(name="test", description="Test admin command with parameters") - @require_admin - async def test( - self, - interaction: Interaction, - message: str, - count: int = 1 - ): - """Test command to verify parameter logging works correctly.""" - await interaction.response.send_message( - f"Test executed! Message: {message}, Count: {count}", - ephemeral=True - ) - - @app_commands.command(name="owner_only", description="Bot owner only command") - @require_bot_owner - async def owner_only(self, interaction: Interaction): - """Example command restricted to bot owner only.""" - await interaction.response.send_message( - "This command can only be run by the bot owner!", - ephemeral=True - ) - - -async def setup(bot): - """Register admin commands cog.""" - await bot.add_cog(AdminCommands(bot)) diff --git a/backend/integrations/discord/bot.py b/backend/integrations/discord/bot.py index dbb7c3a4..3dced54c 100644 --- a/backend/integrations/discord/bot.py +++ b/backend/integrations/discord/bot.py @@ -38,8 +38,17 @@ async def on_ready(self): logger.info(f'Enhanced Discord bot logged in as {self.user}') print(f'Bot is ready! Logged in as {self.user}') try: + # Sync globally synced = await self.tree.sync() - print(f"Synced {len(synced)} slash command(s)") + print(f"Synced {len(synced)} global slash command(s)") + + # Also sync to each guild for instant availability + for guild in self.guilds: + try: + guild_synced = await self.tree.sync(guild=guild) + print(f"Synced {len(guild_synced)} commands to guild {guild.name}") + except Exception as e: + print(f"Failed to sync to guild {guild.name}: {e}") except Exception as e: print(f"Failed to sync slash commands: {e}") diff --git a/backend/integrations/discord/permissions.py b/backend/integrations/discord/permissions.py index cdd0ce67..c085f1d4 100644 --- a/backend/integrations/discord/permissions.py +++ b/backend/integrations/discord/permissions.py @@ -5,8 +5,8 @@ import logging import inspect -from backend.app.core.config.settings import settings -from backend.app.utils.admin_logger import log_admin_action +from app.core.config.settings import settings +from app.utils.admin_logger import log_admin_action logger = logging.getLogger(__name__) diff --git a/backend/integrations/discord/views.py b/backend/integrations/discord/views.py index 807d6dd7..fdf490da 100644 --- a/backend/integrations/discord/views.py +++ b/backend/integrations/discord/views.py @@ -49,6 +49,35 @@ def __init__(self, oauth_url: str, provider_name: str): self.add_item(button) +class ConfirmActionView(discord.ui.View): + def __init__(self, timeout: float = 30.0): + super().__init__(timeout=timeout) + self.confirmed = False + self.interaction = None + + @discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger) + async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button): + self.confirmed = True + self.interaction = interaction + for item in self.children: + item.disabled = True + await interaction.response.edit_message(view=self) + self.stop() + + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary) + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): + self.confirmed = False + self.interaction = interaction + for item in self.children: + item.disabled = True + await interaction.response.edit_message(view=self) + self.stop() + + async def on_timeout(self): + for item in self.children: + item.disabled = True + + class OnboardingView(discord.ui.View): """View shown in onboarding DM with optional GitHub connect link and Skip button.""" diff --git a/backend/main.py b/backend/main.py index b7ad80a6..ff74e493 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,6 +51,12 @@ async def start_background_tasks(self): except (ImportError, commands.ExtensionError) as e: logger.error("Failed to load Discord cog extension: %s", e) + try: + await self.discord_bot.load_extension("integrations.discord.admin_cog") + logger.info("Admin cog loaded successfully") + except Exception as e: + logger.error("Failed to load admin cog extension: %s", e, exc_info=True) + # Start the bot as a background task. asyncio.create_task( self.discord_bot.start(settings.discord_bot_token) diff --git a/tests/test_admin_integration.py b/tests/test_admin_integration.py new file mode 100644 index 00000000..ba82e496 --- /dev/null +++ b/tests/test_admin_integration.py @@ -0,0 +1,219 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from unittest.mock import Mock, AsyncMock, patch +import asyncio + + +async def test_full_workflow(): + from app.services.admin.bot_stats_service import BotStatsService + from app.services.admin.health_check_service import HealthCheckService, ServiceHealth + from app.services.admin.queue_service import QueueService, FullQueueStatus, QueueStats + + mock_bot = Mock() + mock_bot.guilds = [Mock(member_count=100)] + mock_bot.active_threads = {} + mock_bot.latency = 0.05 + + stats_service = BotStatsService(mock_bot, None) + health_service = HealthCheckService(None) + queue_service = QueueService(None) + + with patch.object(stats_service, 'get_message_stats', new_callable=AsyncMock) as m1, \ + patch.object(stats_service, 'get_queue_stats', new_callable=AsyncMock) as m2: + m1.return_value = {"today": 5, "week": 20} + m2.return_value = {"high": 0, "medium": 0, "low": 0} + + stats = await stats_service.get_all_stats() + assert stats.guild_count == 1 + + with patch.object(health_service, 'check_supabase', new_callable=AsyncMock) as h1, \ + patch.object(health_service, 'check_rabbitmq', new_callable=AsyncMock) as h2, \ + patch.object(health_service, 'check_weaviate', new_callable=AsyncMock) as h3, \ + patch.object(health_service, 'check_gemini_api', new_callable=AsyncMock) as h4: + + h1.return_value = ServiceHealth("Supabase", "healthy", 10) + h2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) + h3.return_value = ServiceHealth("Weaviate", "healthy", 15) + h4.return_value = ServiceHealth("Gemini", "healthy", 100) + + health = await health_service.get_all_health() + assert health.overall_status == "healthy" + + with patch.object(queue_service, 'get_queue_stats', new_callable=AsyncMock) as q1: + q1.return_value = FullQueueStatus( + high=QueueStats("high", 0, 1), + medium=QueueStats("medium", 0, 1), + low=QueueStats("low", 0, 1), + total_pending=0 + ) + + status = await queue_service.get_queue_stats() + assert status.total_pending == 0 + + print("PASS: test_full_workflow") + + +async def test_permission_denies_regular_user(): + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + + mock_settings.bot_owner_id = 123456789 + + interaction = Mock() + interaction.user = Mock() + interaction.user.id = 999999999 + interaction.user.name = "regular_user" + interaction.guild = Mock() + interaction.guild.id = 1234567890 + + member = Mock() + member.guild_permissions = Mock() + member.guild_permissions.administrator = False + interaction.guild.get_member = Mock(return_value=member) + + interaction.command = Mock() + interaction.command.name = "stats" + + interaction.response = Mock() + interaction.response.is_done = Mock(return_value=False) + interaction.response.send_message = AsyncMock() + + called = False + + class Cog: + pass + + @require_admin + async def cmd(self, interaction): + nonlocal called + called = True + + await cmd(Cog(), interaction) + + assert not called + assert interaction.response.send_message.called + print("PASS: test_permission_denies_regular_user") + + +async def test_permission_allows_admin(): + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + + mock_settings.bot_owner_id = 123456789 + + interaction = Mock() + interaction.user = Mock() + interaction.user.id = 999999999 + interaction.user.name = "admin_user" + interaction.guild = Mock() + interaction.guild.id = 1234567890 + + member = Mock() + member.guild_permissions = Mock() + member.guild_permissions.administrator = True + interaction.guild.get_member = Mock(return_value=member) + + interaction.command = Mock() + interaction.command.name = "stats" + + interaction.response = Mock() + interaction.response.is_done = Mock(return_value=False) + + called = False + + class Cog: + pass + + @require_admin + async def cmd(self, interaction): + nonlocal called + called = True + + await cmd(Cog(), interaction) + + assert called + print("PASS: test_permission_allows_admin") + + +async def test_logging_works(): + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: + + mock_settings.bot_owner_id = 123456789 + + interaction = Mock() + interaction.user = Mock() + interaction.user.id = 123456789 + interaction.user.name = "bot_owner" + interaction.guild = Mock() + interaction.guild.id = 1234567890 + + member = Mock() + member.guild_permissions = Mock() + member.guild_permissions.administrator = False + interaction.guild.get_member = Mock(return_value=member) + + interaction.command = Mock() + interaction.command.name = "queue_clear" + + interaction.response = Mock() + interaction.response.is_done = Mock(return_value=False) + + class Cog: + pass + + @require_admin + async def cmd(self, interaction, priority="all"): + pass + + await cmd(Cog(), interaction, priority="high") + + assert mock_log.called + kwargs = mock_log.call_args.kwargs + assert kwargs["executor_id"] == "123456789" + assert kwargs["command_name"] == "queue_clear" + print("PASS: test_logging_works") + + +async def run_all_tests(): + print("\n" + "=" * 60) + print("Running Admin Integration Tests") + print("=" * 60 + "\n") + + passed = 0 + failed = 0 + + tests = [ + test_full_workflow, + test_permission_denies_regular_user, + test_permission_allows_admin, + test_logging_works, + ] + + for test in tests: + try: + await test() + passed += 1 + except Exception as e: + failed += 1 + print(f"FAIL: {test.__name__}: {e}") + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 + + +if __name__ == "__main__": + success = asyncio.run(run_all_tests()) + sys.exit(0 if success else 1) diff --git a/tests/test_admin_management.py b/tests/test_admin_management.py new file mode 100644 index 00000000..cfe717de --- /dev/null +++ b/tests/test_admin_management.py @@ -0,0 +1,190 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from unittest.mock import Mock, AsyncMock, patch +import asyncio + + +async def test_queue_status(): + from app.services.admin.queue_service import QueueService, FullQueueStatus, QueueStats + + service = QueueService(None) + + with patch.object(service, 'get_queue_stats', new_callable=AsyncMock) as mock_stats: + mock_stats.return_value = FullQueueStatus( + high=QueueStats("high", 5, 2), + medium=QueueStats("medium", 10, 3), + low=QueueStats("low", 3, 1), + total_pending=18 + ) + + status = await service.get_queue_stats() + + assert status.total_pending == 18 + assert status.high.pending == 5 + print("PASS: test_queue_status") + + +async def test_queue_clear(): + from app.services.admin.queue_service import QueueService + from app.core.orchestration.queue_manager import QueuePriority + + mock_channel = Mock() + mock_queue_manager = Mock() + mock_queue_manager.channel = mock_channel + mock_queue_manager.queues = { + QueuePriority.HIGH: "high_task_queue", + QueuePriority.MEDIUM: "medium_task_queue", + QueuePriority.LOW: "low_task_queue" + } + + mock_queue = Mock() + mock_queue.declaration_result = Mock() + mock_queue.declaration_result.message_count = 5 + mock_queue.purge = AsyncMock() + + mock_channel.declare_queue = AsyncMock(return_value=mock_queue) + + service = QueueService(mock_queue_manager) + cleared = await service.clear_queue("all") + + assert "high" in cleared + assert "medium" in cleared + assert "low" in cleared + print("PASS: test_queue_clear") + + +async def test_user_reset_memory(): + from app.services.admin.user_management_service import UserManagementService + + mock_queue = Mock() + mock_queue.enqueue = AsyncMock() + + service = UserManagementService(None, mock_queue) + + result = await service.reset_user( + "123456", + reset_memory=True, + reset_thread=False, + reset_verification=False + ) + + assert result.memory_cleared is True + assert result.thread_closed is False + mock_queue.enqueue.assert_called_once() + print("PASS: test_user_reset_memory") + + +async def test_user_reset_full(): + from app.services.admin.user_management_service import UserManagementService + + mock_bot = Mock() + mock_bot.active_threads = {"123456": "thread123"} + mock_bot.get_channel = Mock(return_value=None) + + mock_queue = Mock() + mock_queue.enqueue = AsyncMock() + + service = UserManagementService(mock_bot, mock_queue) + + with patch('app.services.admin.user_management_service.get_supabase_client') as mock_supa: + mock_client = Mock() + mock_client.table = Mock(return_value=mock_client) + mock_client.update = Mock(return_value=mock_client) + mock_client.eq = Mock(return_value=mock_client) + mock_client.execute = AsyncMock() + mock_supa.return_value = mock_client + + result = await service.reset_user( + "123456", + reset_memory=True, + reset_thread=True, + reset_verification=True + ) + + assert result.memory_cleared is True + assert result.thread_closed is True + assert result.verification_reset is True + print("PASS: test_user_reset_full") + + +async def test_cache_clear(): + from app.services.admin.cache_service import CacheService + + mock_bot = Mock() + mock_bot.active_threads = {"1": "t1", "2": "t2", "3": "t3"} + + service = CacheService(mock_bot) + cleared = await service.clear_cache("all") + + assert cleared["active_threads"] == 3 + assert len(mock_bot.active_threads) == 0 + print("PASS: test_cache_clear") + + +async def test_cache_sizes(): + from app.services.admin.cache_service import CacheService + + mock_bot = Mock() + mock_bot.active_threads = {"1": "t1"} + + service = CacheService(mock_bot) + sizes = await service.get_cache_sizes() + + assert sizes["active_threads"] == 1 + print("PASS: test_cache_sizes") + + +def test_confirm_view(): + from integrations.discord.views import ConfirmActionView + + view = ConfirmActionView(timeout=30.0) + assert view.confirmed is False + assert view.timeout == 30.0 + print("PASS: test_confirm_view") + + +async def run_all_tests(): + print("\n" + "=" * 60) + print("Running Admin Management Tests") + print("=" * 60 + "\n") + + passed = 0 + failed = 0 + + async_tests = [ + test_queue_status, + test_queue_clear, + test_user_reset_memory, + test_user_reset_full, + test_cache_clear, + test_cache_sizes, + ] + + for test in async_tests: + try: + await test() + passed += 1 + except Exception as e: + failed += 1 + print(f"FAIL: {test.__name__}: {e}") + + try: + test_confirm_view() + passed += 1 + except Exception as e: + failed += 1 + print(f"FAIL: test_confirm_view: {e}") + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 + + +if __name__ == "__main__": + success = asyncio.run(run_all_tests()) + sys.exit(0 if success else 1) diff --git a/tests/test_admin_monitoring.py b/tests/test_admin_monitoring.py new file mode 100644 index 00000000..467d0130 --- /dev/null +++ b/tests/test_admin_monitoring.py @@ -0,0 +1,197 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from unittest.mock import Mock, AsyncMock, patch +import asyncio + + +async def test_stats_service(): + from app.services.admin.bot_stats_service import BotStatsService + + mock_bot = Mock() + mock_bot.guilds = [Mock(member_count=100), Mock(member_count=200)] + mock_bot.active_threads = {"1": "thread1"} + mock_bot.latency = 0.05 + + service = BotStatsService(mock_bot, None) + + with patch.object(service, 'get_message_stats', new_callable=AsyncMock) as mock_msg, \ + patch.object(service, 'get_queue_stats', new_callable=AsyncMock) as mock_queue: + mock_msg.return_value = {"today": 10, "week": 50} + mock_queue.return_value = {"high": 1, "medium": 2, "low": 3} + + stats = await service.get_all_stats() + + assert stats.guild_count == 2 + assert stats.total_members == 300 + assert stats.active_threads == 1 + assert stats.messages_today == 10 + print("PASS: test_stats_service") + + +async def test_stats_handles_errors(): + from app.services.admin.bot_stats_service import BotStatsService + + mock_bot = Mock() + mock_bot.guilds = [] + mock_bot.active_threads = {} + mock_bot.latency = 0.1 + + service = BotStatsService(mock_bot, None) + + with patch('app.services.admin.bot_stats_service.get_supabase_client') as mock_supa: + mock_supa.side_effect = Exception("Database error") + stats = await service.get_all_stats() + assert stats.messages_today == 0 + print("PASS: test_stats_handles_errors") + + +async def test_health_all_healthy(): + from app.services.admin.health_check_service import HealthCheckService, ServiceHealth + + service = HealthCheckService(None) + + with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ + patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ + patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m4: + + m1.return_value = ServiceHealth("Supabase", "healthy", 10) + m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) + m3.return_value = ServiceHealth("Weaviate", "healthy", 15) + m4.return_value = ServiceHealth("Gemini", "healthy", 100) + + health = await service.get_all_health() + + assert health.overall_status == "healthy" + assert len(health.services) == 4 + print("PASS: test_health_all_healthy") + + +async def test_health_service_down(): + from app.services.admin.health_check_service import HealthCheckService, ServiceHealth + + service = HealthCheckService(None) + + with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ + patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ + patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m4: + + m1.return_value = ServiceHealth("Supabase", "unhealthy", 0, "Connection failed") + m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) + m3.return_value = ServiceHealth("Weaviate", "healthy", 15) + m4.return_value = ServiceHealth("Gemini", "healthy", 100) + + health = await service.get_all_health() + + assert health.overall_status == "unhealthy" + print("PASS: test_health_service_down") + + +async def test_user_info_verified(): + from app.services.admin.user_info_service import UserInfoService + + mock_bot = Mock() + mock_bot.active_threads = {"123456": "thread1"} + + service = UserInfoService(mock_bot) + + mock_user = Mock() + mock_user.id = 123456 + mock_user.name = "testuser" + mock_user.display_name = "Test User" + mock_user.avatar = Mock() + mock_user.avatar.url = "https://example.com/avatar.png" + mock_user.created_at = Mock() + mock_user.created_at.strftime = Mock(return_value="2020-01-01") + + mock_member = Mock() + mock_member.roles = [Mock(), Mock(), Mock()] + + with patch.object(service, 'get_user_profile', new_callable=AsyncMock) as m1, \ + patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ + patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: + + m1.return_value = {"is_verified": True, "github_username": "testuser_gh"} + m2.return_value = 42 + m3.return_value = "2024-01-15T10:00:00" + + info = await service.get_full_user_info(mock_user, mock_member) + + assert info.is_verified is True + assert info.github_username == "testuser_gh" + assert info.message_count == 42 + assert info.has_active_thread is True + print("PASS: test_user_info_verified") + + +async def test_user_info_unverified(): + from app.services.admin.user_info_service import UserInfoService + + mock_bot = Mock() + mock_bot.active_threads = {} + + service = UserInfoService(mock_bot) + + mock_user = Mock() + mock_user.id = 999999 + mock_user.name = "newuser" + mock_user.display_name = "New User" + mock_user.avatar = None + mock_user.created_at = Mock() + mock_user.created_at.strftime = Mock(return_value="2024-01-01") + + with patch.object(service, 'get_user_profile', new_callable=AsyncMock) as m1, \ + patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ + patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: + + m1.return_value = None + m2.return_value = 0 + m3.return_value = None + + info = await service.get_full_user_info(mock_user, None) + + assert info.is_verified is False + assert info.github_username is None + assert info.message_count == 0 + print("PASS: test_user_info_unverified") + + +async def run_all_tests(): + print("\n" + "=" * 60) + print("Running Admin Monitoring Tests") + print("=" * 60 + "\n") + + passed = 0 + failed = 0 + + tests = [ + test_stats_service, + test_stats_handles_errors, + test_health_all_healthy, + test_health_service_down, + test_user_info_verified, + test_user_info_unverified, + ] + + for test in tests: + try: + await test() + passed += 1 + except Exception as e: + failed += 1 + print(f"FAIL: {test.__name__}: {e}") + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 + + +if __name__ == "__main__": + success = asyncio.run(run_all_tests()) + sys.exit(0 if success else 1) diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index c1d08f0f..c06c6405 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,18 +1,11 @@ import sys from pathlib import Path -import asyncio -sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) -import discord -from discord import Interaction, Member, Guild, User from unittest.mock import Mock, AsyncMock, patch - -from backend.integrations.discord.permissions import ( - require_admin, - require_bot_owner, - get_permission_embed, -) +import discord +import asyncio class MockCog: @@ -21,209 +14,192 @@ def __init__(self): def create_mock_interaction(user_id=999999999, guild_id=987654321, command_name="test_command", is_admin_user=False): - interaction = Mock(spec=Interaction) - interaction.user = Mock(spec=User) + interaction = Mock() + interaction.user = Mock() interaction.user.id = user_id interaction.user.name = "test_user" - - interaction.guild = Mock(spec=Guild) + + interaction.guild = Mock() interaction.guild.id = guild_id - - member = Mock(spec=Member) + + member = Mock() member.guild_permissions = Mock() member.guild_permissions.administrator = is_admin_user interaction.guild.get_member = Mock(return_value=member) - + interaction.command = Mock() interaction.command.name = command_name - + interaction.response = Mock() interaction.response.is_done = Mock(return_value=False) interaction.response.send_message = AsyncMock() - + interaction.followup = Mock() interaction.followup.send = AsyncMock() - + return interaction async def test_admin_decorator_allows_administrator(): - """Admin decorator should allow users with ADMINISTRATOR permission""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): - + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) cog = MockCog() - + called = False - + @require_admin - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): nonlocal called called = True - + await test_command(cog, interaction) - + assert called, "Command should execute for admin user" - assert not interaction.response.send_message.called, "Should not send error message" - print("✓ test_admin_decorator_allows_administrator passed") + print("PASS: test_admin_decorator_allows_administrator") async def test_admin_decorator_allows_bot_owner(): - """Bot owner should pass through admin decorator""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): - + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) cog = MockCog() - + called = False - + @require_admin - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): nonlocal called called = True - + await test_command(cog, interaction) - + assert called, "Command should execute for bot owner" - assert not interaction.response.send_message.called, "Should not send error message" - print("✓ test_admin_decorator_allows_bot_owner passed") + print("PASS: test_admin_decorator_allows_bot_owner") async def test_admin_decorator_denies_regular_user(): - """Regular users should be denied by admin decorator""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): - + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=999999999, is_admin_user=False) cog = MockCog() - + called = False - + @require_admin - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): nonlocal called called = True - + await test_command(cog, interaction) - - assert not called, "Command should not execute" + + assert not called, "Command should not execute for regular user" assert interaction.response.send_message.called, "Should send error message" - call_kwargs = interaction.response.send_message.call_args.kwargs - assert call_kwargs["ephemeral"] is True - assert isinstance(call_kwargs["embed"], discord.Embed) - print("✓ test_admin_decorator_denies_regular_user passed") + print("PASS: test_admin_decorator_denies_regular_user") async def test_bot_owner_decorator_denies_admin(): - """Admin users without bot owner status should be denied""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): - + from integrations.discord.permissions import require_bot_owner + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) cog = MockCog() - + called = False - + @require_bot_owner - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): nonlocal called called = True - + await test_command(cog, interaction) - - assert not called, "Command should not execute for non-owner" - assert interaction.response.send_message.called, "Should send error message" - print("✓ test_bot_owner_decorator_denies_admin passed") + + assert not called, "Command should not execute for non-owner admin" + print("PASS: test_bot_owner_decorator_denies_admin") async def test_bot_owner_decorator_allows_owner(): - """Bot owner should pass through bot owner decorator""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): - + from integrations.discord.permissions import require_bot_owner + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) cog = MockCog() - + called = False - + @require_bot_owner - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): nonlocal called called = True - + await test_command(cog, interaction) - + assert called, "Command should execute for bot owner" - assert not interaction.response.send_message.called, "Should not send error message" - print("✓ test_bot_owner_decorator_allows_owner passed") + print("PASS: test_bot_owner_decorator_allows_owner") async def test_permission_check_logging(): - """All permission checks should be logged to database""" - with patch("backend.integrations.discord.permissions.settings") as mock_settings, \ - patch("backend.integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: - + from integrations.discord.permissions import require_admin + + with patch("integrations.discord.permissions.settings") as mock_settings, \ + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: + mock_settings.bot_owner_id = 123456789 - interaction = create_mock_interaction(user_id=999999999, is_admin_user=False, command_name="stats") cog = MockCog() - + @require_admin - async def test_command(self, interaction: Interaction): + async def test_command(self, interaction): pass - + await test_command(cog, interaction) - + assert mock_log.called, "Should log permission check" call_kwargs = mock_log.call_args.kwargs assert call_kwargs["executor_id"] == "999999999" assert call_kwargs["command_name"] == "stats" assert call_kwargs["action_result"] == "failure" - assert "Permission denied" in call_kwargs["error_message"] - print("✓ test_permission_check_logging passed") + print("PASS: test_permission_check_logging") def test_error_embed_generation(): - """Error embeds should have helpful messages""" + from integrations.discord.permissions import get_permission_embed + embed = get_permission_embed() - + assert embed.title == "Permission Denied" assert "don't have permission" in embed.description - assert embed.color == discord.Color.red() assert len(embed.fields) >= 2 - - required_field = embed.fields[0] - assert required_field.name == "Required Permission" - assert "Administrator" in required_field.value or "Bot Owner" in required_field.value - - contact_field = embed.fields[1] - assert contact_field.name == "Contact" - assert "bot owner" in contact_field.value.lower() - print("✓ test_error_embed_generation passed") + print("PASS: test_error_embed_generation") async def run_all_tests(): - """Run all tests""" - print("\n" + "="*60) + print("\n" + "=" * 60) print("Running Admin Permissions Tests") - print("="*60 + "\n") - - tests_passed = 0 - tests_failed = 0 - - test_functions = [ + print("=" * 60 + "\n") + + passed = 0 + failed = 0 + + tests = [ test_admin_decorator_allows_administrator, test_admin_decorator_allows_bot_owner, test_admin_decorator_denies_regular_user, @@ -231,34 +207,27 @@ async def run_all_tests(): test_bot_owner_decorator_allows_owner, test_permission_check_logging, ] - - for test_func in test_functions: + + for test in tests: try: - await test_func() - tests_passed += 1 - except AssertionError as e: - tests_failed += 1 - print(f"✗ {test_func.__name__} failed: {e}") + await test() + passed += 1 except Exception as e: - tests_failed += 1 - print(f"✗ {test_func.__name__} error: {e}") - - # Run sync test + failed += 1 + print(f"FAIL: {test.__name__}: {e}") + try: test_error_embed_generation() - tests_passed += 1 - except AssertionError as e: - tests_failed += 1 - print(f"✗ test_error_embed_generation failed: {e}") + passed += 1 except Exception as e: - tests_failed += 1 - print(f"✗ test_error_embed_generation error: {e}") - - print("\n" + "="*60) - print(f"Results: {tests_passed} passed, {tests_failed} failed") - print("="*60 + "\n") - - return tests_failed == 0 + failed += 1 + print(f"FAIL: test_error_embed_generation: {e}") + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 if __name__ == "__main__": From 7e7fe2a5e3deb80291069988f76694a2f5e161eb Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 14:40:33 +0530 Subject: [PATCH 08/18] feat:wire /admin commands to service layer --- backend/app/database/supabase/client.py | 2 +- backend/app/utils/admin_logger.py | 2 +- backend/integrations/discord/admin_cog.py | 191 ++++++++++++++++------ tests/test_admin_management.py | 6 +- tests/test_embedding_service.py | 4 +- tests/test_supabase.py | 9 +- tests/test_weaviate.py | 11 +- tests/tests_db.py | 5 +- 8 files changed, 163 insertions(+), 67 deletions(-) diff --git a/backend/app/database/supabase/client.py b/backend/app/database/supabase/client.py index 09868886..b0f5fa7c 100644 --- a/backend/app/database/supabase/client.py +++ b/backend/app/database/supabase/client.py @@ -1,4 +1,4 @@ -from backend.app.core.config import settings +from app.core.config import settings from supabase._async.client import AsyncClient supabase_client: AsyncClient = AsyncClient( diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index 00069f04..73ae2c21 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -3,7 +3,7 @@ from datetime import datetime import uuid -from backend.app.database.supabase.client import get_supabase_client +from app.database.supabase.client import get_supabase_client logger = logging.getLogger(__name__) diff --git a/backend/integrations/discord/admin_cog.py b/backend/integrations/discord/admin_cog.py index 5649dd7b..0703fca0 100644 --- a/backend/integrations/discord/admin_cog.py +++ b/backend/integrations/discord/admin_cog.py @@ -6,6 +6,14 @@ from integrations.discord.bot import DiscordBot from integrations.discord.permissions import require_admin from app.core.orchestration.queue_manager import AsyncQueueManager +from app.services.admin import ( + BotStatsService, + HealthCheckService, + UserInfoService, + QueueService, + CacheService, + UserManagementService, +) logger = logging.getLogger(__name__) @@ -16,6 +24,12 @@ class AdminCommands(commands.GroupCog, name="admin"): def __init__(self, bot: DiscordBot, queue_manager: AsyncQueueManager): self.bot = bot self.queue = queue_manager + self.stats_service = BotStatsService(bot=bot, queue_manager=queue_manager) + self.health_service = HealthCheckService(queue_manager=queue_manager) + self.user_info_service = UserInfoService(bot=bot) + self.queue_service = QueueService(queue_manager=queue_manager) + self.cache_service = CacheService(bot=bot) + self.user_management_service = UserManagementService(bot=bot, queue_manager=queue_manager) super().__init__() async def cog_command_error(self, interaction: Interaction, error: Exception): @@ -44,16 +58,26 @@ async def stats(self, interaction: Interaction): await interaction.response.defer(ephemeral=True) try: - guild_count = len(self.bot.guilds) - total_members = sum(guild.member_count or 0 for guild in self.bot.guilds) - active_threads = len(self.bot.active_threads) + stats = await self.stats_service.get_all_stats() embed = discord.Embed(title="Bot Statistics", color=discord.Color.blue()) - embed.add_field(name="Servers", value=str(guild_count), inline=True) - embed.add_field(name="Total Members", value=str(total_members), inline=True) - embed.add_field(name="Active Threads", value=str(active_threads), inline=True) - embed.add_field(name="Latency", value=f"{round(self.bot.latency * 1000)}ms", inline=True) - embed.set_footer(text="More detailed stats coming soon") + embed.add_field(name="Servers", value=str(stats.guild_count), inline=True) + embed.add_field(name="Total Members", value=str(stats.total_members), inline=True) + embed.add_field(name="Active Threads", value=str(stats.active_threads), inline=True) + embed.add_field(name="Latency", value=f"{stats.latency_ms}ms", inline=True) + embed.add_field(name="Memory Usage", value=f"{stats.memory_mb} MB", inline=True) + embed.add_field(name="Uptime", value=f"{int(stats.uptime_seconds)}s", inline=True) + embed.add_field(name="Messages Today", value=str(stats.messages_today), inline=True) + embed.add_field(name="Messages (7d)", value=str(stats.messages_week), inline=True) + embed.add_field( + name="Queue", + value=( + f"High: {stats.queue_high}\n" + f"Medium: {stats.queue_medium}\n" + f"Low: {stats.queue_low}" + ), + inline=False, + ) await interaction.followup.send(embed=embed, ephemeral=True) @@ -68,11 +92,40 @@ async def health(self, interaction: Interaction): await interaction.response.defer(ephemeral=True) try: - # TODO: Add actual health checks for Supabase, Weaviate, RabbitMQ, etc - embed = discord.Embed(title="System Health", color=discord.Color.green()) - embed.add_field(name="Discord API", value="Healthy", inline=True) - embed.add_field(name="Bot Status", value="Running", inline=True) - embed.set_footer(text="Full service checks coming in next update") + health = await self.health_service.get_all_health() + + status_color = { + "healthy": discord.Color.green(), + "degraded": discord.Color.orange(), + "unhealthy": discord.Color.red(), + } + status_emoji = { + "healthy": "✅", + "degraded": "⚠️", + "unhealthy": "❌", + } + + embed = discord.Embed( + title="System Health", + color=status_color.get(health.overall_status, discord.Color.orange()), + ) + embed.add_field( + name="Overall", + value=f"{status_emoji.get(health.overall_status, '⚠️')} {health.overall_status.title()}", + inline=False, + ) + + for service in health.services: + details = f"{service.latency_ms}ms" + if service.error: + details += f"\n{service.error}" + embed.add_field( + name=f"{status_emoji.get(service.status, '⚠️')} {service.name}", + value=details, + inline=True, + ) + + embed.set_footer(text=f"Checked at {health.timestamp}") await interaction.followup.send(embed=embed, ephemeral=True) @@ -89,20 +142,19 @@ async def user_info(self, interaction: Interaction, user: discord.User): try: member = interaction.guild.get_member(user.id) if interaction.guild else None - created = user.created_at.strftime("%Y-%m-%d") + info = await self.user_info_service.get_full_user_info(user, member) embed = discord.Embed(title="User Information", color=discord.Color.blue()) embed.set_thumbnail(url=user.display_avatar.url) - embed.add_field(name="Username", value=user.name, inline=True) - embed.add_field(name="ID", value=str(user.id), inline=True) - embed.add_field(name="Created", value=created, inline=True) - - if member: - embed.add_field(name="Nickname", value=member.display_name or "None", inline=True) - embed.add_field(name="Roles", value=str(len(member.roles) - 1), inline=True) - - # TODO: Add verification status, message count, etc from database - embed.set_footer(text="More details coming soon") + embed.add_field(name="Username", value=info.discord_username, inline=True) + embed.add_field(name="ID", value=info.discord_id, inline=True) + embed.add_field(name="Created", value=info.created_at, inline=True) + embed.add_field(name="Verified", value="Yes" if info.is_verified else "No", inline=True) + embed.add_field(name="GitHub", value=info.github_username or "Not linked", inline=True) + embed.add_field(name="Messages", value=str(info.message_count), inline=True) + embed.add_field(name="Active Thread", value="Yes" if info.has_active_thread else "No", inline=True) + embed.add_field(name="Roles", value=str(info.roles_count), inline=True) + embed.add_field(name="Last Message", value=info.last_message_at or "Never", inline=False) await interaction.followup.send(embed=embed, ephemeral=True) @@ -134,21 +186,26 @@ async def user_reset( return try: - # TODO: Implement actual reset logic with confirmation dialog - actions = [] - if reset_memory: - actions.append("memory") - if reset_thread: - actions.append("thread") - if reset_verification: - actions.append("verification") + result = await self.user_management_service.reset_user( + user_id=str(user.id), + reset_memory=reset_memory, + reset_thread=reset_thread, + reset_verification=reset_verification, + ) + + success = len(result.errors) == 0 embed = discord.Embed( - title="User Reset", - description=f"Would reset {', '.join(actions)} for {user.mention}", - color=discord.Color.orange() + title="User Reset Complete" if success else "User Reset Completed with Issues", + description=f"Target: {user.mention}", + color=discord.Color.green() if success else discord.Color.orange(), ) - embed.set_footer(text="Need to add confirmation dialog first") + embed.add_field(name="Memory Cleared", value="Yes" if result.memory_cleared else "No", inline=True) + embed.add_field(name="Thread Closed", value="Yes" if result.thread_closed else "No", inline=True) + embed.add_field(name="Verification Reset", value="Yes" if result.verification_reset else "No", inline=True) + + if result.errors: + embed.add_field(name="Errors", value="\n".join(result.errors), inline=False) await interaction.followup.send(embed=embed, ephemeral=True) @@ -163,12 +220,36 @@ async def queue_status(self, interaction: Interaction): await interaction.response.defer(ephemeral=True) try: - # TODO: Get actual queue stats from RabbitMQ + status = await self.queue_service.get_queue_stats() + + total = status.total_pending + if total == 0: + color = discord.Color.green() + elif total < 10: + color = discord.Color.blue() + elif total < 50: + color = discord.Color.orange() + else: + color = discord.Color.red() + embed = discord.Embed(title="Queue Status", color=discord.Color.blue()) - embed.add_field(name="High", value="0 pending", inline=True) - embed.add_field(name="Medium", value="0 pending", inline=True) - embed.add_field(name="Low", value="0 pending", inline=True) - embed.set_footer(text="Need to wire up RabbitMQ stats") + embed.color = color + embed.add_field( + name="High", + value=f"{status.high.pending} pending\n{status.high.consumers} consumers", + inline=True, + ) + embed.add_field( + name="Medium", + value=f"{status.medium.pending} pending\n{status.medium.consumers} consumers", + inline=True, + ) + embed.add_field( + name="Low", + value=f"{status.low.pending} pending\n{status.low.consumers} consumers", + inline=True, + ) + embed.add_field(name="Total Pending", value=str(status.total_pending), inline=False) await interaction.followup.send(embed=embed, ephemeral=True) @@ -190,14 +271,19 @@ async def queue_clear(self, interaction: Interaction, priority: str = "all"): await interaction.response.defer(ephemeral=True) try: - # TODO: Add confirmation buttons before actually clearing + cleared = await self.queue_service.clear_queue(priority=priority) + total = sum(cleared.values()) + embed = discord.Embed( - title="Queue Clear", - description=f"This would clear {priority} priority queue(s).", - color=discord.Color.orange() + title="Queue Cleared", + description=f"Cleared queue scope: {priority}", + color=discord.Color.orange(), ) - embed.add_field(name="Warning", value="Can't undo this. Need confirmation dialog first.", inline=False) - + embed.add_field(name="High", value=str(cleared.get("high", 0)), inline=True) + embed.add_field(name="Medium", value=str(cleared.get("medium", 0)), inline=True) + embed.add_field(name="Low", value=str(cleared.get("low", 0)), inline=True) + embed.add_field(name="Total Cleared", value=str(total), inline=False) + await interaction.followup.send(embed=embed, ephemeral=True) except Exception as e: @@ -208,6 +294,7 @@ async def queue_clear(self, interaction: Interaction, priority: str = "all"): @app_commands.describe(cache_type="What to clear") @app_commands.choices(cache_type=[ app_commands.Choice(name="All", value="all"), + app_commands.Choice(name="Active Threads", value="active_threads"), app_commands.Choice(name="Embeddings", value="embeddings"), app_commands.Choice(name="Memories", value="memories") ]) @@ -217,13 +304,15 @@ async def cache_clear(self, interaction: Interaction, cache_type: str = "all"): await interaction.response.defer(ephemeral=True) try: - # TODO: Implement actual cache clearing + cleared = await self.cache_service.clear_cache(cache_type=cache_type) + embed = discord.Embed( title="Cache Clear", - description=f"Would clear {cache_type} cache.", - color=discord.Color.blue() + description=f"Cleared cache type: {cache_type}", + color=discord.Color.blue(), ) - embed.set_footer(text="Still need to implement this") + for name, count in cleared.items(): + embed.add_field(name=name.replace("_", " ").title(), value=str(count), inline=True) await interaction.followup.send(embed=embed, ephemeral=True) diff --git a/tests/test_admin_management.py b/tests/test_admin_management.py index cfe717de..c3aadccd 100644 --- a/tests/test_admin_management.py +++ b/tests/test_admin_management.py @@ -1,12 +1,12 @@ +import asyncio +from unittest.mock import Mock, AsyncMock, patch import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) -from unittest.mock import Mock, AsyncMock, patch -import asyncio - +print(Path(__file__).parent.parent / "backend" / "app" / "services" / "admin" / "queue_service.py") async def test_queue_status(): from app.services.admin.queue_service import QueueService, FullQueueStatus, QueueStats diff --git a/tests/test_embedding_service.py b/tests/test_embedding_service.py index 9794bebb..be557954 100644 --- a/tests/test_embedding_service.py +++ b/tests/test_embedding_service.py @@ -1,7 +1,7 @@ import sys import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from backend.app.services.embedding_service.service import EmbeddingService +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'backend'))) +from app.services.embedding_service.service import EmbeddingService import unittest from sklearn.metrics.pairwise import cosine_similarity diff --git a/tests/test_supabase.py b/tests/test_supabase.py index 55b98671..6b7eb028 100644 --- a/tests/test_supabase.py +++ b/tests/test_supabase.py @@ -1,6 +1,11 @@ -from backend.app.models.database.supabase import User, Interaction, CodeChunk, Repository +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from app.models.database.supabase import User, Interaction, CodeChunk, Repository from uuid import uuid4 -from backend.app.database.supabase.client import get_supabase_client +from app.database.supabase.client import get_supabase_client from datetime import datetime # Your User model import client = get_supabase_client() diff --git a/tests/test_weaviate.py b/tests/test_weaviate.py index ff8fd863..1b5a183a 100644 --- a/tests/test_weaviate.py +++ b/tests/test_weaviate.py @@ -1,10 +1,13 @@ -from app.db.weaviate.weaviate_client import get_client +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from app.database.weaviate.client import get_client from datetime import datetime from uuid import uuid4 -from app.model.weaviate.models import ( +from app.models.database.weaviate import ( WeaviateUserProfile, - WeaviateCodeChunk, - WeaviateInteraction ) diff --git a/tests/tests_db.py b/tests/tests_db.py index 36cffa61..ee44a5e4 100644 --- a/tests/tests_db.py +++ b/tests/tests_db.py @@ -1,10 +1,9 @@ import sys import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from backend.app.services.vector_db.service import EmbeddingItem, VectorDBService +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'backend'))) +from app.services.vector_db.service import EmbeddingItem, VectorDBService import asyncio import logging -from backend.app.services.vector_db.service import EmbeddingItem, VectorDBService logging.basicConfig(level=logging.INFO) From 1c5609227a27c77fa7d0914f4c983512c64cd59c Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:01:54 +0530 Subject: [PATCH 09/18] feat:add destructive action confirmations --- .../services/admin/health_check_service.py | 1 + backend/app/utils/admin_logger.py | 27 +++++ backend/integrations/discord/admin_cog.py | 111 ++++++++++++++++-- backend/main.py | 6 + tests/test_admin_integration.py | 10 +- tests/test_admin_management.py | 6 +- tests/test_admin_monitoring.py | 18 ++- tests/test_admin_permissions.py | 4 +- tests/test_embedding_service.py | 6 +- tests/test_supabase.py | 4 +- tests/test_weaviate.py | 4 +- tests/tests_db.py | 6 +- 12 files changed, 173 insertions(+), 30 deletions(-) diff --git a/backend/app/services/admin/health_check_service.py b/backend/app/services/admin/health_check_service.py index 749829b5..dc93d314 100644 --- a/backend/app/services/admin/health_check_service.py +++ b/backend/app/services/admin/health_check_service.py @@ -129,6 +129,7 @@ async def get_all_health(self) -> SystemHealth: self.check_supabase(), self.check_rabbitmq(), self.check_weaviate(), + self.check_falkordb(), self.check_gemini_api(), return_exceptions=True ) diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index 73ae2c21..55e8f43d 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -7,6 +7,30 @@ logger = logging.getLogger(__name__) +_admin_logs_table_ready: Optional[bool] = None + + +async def ensure_admin_logs_table() -> bool: + """Check whether the admin_logs table is available in the current environment.""" + global _admin_logs_table_ready + + if _admin_logs_table_ready is True: + return True + + try: + supabase = get_supabase_client() + await supabase.table("admin_logs").select("id").limit(1).execute() + _admin_logs_table_ready = True + return True + except Exception as e: + _admin_logs_table_ready = False + logger.error( + "admin_logs table is unavailable. Apply migration at " + "backend/database/02_create_admin_logs_table.sql. Details: %s", + str(e), + ) + return False + async def log_admin_action( executor_id: str, @@ -21,6 +45,9 @@ async def log_admin_action( ) -> Optional[str]: """Log admin command execution to database. Returns log UUID or None if failed.""" try: + if not await ensure_admin_logs_table(): + return None + supabase = get_supabase_client() # Validate action_result diff --git a/backend/integrations/discord/admin_cog.py b/backend/integrations/discord/admin_cog.py index 0703fca0..880ed131 100644 --- a/backend/integrations/discord/admin_cog.py +++ b/backend/integrations/discord/admin_cog.py @@ -5,6 +5,7 @@ from integrations.discord.bot import DiscordBot from integrations.discord.permissions import require_admin +from integrations.discord.views import ConfirmActionView from app.core.orchestration.queue_manager import AsyncQueueManager from app.services.admin import ( BotStatsService, @@ -32,6 +33,46 @@ def __init__(self, bot: DiscordBot, queue_manager: AsyncQueueManager): self.user_management_service = UserManagementService(bot=bot, queue_manager=queue_manager) super().__init__() + async def _confirm_action( + self, + interaction: Interaction, + *, + title: str, + description: str, + timeout: float = 30.0, + ) -> bool: + """Show a confirmation dialog and return True only when confirmed.""" + embed = discord.Embed( + title=title, + description=description, + color=discord.Color.orange(), + ) + embed.set_footer(text=f"This action times out in {int(timeout)} seconds.") + + view = ConfirmActionView(timeout=timeout) + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) + await view.wait() + + if view.interaction is None: + timeout_embed = discord.Embed( + title="Action Timed Out", + description="No confirmation received. Operation cancelled.", + color=discord.Color.light_grey(), + ) + await interaction.edit_original_response(embed=timeout_embed, view=None) + return False + + if not view.confirmed: + cancelled_embed = discord.Embed( + title="Action Cancelled", + description="No changes were made.", + color=discord.Color.light_grey(), + ) + await interaction.edit_original_response(embed=cancelled_embed, view=None) + return False + + return True + async def cog_command_error(self, interaction: Interaction, error: Exception): """Handle errors for admin commands.""" logger.error(f"Admin command error: {error}", exc_info=True) @@ -179,13 +220,30 @@ async def user_reset( reset_verification: bool = False ): """Reset various aspects of user state.""" - await interaction.response.defer(ephemeral=True) - if not any([reset_memory, reset_thread, reset_verification]): - await interaction.followup.send("Select at least one thing to reset.", ephemeral=True) + await interaction.response.send_message("Select at least one thing to reset.", ephemeral=True) return try: + actions = [] + if reset_memory: + actions.append("memory") + if reset_thread: + actions.append("thread") + if reset_verification: + actions.append("verification") + + confirmed = await self._confirm_action( + interaction, + title="Confirm User Reset", + description=( + f"You are about to reset **{', '.join(actions)}** for {user.mention}.\n" + "This action may be destructive and cannot be fully undone." + ), + ) + if not confirmed: + return + result = await self.user_management_service.reset_user( user_id=str(user.id), reset_memory=reset_memory, @@ -207,11 +265,11 @@ async def user_reset( if result.errors: embed.add_field(name="Errors", value="\n".join(result.errors), inline=False) - await interaction.followup.send(embed=embed, ephemeral=True) + await interaction.edit_original_response(embed=embed, view=None) except Exception as e: logger.error(f"User reset failed: {e}", exc_info=True) - await interaction.followup.send(f"Reset failed: {str(e)}", ephemeral=True) + await interaction.edit_original_response(content=f"Reset failed: {str(e)}", embed=None, view=None) @app_commands.command(name="queue_status", description="Check queue status") @require_admin @@ -268,9 +326,18 @@ async def queue_status(self, interaction: Interaction): @require_admin async def queue_clear(self, interaction: Interaction, priority: str = "all"): """Clear stuck messages from queue.""" - await interaction.response.defer(ephemeral=True) - try: + confirmed = await self._confirm_action( + interaction, + title="Confirm Queue Clear", + description=( + f"You are about to clear the **{priority}** queue scope.\n" + "This action is destructive and cannot be undone." + ), + ) + if not confirmed: + return + cleared = await self.queue_service.clear_queue(priority=priority) total = sum(cleared.values()) @@ -284,11 +351,11 @@ async def queue_clear(self, interaction: Interaction, priority: str = "all"): embed.add_field(name="Low", value=str(cleared.get("low", 0)), inline=True) embed.add_field(name="Total Cleared", value=str(total), inline=False) - await interaction.followup.send(embed=embed, ephemeral=True) + await interaction.edit_original_response(embed=embed, view=None) except Exception as e: logger.error(f"Queue clear failed: {e}", exc_info=True) - await interaction.followup.send(f"Clear failed: {str(e)}", ephemeral=True) + await interaction.edit_original_response(content=f"Clear failed: {str(e)}", embed=None, view=None) @app_commands.command(name="cache_clear", description="Clear cached data") @app_commands.describe(cache_type="What to clear") @@ -301,9 +368,21 @@ async def queue_clear(self, interaction: Interaction, priority: str = "all"): @require_admin async def cache_clear(self, interaction: Interaction, cache_type: str = "all"): """Clear various caches.""" - await interaction.response.defer(ephemeral=True) - try: + if cache_type == "all": + confirmed = await self._confirm_action( + interaction, + title="Confirm Cache Clear", + description=( + "You are about to clear **all** caches.\n" + "This action may affect active sessions and performance." + ), + ) + if not confirmed: + return + else: + await interaction.response.defer(ephemeral=True) + cleared = await self.cache_service.clear_cache(cache_type=cache_type) embed = discord.Embed( @@ -314,11 +393,17 @@ async def cache_clear(self, interaction: Interaction, cache_type: str = "all"): for name, count in cleared.items(): embed.add_field(name=name.replace("_", " ").title(), value=str(count), inline=True) - await interaction.followup.send(embed=embed, ephemeral=True) + if cache_type == "all": + await interaction.edit_original_response(embed=embed, view=None) + else: + await interaction.followup.send(embed=embed, ephemeral=True) except Exception as e: logger.error(f"Cache clear failed: {e}", exc_info=True) - await interaction.followup.send(f"Clear failed: {str(e)}", ephemeral=True) + if interaction.response.is_done(): + await interaction.followup.send(f"Clear failed: {str(e)}", ephemeral=True) + else: + await interaction.response.send_message(f"Clear failed: {str(e)}", ephemeral=True) async def setup(bot: DiscordBot): diff --git a/backend/main.py b/backend/main.py index ff74e493..b8baba37 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,6 +12,7 @@ from app.core.orchestration.agent_coordinator import AgentCoordinator from app.core.orchestration.queue_manager import AsyncQueueManager from app.database.weaviate.client import get_weaviate_client +from app.utils.admin_logger import ensure_admin_logs_table from integrations.discord.bot import DiscordBot from discord.ext import commands # DevRel commands are now loaded dynamically (commented out below) @@ -45,6 +46,11 @@ async def start_background_tasks(self): await self.queue_manager.start(num_workers=3) + if await ensure_admin_logs_table(): + logger.info("Admin logs table check passed") + else: + logger.warning("Admin logs table check failed; admin action logs will be skipped") + # --- Load commands inside the async startup function --- try: await self.discord_bot.load_extension("integrations.discord.cogs") diff --git a/tests/test_admin_integration.py b/tests/test_admin_integration.py index ba82e496..9d94e622 100644 --- a/tests/test_admin_integration.py +++ b/tests/test_admin_integration.py @@ -1,7 +1,9 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from unittest.mock import Mock, AsyncMock, patch import asyncio @@ -32,12 +34,14 @@ async def test_full_workflow(): with patch.object(health_service, 'check_supabase', new_callable=AsyncMock) as h1, \ patch.object(health_service, 'check_rabbitmq', new_callable=AsyncMock) as h2, \ patch.object(health_service, 'check_weaviate', new_callable=AsyncMock) as h3, \ - patch.object(health_service, 'check_gemini_api', new_callable=AsyncMock) as h4: + patch.object(health_service, 'check_falkordb', new_callable=AsyncMock) as h4, \ + patch.object(health_service, 'check_gemini_api', new_callable=AsyncMock) as h5: h1.return_value = ServiceHealth("Supabase", "healthy", 10) h2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) h3.return_value = ServiceHealth("Weaviate", "healthy", 15) - h4.return_value = ServiceHealth("Gemini", "healthy", 100) + h4.return_value = ServiceHealth("FalkorDB", "healthy", 12) + h5.return_value = ServiceHealth("Gemini", "healthy", 100) health = await health_service.get_all_health() assert health.overall_status == "healthy" diff --git a/tests/test_admin_management.py b/tests/test_admin_management.py index c3aadccd..0310c001 100644 --- a/tests/test_admin_management.py +++ b/tests/test_admin_management.py @@ -3,10 +3,10 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) - -print(Path(__file__).parent.parent / "backend" / "app" / "services" / "admin" / "queue_service.py") async def test_queue_status(): from app.services.admin.queue_service import QueueService, FullQueueStatus, QueueStats diff --git a/tests/test_admin_monitoring.py b/tests/test_admin_monitoring.py index 467d0130..74704ffc 100644 --- a/tests/test_admin_monitoring.py +++ b/tests/test_admin_monitoring.py @@ -1,7 +1,9 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from unittest.mock import Mock, AsyncMock, patch import asyncio @@ -56,17 +58,19 @@ async def test_health_all_healthy(): with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ - patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m4: + patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: m1.return_value = ServiceHealth("Supabase", "healthy", 10) m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) m3.return_value = ServiceHealth("Weaviate", "healthy", 15) - m4.return_value = ServiceHealth("Gemini", "healthy", 100) + m4.return_value = ServiceHealth("FalkorDB", "healthy", 12) + m5.return_value = ServiceHealth("Gemini", "healthy", 100) health = await service.get_all_health() assert health.overall_status == "healthy" - assert len(health.services) == 4 + assert len(health.services) == 5 print("PASS: test_health_all_healthy") @@ -78,12 +82,14 @@ async def test_health_service_down(): with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ - patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m4: + patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: m1.return_value = ServiceHealth("Supabase", "unhealthy", 0, "Connection failed") m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) m3.return_value = ServiceHealth("Weaviate", "healthy", 15) - m4.return_value = ServiceHealth("Gemini", "healthy", 100) + m4.return_value = ServiceHealth("FalkorDB", "healthy", 12) + m5.return_value = ServiceHealth("Gemini", "healthy", 100) health = await service.get_all_health() diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index c06c6405..acd4b59f 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,7 +1,9 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from unittest.mock import Mock, AsyncMock, patch import discord diff --git a/tests/test_embedding_service.py b/tests/test_embedding_service.py index be557954..ca00fe4f 100644 --- a/tests/test_embedding_service.py +++ b/tests/test_embedding_service.py @@ -1,6 +1,10 @@ import sys import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'backend'))) +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from app.services.embedding_service.service import EmbeddingService import unittest from sklearn.metrics.pairwise import cosine_similarity diff --git a/tests/test_supabase.py b/tests/test_supabase.py index 6b7eb028..53e4bcff 100644 --- a/tests/test_supabase.py +++ b/tests/test_supabase.py @@ -1,7 +1,9 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from app.models.database.supabase import User, Interaction, CodeChunk, Repository from uuid import uuid4 diff --git a/tests/test_weaviate.py b/tests/test_weaviate.py index 1b5a183a..30073823 100644 --- a/tests/test_weaviate.py +++ b/tests/test_weaviate.py @@ -1,7 +1,9 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from app.database.weaviate.client import get_client from datetime import datetime diff --git a/tests/tests_db.py b/tests/tests_db.py index ee44a5e4..404b5c2c 100644 --- a/tests/tests_db.py +++ b/tests/tests_db.py @@ -1,6 +1,10 @@ import sys import os -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'backend'))) +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "backend")) from app.services.vector_db.service import EmbeddingItem, VectorDBService import asyncio import logging From 7374f492ea2f135970f479c080cd8b7a85a9a19a Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:14:06 +0530 Subject: [PATCH 10/18] chore(admin): align imports, update admin tests, and replace debug prints with logging --- backend/integrations/discord/bot.py | 10 ++++----- backend/integrations/discord/cogs.py | 4 ++-- tests/test_admin_integration.py | 21 +++++++++---------- tests/test_admin_monitoring.py | 31 ++++++++++++++-------------- tests/test_admin_permissions.py | 19 ++++++++--------- tests/test_embedding_service.py | 27 ++++++++++++------------ tests/test_supabase.py | 8 +++---- tests/test_weaviate.py | 13 ++++++------ tests/tests_db.py | 6 +++--- 9 files changed, 68 insertions(+), 71 deletions(-) diff --git a/backend/integrations/discord/bot.py b/backend/integrations/discord/bot.py index 3dced54c..329542c7 100644 --- a/backend/integrations/discord/bot.py +++ b/backend/integrations/discord/bot.py @@ -36,21 +36,21 @@ def _register_queue_handlers(self): async def on_ready(self): """Bot ready event""" logger.info(f'Enhanced Discord bot logged in as {self.user}') - print(f'Bot is ready! Logged in as {self.user}') + logger.info(f'Bot is ready! Logged in as {self.user}') try: # Sync globally synced = await self.tree.sync() - print(f"Synced {len(synced)} global slash command(s)") + logger.info(f"Synced {len(synced)} global slash command(s)") # Also sync to each guild for instant availability for guild in self.guilds: try: guild_synced = await self.tree.sync(guild=guild) - print(f"Synced {len(guild_synced)} commands to guild {guild.name}") + logger.info(f"Synced {len(guild_synced)} commands to guild {guild.name}") except Exception as e: - print(f"Failed to sync to guild {guild.name}: {e}") + logger.warning(f"Failed to sync to guild {guild.name}: {e}") except Exception as e: - print(f"Failed to sync slash commands: {e}") + logger.error(f"Failed to sync slash commands: {e}") async def on_message(self, message): """Handles regular chat messages, but ignores slash commands.""" diff --git a/backend/integrations/discord/cogs.py b/backend/integrations/discord/cogs.py index 64fd0b7f..f74a83a5 100644 --- a/backend/integrations/discord/cogs.py +++ b/backend/integrations/discord/cogs.py @@ -38,9 +38,9 @@ def cog_unload(self): async def cleanup_expired_tokens(self): """Periodic cleanup of expired verification tokens""" try: - print("--> Running token cleanup task...") + logger.debug("Running token cleanup task") await cleanup_expired_tokens() - print("--> Token cleanup task finished.") + logger.debug("Token cleanup task finished") except Exception as e: logger.error(f"Error during token cleanup: {e}") diff --git a/tests/test_admin_integration.py b/tests/test_admin_integration.py index 9d94e622..12b71986 100644 --- a/tests/test_admin_integration.py +++ b/tests/test_admin_integration.py @@ -1,3 +1,5 @@ +import asyncio +from unittest.mock import Mock, AsyncMock, patch import sys from pathlib import Path @@ -5,9 +7,6 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from unittest.mock import Mock, AsyncMock, patch -import asyncio - async def test_full_workflow(): from app.services.admin.bot_stats_service import BotStatsService @@ -24,7 +23,7 @@ async def test_full_workflow(): queue_service = QueueService(None) with patch.object(stats_service, 'get_message_stats', new_callable=AsyncMock) as m1, \ - patch.object(stats_service, 'get_queue_stats', new_callable=AsyncMock) as m2: + patch.object(stats_service, 'get_queue_stats', new_callable=AsyncMock) as m2: m1.return_value = {"today": 5, "week": 20} m2.return_value = {"high": 0, "medium": 0, "low": 0} @@ -32,10 +31,10 @@ async def test_full_workflow(): assert stats.guild_count == 1 with patch.object(health_service, 'check_supabase', new_callable=AsyncMock) as h1, \ - patch.object(health_service, 'check_rabbitmq', new_callable=AsyncMock) as h2, \ - patch.object(health_service, 'check_weaviate', new_callable=AsyncMock) as h3, \ - patch.object(health_service, 'check_falkordb', new_callable=AsyncMock) as h4, \ - patch.object(health_service, 'check_gemini_api', new_callable=AsyncMock) as h5: + patch.object(health_service, 'check_rabbitmq', new_callable=AsyncMock) as h2, \ + patch.object(health_service, 'check_weaviate', new_callable=AsyncMock) as h3, \ + patch.object(health_service, 'check_falkordb', new_callable=AsyncMock) as h4, \ + patch.object(health_service, 'check_gemini_api', new_callable=AsyncMock) as h5: h1.return_value = ServiceHealth("Supabase", "healthy", 10) h2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) @@ -64,7 +63,7 @@ async def test_permission_denies_regular_user(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 @@ -108,7 +107,7 @@ async def test_permission_allows_admin(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 @@ -150,7 +149,7 @@ async def test_logging_works(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: mock_settings.bot_owner_id = 123456789 diff --git a/tests/test_admin_monitoring.py b/tests/test_admin_monitoring.py index 74704ffc..b95aeb75 100644 --- a/tests/test_admin_monitoring.py +++ b/tests/test_admin_monitoring.py @@ -1,3 +1,5 @@ +import asyncio +from unittest.mock import Mock, AsyncMock, patch import sys from pathlib import Path @@ -5,9 +7,6 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from unittest.mock import Mock, AsyncMock, patch -import asyncio - async def test_stats_service(): from app.services.admin.bot_stats_service import BotStatsService @@ -20,7 +19,7 @@ async def test_stats_service(): service = BotStatsService(mock_bot, None) with patch.object(service, 'get_message_stats', new_callable=AsyncMock) as mock_msg, \ - patch.object(service, 'get_queue_stats', new_callable=AsyncMock) as mock_queue: + patch.object(service, 'get_queue_stats', new_callable=AsyncMock) as mock_queue: mock_msg.return_value = {"today": 10, "week": 50} mock_queue.return_value = {"high": 1, "medium": 2, "low": 3} @@ -56,10 +55,10 @@ async def test_health_all_healthy(): service = HealthCheckService(None) with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ - patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ - patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ - patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ - patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: + patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ + patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ + patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: m1.return_value = ServiceHealth("Supabase", "healthy", 10) m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) @@ -80,10 +79,10 @@ async def test_health_service_down(): service = HealthCheckService(None) with patch.object(service, 'check_supabase', new_callable=AsyncMock) as m1, \ - patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ - patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ - patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ - patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: + patch.object(service, 'check_rabbitmq', new_callable=AsyncMock) as m2, \ + patch.object(service, 'check_weaviate', new_callable=AsyncMock) as m3, \ + patch.object(service, 'check_falkordb', new_callable=AsyncMock) as m4, \ + patch.object(service, 'check_gemini_api', new_callable=AsyncMock) as m5: m1.return_value = ServiceHealth("Supabase", "unhealthy", 0, "Connection failed") m2.return_value = ServiceHealth("RabbitMQ", "healthy", 5) @@ -118,8 +117,8 @@ async def test_user_info_verified(): mock_member.roles = [Mock(), Mock(), Mock()] with patch.object(service, 'get_user_profile', new_callable=AsyncMock) as m1, \ - patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ - patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: + patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ + patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: m1.return_value = {"is_verified": True, "github_username": "testuser_gh"} m2.return_value = 42 @@ -151,8 +150,8 @@ async def test_user_info_unverified(): mock_user.created_at.strftime = Mock(return_value="2024-01-01") with patch.object(service, 'get_user_profile', new_callable=AsyncMock) as m1, \ - patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ - patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: + patch.object(service, 'get_user_message_count', new_callable=AsyncMock) as m2, \ + patch.object(service, 'get_last_message', new_callable=AsyncMock) as m3: m1.return_value = None m2.return_value = 0 diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index acd4b59f..fdec677c 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,3 +1,6 @@ +import asyncio +import discord +from unittest.mock import Mock, AsyncMock, patch import sys from pathlib import Path @@ -5,10 +8,6 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from unittest.mock import Mock, AsyncMock, patch -import discord -import asyncio - class MockCog: def __init__(self): @@ -46,7 +45,7 @@ async def test_admin_decorator_allows_administrator(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) @@ -69,7 +68,7 @@ async def test_admin_decorator_allows_bot_owner(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) @@ -92,7 +91,7 @@ async def test_admin_decorator_denies_regular_user(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=999999999, is_admin_user=False) @@ -116,7 +115,7 @@ async def test_bot_owner_decorator_denies_admin(): from integrations.discord.permissions import require_bot_owner with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=999999999, is_admin_user=True) @@ -139,7 +138,7 @@ async def test_bot_owner_decorator_allows_owner(): from integrations.discord.permissions import require_bot_owner with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock): mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=123456789, is_admin_user=False) @@ -162,7 +161,7 @@ async def test_permission_check_logging(): from integrations.discord.permissions import require_admin with patch("integrations.discord.permissions.settings") as mock_settings, \ - patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: + patch("integrations.discord.permissions.log_admin_action", new_callable=AsyncMock) as mock_log: mock_settings.bot_owner_id = 123456789 interaction = create_mock_interaction(user_id=999999999, is_admin_user=False, command_name="stats") diff --git a/tests/test_embedding_service.py b/tests/test_embedding_service.py index ca00fe4f..64c2c765 100644 --- a/tests/test_embedding_service.py +++ b/tests/test_embedding_service.py @@ -1,3 +1,6 @@ +from sklearn.metrics.pairwise import cosine_similarity +import unittest +from app.services.embedding_service.service import EmbeddingService import sys import os from pathlib import Path @@ -5,9 +8,6 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from app.services.embedding_service.service import EmbeddingService -import unittest -from sklearn.metrics.pairwise import cosine_similarity class TestEmbeddingService(unittest.IsolatedAsyncioTestCase): @@ -18,44 +18,45 @@ async def test_get_embedding(self): text = "Hi, this seems to be great!" embedding = await self.embedding_service.get_embedding(text) self.assertTrue(len(embedding) == 384) - + async def test_similarity(self): texts = ["Hi, this seems to be great!", "This is good!"] embeddings = await self.embedding_service.get_embeddings(texts) similarity = cosine_similarity([embeddings[0]], [embeddings[1]])[0][0] self.assertTrue(similarity > 0.5) - + def test_get_model_info(self): # Access model once to initialize it _ = self.embedding_service.model - + info = self.embedding_service.get_model_info() - + # Check that all expected keys are present self.assertIn("model_name", info) self.assertIn("device", info) self.assertIn("embedding_size", info) - + # Verify values self.assertEqual(info["model_name"], self.embedding_service.model_name) self.assertEqual(info["device"], self.embedding_service.device) self.assertEqual(info["embedding_size"], 384) # For BGE-small model - + def test_clear_cache(self): # Access model first to ensure it's loaded _ = self.embedding_service.model self.assertIsNotNone(self.embedding_service._model) - + # Clear the cache self.embedding_service.clear_cache() - + # Verify model is cleared self.assertIsNone(self.embedding_service._model) - + # Verify model loads again after clearing _ = self.embedding_service.model self.assertIsNotNone(self.embedding_service._model) + # run the tests if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_supabase.py b/tests/test_supabase.py index 53e4bcff..b5a29a43 100644 --- a/tests/test_supabase.py +++ b/tests/test_supabase.py @@ -1,3 +1,7 @@ +from datetime import datetime # Your User model import +from app.database.supabase.client import get_supabase_client +from uuid import uuid4 +from app.models.database.supabase import User, Interaction, CodeChunk, Repository import sys from pathlib import Path @@ -5,10 +9,6 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from app.models.database.supabase import User, Interaction, CodeChunk, Repository -from uuid import uuid4 -from app.database.supabase.client import get_supabase_client -from datetime import datetime # Your User model import client = get_supabase_client() diff --git a/tests/test_weaviate.py b/tests/test_weaviate.py index 30073823..5ecde789 100644 --- a/tests/test_weaviate.py +++ b/tests/test_weaviate.py @@ -1,3 +1,9 @@ +from app.models.database.weaviate import ( + WeaviateUserProfile, +) +from uuid import uuid4 +from datetime import datetime +from app.database.weaviate.client import get_client import sys from pathlib import Path @@ -5,13 +11,6 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from app.database.weaviate.client import get_client -from datetime import datetime -from uuid import uuid4 -from app.models.database.weaviate import ( - WeaviateUserProfile, -) - def test_weaviate_client(): client = get_client() diff --git a/tests/tests_db.py b/tests/tests_db.py index 404b5c2c..dde5878d 100644 --- a/tests/tests_db.py +++ b/tests/tests_db.py @@ -1,3 +1,6 @@ +import logging +import asyncio +from app.services.vector_db.service import EmbeddingItem, VectorDBService import sys import os from pathlib import Path @@ -5,9 +8,6 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) -from app.services.vector_db.service import EmbeddingItem, VectorDBService -import asyncio -import logging logging.basicConfig(level=logging.INFO) From 2e102be541c742545df845bbfd1b536d9215ad2b Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:27:55 +0530 Subject: [PATCH 11/18] Docs:added documentation for admin commands --- docs/ADMIN_COMMANDS.md | 238 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/ADMIN_COMMANDS.md diff --git a/docs/ADMIN_COMMANDS.md b/docs/ADMIN_COMMANDS.md new file mode 100644 index 00000000..09ce4d01 --- /dev/null +++ b/docs/ADMIN_COMMANDS.md @@ -0,0 +1,238 @@ +# Admin Commands Documentation + +## Overview + +The admin commands provide server administrators and bot owners with tools to manage, monitor, and troubleshoot the Devr.AI bot. All admin commands are grouped under the `/admin` command prefix. + +## Permissions + +Admin commands require one of the following: + +- **Server Administrator**: Users with the Discord `ADMINISTRATOR` permission +- **Bot Owner**: The user ID configured in `BOT_OWNER_ID` environment variable + +All admin command executions are logged for audit purposes. + +## Setup + +### Configure Bot Owner + +Add your Discord user ID to `.env`: + +```env +BOT_OWNER_ID=your_discord_user_id_here +``` + +To find your Discord user ID: +1. Enable Developer Mode in Discord (Settings > App Settings > Advanced) +2. Right-click your profile and select "Copy User ID" + +### Run Database Migration + +Execute the admin logs table migration: + +```sql +-- Run in Supabase SQL Editor or via psql +\i backend/database/02_create_admin_logs_table.sql +``` + +Optional verification query: + +```sql +SELECT to_regclass('public.admin_logs'); +``` + +Expected result: `admin_logs` + +> Note: On startup, the backend checks whether `admin_logs` exists. If missing, +> admin commands still run, but admin-action logging is skipped until migration is applied. + +## Commands + +### /admin stats + +Display bot statistics and metrics. + +**Usage:** `/admin stats` + +**Shows:** +- Server count and total members +- Active threads +- Bot latency and uptime +- Memory usage +- Messages processed (today and 7-day) +- Queue status by priority + +### /admin health + +Check system health and service status. + +**Usage:** `/admin health` + +**Checks:** +- Supabase database connection +- RabbitMQ message queue +- Weaviate vector store +- FalkorDB availability +- Gemini API availability + +**Status Indicators:** +- Green: Healthy +- Orange: Degraded +- Red: Unhealthy + +### /admin user_info + +Get detailed information about a user. + +**Usage:** `/admin user_info user:<@user>` + +**Parameters:** +- `user`: The Discord user to look up (mention or ID) + +**Shows:** +- Discord profile (username, ID, account creation date) +- GitHub verification status +- Linked GitHub username (if verified) +- Message count +- Active thread status +- Role count + +### /admin user_reset + +Reset user state with confirmation. + +**Usage:** `/admin user_reset user:<@user> [options]` + +**Parameters:** +- `user`: The Discord user to reset +- `reset_memory`: Clear conversation memory (default: False) +- `reset_thread`: Close active thread (default: False) +- `reset_verification`: Clear GitHub verification (default: False) + +**Requires Confirmation:** Yes + +### /admin queue_status + +Check message queue status. + +**Usage:** `/admin queue_status` + +**Shows:** +- Pending messages by priority (High, Medium, Low) +- Consumer count per queue +- Total pending messages + +**Color Indicators:** +- Green: 0 pending +- Blue: 1-9 pending +- Orange: 10-49 pending +- Red: 50+ pending + +### /admin queue_clear + +Clear messages from the queue. + +**Usage:** `/admin queue_clear [priority]` + +**Parameters:** +- `priority`: Which queue to clear + - `all` (default): Clear all queues + - `high`: Clear high priority only + - `medium`: Clear medium priority only + - `low`: Clear low priority only + +**Requires Confirmation:** Yes + +### /admin cache_clear + +Clear cached data. + +**Usage:** `/admin cache_clear [cache_type]` + +**Parameters:** +- `cache_type`: What to clear + - `all` (default): Clear all caches + - `active_threads`: Clear tracked threads + - `embeddings`: Clear embedding cache + - `memories`: Clear memory cache + +**Requires Confirmation:** Yes (for `all` only) + +## Logging + +All admin command executions are logged to the `admin_logs` table with: + +- Timestamp +- Executor ID and username +- Command name and arguments +- Target user (if applicable) +- Result (success/failure/error) +- Error message (if failed) +- Server ID +- Additional metadata + +### Viewing Logs + +Query logs via Supabase dashboard or API: + +```python +from app.utils.admin_logger import get_admin_logs + +# Get recent logs +logs = await get_admin_logs(limit=50) + +# Filter by executor +logs = await get_admin_logs(executor_id="123456789") + +# Filter by command +logs = await get_admin_logs(command_name="queue_clear") + +# Get statistics +from app.utils.admin_logger import get_admin_log_stats +stats = await get_admin_log_stats(server_id="987654321") +``` + +## Best Practices + +### When to Use Queue Clear +- Stuck messages that won't process +- After system errors that corrupted messages +- During maintenance windows + +### When to Use User Reset +- User reports conversation issues +- Memory corruption +- Re-verification needed + +### When to Use Cache Clear +- After configuration changes +- Memory pressure issues +- Stale data problems + +## Troubleshooting + +### Commands Not Appearing + +1. Wait for Discord to sync slash commands (can take up to 1 hour) +2. Try `/admin` and check autocomplete +3. Restart the bot to force sync + +### Permission Denied + +1. Verify you have `ADMINISTRATOR` permission in the server +2. Check if `BOT_OWNER_ID` is set correctly in `.env` +3. Verify the bot was restarted after config changes + +### Health Check Failures + +- **Supabase**: Check credentials in `.env` +- **RabbitMQ**: Verify Docker container is running +- **Weaviate**: Check Docker container and port 8080 +- **Gemini API**: Verify API key is valid + +### Queue Not Clearing + +1. Check RabbitMQ connection with `/admin health` +2. Verify Docker containers are running +3. Check RabbitMQ management console (port 15672) From 2c840c1c9766d946a882466fe805e45849bd86c1 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:39:18 +0530 Subject: [PATCH 12/18] fix:added weaviate missing classes --- backend/app/models/database/weaviate.py | 37 ++++++++++++++++++++++++- tests/test_weaviate.py | 2 ++ tests/tests_db.py | 3 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/backend/app/models/database/weaviate.py b/backend/app/models/database/weaviate.py index a685a091..390c03dd 100644 --- a/backend/app/models/database/weaviate.py +++ b/backend/app/models/database/weaviate.py @@ -29,6 +29,32 @@ class WeaviatePullRequest(BaseModel): labels: List[str] = Field(default_factory=list, description="Labels associated with the pull request.") url: str = Field(..., description="The URL of the pull request.") + +class WeaviateCodeChunk(BaseModel): + """Represents a code chunk stored in Weaviate for semantic code search.""" + supabase_chunk_id: str = Field(..., description="The unique identifier linking to Supabase code chunk record.") + code_content: str = Field(..., description="Raw code content for the chunk.") + language: str = Field(..., description="Programming language of the code chunk.") + function_names: List[str] = Field(default_factory=list, description="Function names detected in the code chunk.") + embedding: List[float] = Field(..., description="Vector embedding representation for semantic search.") + created_at: datetime = Field(default_factory=datetime.now, description="Creation timestamp for the chunk record.") + last_updated: datetime = Field(default_factory=datetime.now, description="Last update timestamp for the chunk record.") + + model_config = ConfigDict(from_attributes=True) + + +class WeaviateInteraction(BaseModel): + """Represents an interaction summary stored in Weaviate for semantic retrieval.""" + supabase_interaction_id: str = Field(..., description="The unique identifier linking to Supabase interaction record.") + conversation_summary: str = Field(..., description="Summarized interaction content.") + platform: str = Field(..., description="Origin platform of the interaction (e.g., Discord, Web).") + topics: List[str] = Field(default_factory=list, description="Topics extracted from the interaction.") + embedding: List[float] = Field(..., description="Vector embedding representation for semantic search.") + created_at: datetime = Field(default_factory=datetime.now, description="Creation timestamp for the interaction record.") + last_updated: datetime = Field(default_factory=datetime.now, description="Last update timestamp for the interaction record.") + + model_config = ConfigDict(from_attributes=True) + class WeaviateUserProfile(BaseModel): """ Represents a user's profile data to be stored and indexed in Weaviate. @@ -125,4 +151,13 @@ class WeaviateUserProfile(BaseModel): } } - ) \ No newline at end of file + ) + + +__all__ = [ + "WeaviateRepository", + "WeaviatePullRequest", + "WeaviateCodeChunk", + "WeaviateInteraction", + "WeaviateUserProfile", +] \ No newline at end of file diff --git a/tests/test_weaviate.py b/tests/test_weaviate.py index 5ecde789..b5d09b95 100644 --- a/tests/test_weaviate.py +++ b/tests/test_weaviate.py @@ -1,5 +1,7 @@ from app.models.database.weaviate import ( WeaviateUserProfile, + WeaviateCodeChunk, + WeaviateInteraction, ) from uuid import uuid4 from datetime import datetime diff --git a/tests/tests_db.py b/tests/tests_db.py index dde5878d..1865a54d 100644 --- a/tests/tests_db.py +++ b/tests/tests_db.py @@ -1,6 +1,5 @@ import logging import asyncio -from app.services.vector_db.service import EmbeddingItem, VectorDBService import sys import os from pathlib import Path @@ -9,6 +8,8 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) +from app.services.vector_db.service import EmbeddingItem, VectorDBService + logging.basicConfig(level=logging.INFO) vector_db = VectorDBService() From 3be73b6d2f3f4d9264a1c3f6324bc63a08bf103a Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:41:04 +0530 Subject: [PATCH 13/18] fix : import order error fixed --- tests/test_embedding_service.py | 8 +++++--- tests/test_supabase.py | 10 ++++++---- tests/test_weaviate.py | 18 ++++++++++-------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/test_embedding_service.py b/tests/test_embedding_service.py index 64c2c765..3fa8e507 100644 --- a/tests/test_embedding_service.py +++ b/tests/test_embedding_service.py @@ -1,14 +1,16 @@ -from sklearn.metrics.pairwise import cosine_similarity -import unittest -from app.services.embedding_service.service import EmbeddingService import sys import os from pathlib import Path +from sklearn.metrics.pairwise import cosine_similarity +import unittest + ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) +from app.services.embedding_service.service import EmbeddingService + class TestEmbeddingService(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): diff --git a/tests/test_supabase.py b/tests/test_supabase.py index b5a29a43..71e8b4aa 100644 --- a/tests/test_supabase.py +++ b/tests/test_supabase.py @@ -1,14 +1,16 @@ -from datetime import datetime # Your User model import -from app.database.supabase.client import get_supabase_client -from uuid import uuid4 -from app.models.database.supabase import User, Interaction, CodeChunk, Repository import sys from pathlib import Path +from datetime import datetime # Your User model import +from uuid import uuid4 + ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) +from app.database.supabase.client import get_supabase_client +from app.models.database.supabase import User, Interaction, CodeChunk, Repository + client = get_supabase_client() diff --git a/tests/test_weaviate.py b/tests/test_weaviate.py index b5d09b95..f36915af 100644 --- a/tests/test_weaviate.py +++ b/tests/test_weaviate.py @@ -1,18 +1,20 @@ -from app.models.database.weaviate import ( - WeaviateUserProfile, - WeaviateCodeChunk, - WeaviateInteraction, -) -from uuid import uuid4 -from datetime import datetime -from app.database.weaviate.client import get_client import sys from pathlib import Path +from uuid import uuid4 +from datetime import datetime + ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "backend")) +from app.database.weaviate.client import get_client +from app.models.database.weaviate import ( + WeaviateUserProfile, + WeaviateCodeChunk, + WeaviateInteraction, +) + def test_weaviate_client(): client = get_client() From f9453b099af848b3eb1e3442544b2f9f9712c3b6 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:42:13 +0530 Subject: [PATCH 14/18] updated gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8b529fdd..6aaea051 100644 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,6 @@ ipython_config.py # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. From 61f8c064cb3728863a705375e61d2cd8d9c26fd7 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:48:02 +0530 Subject: [PATCH 15/18] fix:redis protocol port --- .../services/admin/health_check_service.py | 37 +++++++++++++++---- .../app/services/admin/user_info_service.py | 31 ++++++++++++++-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/backend/app/services/admin/health_check_service.py b/backend/app/services/admin/health_check_service.py index dc93d314..43da9d5d 100644 --- a/backend/app/services/admin/health_check_service.py +++ b/backend/app/services/admin/health_check_service.py @@ -1,6 +1,7 @@ import logging import asyncio import aiohttp +from urllib.parse import urlparse from datetime import datetime from typing import Dict, Any, List from dataclasses import dataclass, field @@ -87,20 +88,40 @@ async def check_weaviate(self) -> ServiceHealth: async def check_falkordb(self) -> ServiceHealth: start = datetime.now() + client = None try: - falkor_url = getattr(settings, 'falkordb_url', 'http://localhost:6379') - async with aiohttp.ClientSession() as session: - async with session.get( - falkor_url, - timeout=aiohttp.ClientTimeout(total=self.timeout) - ) as resp: - latency = (datetime.now() - start).total_seconds() * 1000 - return ServiceHealth("FalkorDB", "healthy", round(latency, 2)) + import redis.asyncio as redis + + falkor_url = getattr(settings, 'falkordb_url', None) or "redis://localhost:6379" + + # Normalize non-redis URLs (e.g., http://localhost:6379) to redis:// + parsed = urlparse(falkor_url) + if parsed.scheme not in ("redis", "rediss"): + host = parsed.hostname or "localhost" + port = parsed.port or 6379 + falkor_url = f"redis://{host}:{port}" + + client = redis.from_url(falkor_url, decode_responses=True) + pong = await asyncio.wait_for(client.ping(), timeout=self.timeout) + + latency = (datetime.now() - start).total_seconds() * 1000 + if pong is True or pong == "PONG": + return ServiceHealth("FalkorDB", "healthy", round(latency, 2)) + return ServiceHealth("FalkorDB", "degraded", round(latency, 2), f"Unexpected ping reply: {pong}") except asyncio.TimeoutError: return ServiceHealth("FalkorDB", "degraded", self.timeout * 1000, "Timeout") except Exception as e: latency = (datetime.now() - start).total_seconds() * 1000 return ServiceHealth("FalkorDB", "degraded", round(latency, 2), str(e)[:50]) + finally: + if client is not None: + try: + await client.aclose() + except Exception: + try: + await client.close() + except Exception: + pass async def check_gemini_api(self) -> ServiceHealth: start = datetime.now() diff --git a/backend/app/services/admin/user_info_service.py b/backend/app/services/admin/user_info_service.py index f6bd8a19..e627aee7 100644 --- a/backend/app/services/admin/user_info_service.py +++ b/backend/app/services/admin/user_info_service.py @@ -27,6 +27,21 @@ class UserInfoService: def __init__(self, bot=None): self.bot = bot + async def _get_internal_user_id(self, discord_id: str) -> Optional[str]: + """Resolve internal users.id UUID from a Discord snowflake ID.""" + try: + supabase = get_supabase_client() + res = await supabase.table("users").select("id").eq( + "discord_id", discord_id + ).limit(1).execute() + + if res.data: + return str(res.data[0]["id"]) + return None + except Exception as e: + logger.warning(f"Could not resolve internal user id for discord_id={discord_id}: {e}") + return None + async def get_user_profile(self, discord_id: str) -> Optional[Dict[str, Any]]: try: supabase = get_supabase_client() @@ -42,10 +57,14 @@ async def get_user_profile(self, discord_id: str) -> Optional[Dict[str, Any]]: async def get_user_message_count(self, discord_id: str) -> int: try: + internal_user_id = await self._get_internal_user_id(discord_id) + if not internal_user_id: + return 0 + supabase = get_supabase_client() - res = await supabase.table("message_logs").select( + res = await supabase.table("interactions").select( "id", count="exact" - ).eq("user_id", discord_id).execute() + ).eq("user_id", internal_user_id).execute() return res.count or 0 except Exception as e: logger.warning(f"Could not get message count: {e}") @@ -53,10 +72,14 @@ async def get_user_message_count(self, discord_id: str) -> int: async def get_last_message(self, discord_id: str) -> Optional[str]: try: + internal_user_id = await self._get_internal_user_id(discord_id) + if not internal_user_id: + return None + supabase = get_supabase_client() - res = await supabase.table("message_logs").select( + res = await supabase.table("interactions").select( "created_at" - ).eq("user_id", discord_id).order( + ).eq("user_id", internal_user_id).order( "created_at", desc=True ).limit(1).execute() if res.data: From e626e2ab1d89627d65efb52f703fe6565e844b9b Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:52:49 +0530 Subject: [PATCH 16/18] fix : UTC-aware timezone added --- .../app/agents/devrel/nodes/gather_context.py | 4 ++-- backend/app/database/supabase/services.py | 6 ++--- .../services/admin/health_check_service.py | 4 ++-- .../services/admin/user_management_service.py | 4 ++-- backend/app/services/auth/management.py | 8 +++---- backend/app/services/auth/verification.py | 22 +++++++++---------- .../app/services/codegraph/repo_service.py | 8 +++---- backend/app/services/integration_service.py | 8 +++---- backend/app/utils/admin_logger.py | 4 ++-- 9 files changed, 34 insertions(+), 34 deletions(-) diff --git a/backend/app/agents/devrel/nodes/gather_context.py b/backend/app/agents/devrel/nodes/gather_context.py index ec0eb208..ff9ac62d 100644 --- a/backend/app/agents/devrel/nodes/gather_context.py +++ b/backend/app/agents/devrel/nodes/gather_context.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict from app.agents.state import AgentState @@ -27,7 +27,7 @@ async def gather_context_node(state: AgentState) -> Dict[str, Any]: new_message = { "role": "user", "content": original_message, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now(timezone.utc).isoformat() } profile_data: Dict[str, Any] = dict(state.user_profile or {}) diff --git a/backend/app/database/supabase/services.py b/backend/app/database/supabase/services.py index 44755ba0..bfccc633 100644 --- a/backend/app/database/supabase/services.py +++ b/backend/app/database/supabase/services.py @@ -1,6 +1,6 @@ import logging from typing import Dict, Any, Optional -from datetime import datetime +from datetime import datetime, timezone import uuid from app.database.supabase.client import get_supabase_client @@ -43,7 +43,7 @@ async def ensure_user_exists( # Update last_active timestamp last_active_column = f"last_active_{platform}" await supabase.table("users").update({ - last_active_column: datetime.now().isoformat() + last_active_column: datetime.now(timezone.utc).isoformat() }).eq("id", user_uuid).execute() return user_uuid @@ -64,7 +64,7 @@ async def ensure_user_exists( # Set last_active timestamp last_active_column = f"last_active_{platform}" - new_user[last_active_column] = datetime.now().isoformat() + new_user[last_active_column] = datetime.now(timezone.utc).isoformat() insert_response = await supabase.table("users").insert(new_user).execute() diff --git a/backend/app/services/admin/health_check_service.py b/backend/app/services/admin/health_check_service.py index 43da9d5d..3edc9f6b 100644 --- a/backend/app/services/admin/health_check_service.py +++ b/backend/app/services/admin/health_check_service.py @@ -2,7 +2,7 @@ import asyncio import aiohttp from urllib.parse import urlparse -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, Any, List from dataclasses import dataclass, field @@ -28,7 +28,7 @@ class SystemHealth: def __post_init__(self): if not self.timestamp: - self.timestamp = datetime.now().isoformat() + self.timestamp = datetime.now(timezone.utc).isoformat() class HealthCheckService: diff --git a/backend/app/services/admin/user_management_service.py b/backend/app/services/admin/user_management_service.py index 5f9ba86b..2c914f69 100644 --- a/backend/app/services/admin/user_management_service.py +++ b/backend/app/services/admin/user_management_service.py @@ -1,7 +1,7 @@ import logging from typing import Dict, Any, List, Optional from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from app.database.supabase.client import get_supabase_client from app.core.orchestration.queue_manager import AsyncQueueManager, QueuePriority @@ -73,7 +73,7 @@ async def reset_verification(self, user_id: str) -> bool: "github_username": None, "is_verified": False, "verification_token": None, - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() }).eq("discord_id", user_id).execute() logger.info(f"Reset verification for user {user_id}") return True diff --git a/backend/app/services/auth/management.py b/backend/app/services/auth/management.py index 4d0f35d8..f23d91b1 100644 --- a/backend/app/services/auth/management.py +++ b/backend/app/services/auth/management.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from app.database.supabase.client import get_supabase_client from app.models.database.supabase import User @@ -29,8 +29,8 @@ async def get_or_create_user_by_discord( "discord_username": discord_username, "avatar_url": avatar_url, "preferred_languages": [], - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat() + "created_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat() } insert_res = await supabase.table("users").insert(new_user_data).execute() @@ -80,7 +80,7 @@ async def update_user_profile(user_id: str, **updates) -> Optional[User]: try: # Add updated_at timestamp - updates["updated_at"] = datetime.now().isoformat() + updates["updated_at"] = datetime.now(timezone.utc).isoformat() update_res = await supabase.table("users").update(updates).eq("id", user_id).execute() diff --git a/backend/app/services/auth/verification.py b/backend/app/services/auth/verification.py index cbfa156c..03a154d5 100644 --- a/backend/app/services/auth/verification.py +++ b/backend/app/services/auth/verification.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional, Dict, Tuple from app.database.supabase.client import get_supabase_client from app.models.database.supabase import User @@ -16,7 +16,7 @@ def _cleanup_expired_sessions(): """ Remove expired verification sessions. """ - current_time = datetime.now() + current_time = datetime.now(timezone.utc) expired_sessions = [ session_id for session_id, (discord_id, expiry_time) in _verification_sessions.items() if current_time > expiry_time @@ -40,13 +40,13 @@ async def create_verification_session(discord_id: str) -> Optional[str]: token = str(uuid.uuid4()) session_id = str(uuid.uuid4()) - expiry_time = datetime.now() + timedelta(minutes=SESSION_EXPIRY_MINUTES) + expiry_time = datetime.now(timezone.utc) + timedelta(minutes=SESSION_EXPIRY_MINUTES) try: update_res = await supabase.table("users").update({ "verification_token": token, "verification_token_expires_at": expiry_time.isoformat(), - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() }).eq("discord_id", discord_id).execute() if update_res.data: @@ -79,7 +79,7 @@ async def find_user_by_session_and_verify( discord_id, expiry_time = session_data - current_time = datetime.now().isoformat() + current_time = datetime.now(timezone.utc).isoformat() user_res = await supabase.table("users").select("*").eq( "discord_id", discord_id ).neq( @@ -106,7 +106,7 @@ async def find_user_by_session_and_verify( await supabase.table("users").update({ "verification_token": None, "verification_token_expires_at": None, - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() }).eq("id", user_to_verify['id']).execute() raise Exception(f"GitHub account {github_username} is already linked to another Discord user") @@ -115,10 +115,10 @@ async def find_user_by_session_and_verify( "github_username": github_username, "email": user_to_verify.get('email') or email, "is_verified": True, - "verified_at": datetime.now().isoformat(), + "verified_at": datetime.now(timezone.utc).isoformat(), "verification_token": None, "verification_token_expires_at": None, - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() } await supabase.table("users").update(update_data).eq("id", user_to_verify['id']).execute() @@ -139,7 +139,7 @@ async def cleanup_expired_tokens(): Clean up expired verification tokens from database. """ supabase = get_supabase_client() - current_time = datetime.now().isoformat() + current_time = datetime.now(timezone.utc).isoformat() try: cleanup_res = await supabase.table("users").update({ @@ -165,12 +165,12 @@ async def get_verification_session_info(session_id: str) -> Optional[Dict[str, s discord_id, expiry_time = session_data - if datetime.now() > expiry_time: + if datetime.now(timezone.utc) > expiry_time: del _verification_sessions[session_id] return None return { "discord_id": discord_id, "expiry_time": expiry_time.isoformat(), - "time_remaining": str(expiry_time - datetime.now()) + "time_remaining": str(expiry_time - datetime.now(timezone.utc)) } diff --git a/backend/app/services/codegraph/repo_service.py b/backend/app/services/codegraph/repo_service.py index eba4fca8..835a7760 100644 --- a/backend/app/services/codegraph/repo_service.py +++ b/backend/app/services/codegraph/repo_service.py @@ -2,7 +2,7 @@ import aiohttp import re from typing import Dict, Any -from datetime import datetime +from datetime import datetime, timezone from app.database.supabase.client import get_supabase_client import os @@ -80,7 +80,7 @@ async def index_repo(self, repo_input: str, discord_id: str) -> Dict[str, Any]: await self.supabase.table("indexed_repositories").update({ "indexing_status": "pending", "last_error": None, - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() }).eq("id", repo_data['id']).execute() else: # Insert new record @@ -118,7 +118,7 @@ async def index_repo(self, repo_input: str, discord_id: str) -> Dict[str, Any]: await self.supabase.table("indexed_repositories").update({ "indexing_status": "completed", - "indexed_at": datetime.now().isoformat(), + "indexed_at": datetime.now(timezone.utc).isoformat(), "node_count": data.get("node_count", 0), "edge_count": data.get("edge_count", 0), "last_error": None @@ -269,7 +269,7 @@ async def delete_repo(self, repo_full_name: str, discord_id: str) -> Dict[str, A await self.supabase.table("indexed_repositories").update({ "is_deleted": True, - "updated_at": datetime.now().isoformat() + "updated_at": datetime.now(timezone.utc).isoformat() }).eq("repository_full_name", repo_full_name).eq("is_deleted", False).execute() return {"status": "success", "repo": repo_full_name, "graph_name": graph_name} diff --git a/backend/app/services/integration_service.py b/backend/app/services/integration_service.py index 21f87e48..87550f94 100644 --- a/backend/app/services/integration_service.py +++ b/backend/app/services/integration_service.py @@ -1,6 +1,6 @@ import logging from uuid import UUID, uuid4 -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, List from app.database.supabase.client import get_supabase_client from app.models.integration import ( @@ -49,8 +49,8 @@ async def create_integration( "organization_name": request.organization_name, "is_active": True, "config": request.config or {}, - "created_at": datetime.now().isoformat(), - "updated_at": datetime.now().isoformat() + "created_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc).isoformat() } # Store organization link if provided @@ -134,7 +134,7 @@ async def update_integration( ) -> IntegrationResponse: """Update an existing integration.""" try: - update_data = {"updated_at": datetime.now().isoformat()} + update_data = {"updated_at": datetime.now(timezone.utc).isoformat()} if request.organization_name is not None: update_data["organization_name"] = request.organization_name diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index 55e8f43d..6e6e5e6e 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -1,6 +1,6 @@ import logging from typing import Dict, Any, Optional, List -from datetime import datetime +from datetime import datetime, timezone import uuid from app.database.supabase.client import get_supabase_client @@ -58,7 +58,7 @@ async def log_admin_action( # Prepare log entry log_entry = { "id": str(uuid.uuid4()), - "timestamp": datetime.now().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), "executor_id": executor_id, "executor_username": executor_username, "command_name": command_name, From 13bd10d38daf591981c2d60b92437999a5b548c2 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 15:55:40 +0530 Subject: [PATCH 17/18] fix: admin_logger fixed --- backend/app/utils/admin_logger.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index 6e6e5e6e..a550b390 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -23,7 +23,6 @@ async def ensure_admin_logs_table() -> bool: _admin_logs_table_ready = True return True except Exception as e: - _admin_logs_table_ready = False logger.error( "admin_logs table is unavailable. Apply migration at " "backend/database/02_create_admin_logs_table.sql. Details: %s", From 8ba9ced5eaf2ad18d576485ec41db3ad5c369a30 Mon Sep 17 00:00:00 2001 From: Siddhant Date: Mon, 16 Feb 2026 16:04:27 +0530 Subject: [PATCH 18/18] fix: updated the codebase as said in the review --- backend/app/utils/admin_logger.py | 82 ++++++++++++------- .../database/02_create_admin_logs_table.sql | 2 +- backend/integrations/discord/admin_cog.py | 4 +- backend/integrations/discord/bot.py | 10 ++- backend/integrations/discord/permissions.py | 4 +- 5 files changed, 62 insertions(+), 40 deletions(-) diff --git a/backend/app/utils/admin_logger.py b/backend/app/utils/admin_logger.py index a550b390..8cfb363f 100644 --- a/backend/app/utils/admin_logger.py +++ b/backend/app/utils/admin_logger.py @@ -164,22 +164,62 @@ async def get_admin_log_stats( try: supabase = get_supabase_client() - # Build base query - query = supabase.table("admin_logs").select("*") + # Paginate through matching logs to avoid unbounded result sets + batch_size = 1000 + offset = 0 + + total_commands = 0 + success_count = 0 + failure_count = 0 + error_count = 0 + commands_by_type: Dict[str, int] = {} + executor_counts: Dict[str, int] = {} + + while True: + query = supabase.table("admin_logs").select( + "command_name,action_result,executor_username" + ) - if server_id: - query = query.eq("server_id", server_id) + if server_id: + query = query.eq("server_id", server_id) - if start_time: - query = query.gte("timestamp", start_time.isoformat()) + if start_time: + query = query.gte("timestamp", start_time.isoformat()) - if end_time: - query = query.lte("timestamp", end_time.isoformat()) + if end_time: + query = query.lte("timestamp", end_time.isoformat()) - # Get all matching logs - response = await query.execute() + query = query.order("timestamp", desc=True).range(offset, offset + batch_size - 1) - if not response.data: + response = await query.execute() + logs = response.data or [] + + if not logs: + break + + total_commands += len(logs) + + for log in logs: + action_result = log.get("action_result") + if action_result == "success": + success_count += 1 + elif action_result == "failure": + failure_count += 1 + elif action_result == "error": + error_count += 1 + + cmd = log.get("command_name") or "unknown" + commands_by_type[cmd] = commands_by_type.get(cmd, 0) + 1 + + executor = log.get("executor_username") or "unknown" + executor_counts[executor] = executor_counts.get(executor, 0) + 1 + + if len(logs) < batch_size: + break + + offset += batch_size + + if total_commands == 0: return { "total_commands": 0, "success_count": 0, @@ -189,26 +229,6 @@ async def get_admin_log_stats( "top_executors": [], } - logs = response.data - - # Calculate statistics - total_commands = len(logs) - success_count = sum(1 for log in logs if log["action_result"] == "success") - failure_count = sum(1 for log in logs if log["action_result"] == "failure") - error_count = sum(1 for log in logs if log["action_result"] == "error") - - # Count commands by type - commands_by_type = {} - for log in logs: - cmd = log["command_name"] - commands_by_type[cmd] = commands_by_type.get(cmd, 0) + 1 - - # Count by executor - executor_counts = {} - for log in logs: - executor = log["executor_username"] - executor_counts[executor] = executor_counts.get(executor, 0) + 1 - # Sort executors by count top_executors = [ {"username": username, "count": count} diff --git a/backend/database/02_create_admin_logs_table.sql b/backend/database/02_create_admin_logs_table.sql index e7b1a62b..7a79284a 100644 --- a/backend/database/02_create_admin_logs_table.sql +++ b/backend/database/02_create_admin_logs_table.sql @@ -39,7 +39,7 @@ CREATE POLICY "Authenticated users can view admin logs" CREATE POLICY "Service role can insert admin logs" ON admin_logs FOR INSERT - WITH CHECK (auth.role() = 'service_role' OR auth.role() = 'authenticated'); + WITH CHECK (auth.role() = 'service_role'); -- Add helpful comments COMMENT ON TABLE admin_logs IS 'Tracks all admin command executions for audit trail'; diff --git a/backend/integrations/discord/admin_cog.py b/backend/integrations/discord/admin_cog.py index 880ed131..821a655e 100644 --- a/backend/integrations/discord/admin_cog.py +++ b/backend/integrations/discord/admin_cog.py @@ -73,8 +73,8 @@ async def _confirm_action( return True - async def cog_command_error(self, interaction: Interaction, error: Exception): - """Handle errors for admin commands.""" + async def cog_app_command_error(self, interaction: Interaction, error: Exception): + """Handle errors for admin slash commands.""" logger.error(f"Admin command error: {error}", exc_info=True) if isinstance(error, app_commands.MissingPermissions): diff --git a/backend/integrations/discord/bot.py b/backend/integrations/discord/bot.py index 329542c7..36551ff9 100644 --- a/backend/integrations/discord/bot.py +++ b/backend/integrations/discord/bot.py @@ -39,14 +39,16 @@ async def on_ready(self): logger.info(f'Bot is ready! Logged in as {self.user}') try: # Sync globally - synced = await self.tree.sync() - logger.info(f"Synced {len(synced)} global slash command(s)") + synced = await self.sync_commands() + synced_count = len(synced) if synced is not None else 0 + logger.info(f"Synced {synced_count} global slash command(s)") # Also sync to each guild for instant availability for guild in self.guilds: try: - guild_synced = await self.tree.sync(guild=guild) - logger.info(f"Synced {len(guild_synced)} commands to guild {guild.name}") + guild_synced = await self.sync_commands(guild_ids=[guild.id]) + guild_synced_count = len(guild_synced) if guild_synced is not None else 0 + logger.info(f"Synced {guild_synced_count} commands to guild {guild.name}") except Exception as e: logger.warning(f"Failed to sync to guild {guild.name}: {e}") except Exception as e: diff --git a/backend/integrations/discord/permissions.py b/backend/integrations/discord/permissions.py index c085f1d4..bdd06883 100644 --- a/backend/integrations/discord/permissions.py +++ b/backend/integrations/discord/permissions.py @@ -249,8 +249,8 @@ def _extract_command_args(func, args: tuple, kwargs: dict) -> Dict[str, Any]: # Build argument dictionary command_args = {} - # Add positional arguments (skip first two: self, interaction) - for i, value in enumerate(args[2:] if len(args) > 2 else []): + # Add positional arguments + for i, value in enumerate(args): if i < len(param_names): # Convert to string for JSON serialization serializable_types = (str, int, float, bool, type(None))