diff --git a/vibe_bot/main.py b/vibe_bot/main.py index cb9ecfb..17c9437 100644 --- a/vibe_bot/main.py +++ b/vibe_bot/main.py @@ -49,6 +49,8 @@ logger = logging.getLogger(__name__) # Initialize the bot intents = discord.Intents.default() intents.message_content = True +intents.members = True +intents.presences = True bot = commands.Bot(command_prefix="!", intents=intents) @@ -370,6 +372,99 @@ async def lobotomize(ctx: CommandsContext[Bot]) -> None: await ctx.send("All conversation history and memory has been cleared. 🧠✨") +@bot.command(name="debug") +async def debug( + ctx: CommandsContext[Bot], + *, + subcommand: str | None = None, +) -> None: + """Debug menu for various debugging sub-commands. + + Usage: !debug + Available sub-commands: + - members: List all members in the current channel + - whoami: Show all information the bot has about you + - tools: Show the LLM's available tools + """ + logger.info( + "Debug command triggered by %s with subcommand %r", ctx.author.name, subcommand + ) + + if not subcommand: + menu = "Debug Menu:\n\n" + menu += "Available sub-commands:\n" + menu += "- `members` - List all members in the current channel\n" + menu += "- `whoami` - Show all information the bot has about you\n" + menu += "- `tools` - Show the LLM's available tools" + await ctx.send(menu) + return + + if subcommand == "members": + result = get_channel_members_impl(ctx.channel) + chunk_size = 1900 + chunks: list[str] = [] + current_chunk = result + while current_chunk: + if len(current_chunk) <= chunk_size: + chunks.append(current_chunk) + break + split_pos = current_chunk.rfind("\n", 0, chunk_size) + if split_pos == -1: + split_pos = chunk_size + chunks.append(current_chunk[:split_pos]) + current_chunk = current_chunk[split_pos:].lstrip("\n") + + for chunk in chunks: + await ctx.send(chunk) + return + + if subcommand == "whoami": + user_info = get_user_info(ctx.author) + chunk_size = 1900 + whoami_chunks: list[str] = [] + current_chunk = user_info + while current_chunk: + if len(current_chunk) <= chunk_size: + whoami_chunks.append(current_chunk) + break + split_pos = current_chunk.rfind("\n", 0, chunk_size) + if split_pos == -1: + split_pos = chunk_size + whoami_chunks.append(current_chunk[:split_pos]) + current_chunk = current_chunk[split_pos:].lstrip("\n") + + for chunk in whoami_chunks: + await ctx.send(chunk) + return + + if subcommand == "tools": + tool_list = "LLM Tools:\n\n" + tool_list += f"- `{get_channel_members.name}`\n" + tool_list += f" Description: {get_channel_members.description}\n" + tool_list += f" Parameters: {get_channel_members.args_schema.model_json_schema()}" # type: ignore + chunk_size = 1900 + tool_chunks: list[str] = [] + current_chunk = tool_list + while current_chunk: + if len(current_chunk) <= chunk_size: + tool_chunks.append(current_chunk) + break + split_pos = current_chunk.rfind("\n", 0, chunk_size) + if split_pos == -1: + split_pos = chunk_size + tool_chunks.append(current_chunk[:split_pos]) + current_chunk = current_chunk[split_pos:].lstrip("\n") + + for chunk in tool_chunks: + await ctx.send(chunk) + return + + await ctx.send( + f"Unknown debug sub-command: `{subcommand}`\n\n" + f"Use `!debug` to see available sub-commands.", + ) + + @bot.command(name="voices") async def voices(ctx: CommandsContext[Bot]) -> None: """List all available TTS voices organized by category.""" @@ -377,7 +472,7 @@ async def voices(ctx: CommandsContext[Bot]) -> None: for category, info in VOICES_LIST.items(): voice_list += f"{category} ({info['language']}):\n" for v in info["voices"]: - voice_list += f" - {v}\n" + voice_list += f"- {v}\n" voice_list += "\n" voice_list += "Use `!speak --voice ` to choose a voice." @@ -514,7 +609,9 @@ async def _speak_with_bot( return get_channel_members_impl(ctx.channel) return f"Unknown tool: {tool_name}" - async def speak_tool_call_notifier(tool_name: str, tool_args: dict[str, str]) -> None: + async def speak_tool_call_notifier( + tool_name: str, tool_args: dict[str, str] + ) -> None: """Send a notification message when a tool is called.""" if tool_name == "get_channel_members": await ctx.send(f"**{bot_name}** is looking at the channel members...") diff --git a/vibe_bot/tests/test_main.py b/vibe_bot/tests/test_main.py index 392dab5..37c16dc 100644 --- a/vibe_bot/tests/test_main.py +++ b/vibe_bot/tests/test_main.py @@ -65,7 +65,7 @@ def test_bot_initialized(mock_discord: dict[str, MagicMock]) -> None: def test_bot_intents_set(mock_discord: dict[str, MagicMock]) -> None: - """Test that message_content intent is enabled.""" + """Test that required intents are enabled.""" import vibe_bot.main as main_module main_module.bot = mock_discord["bot_instance"] @@ -74,6 +74,17 @@ def test_bot_intents_set(mock_discord: dict[str, MagicMock]) -> None: assert main_module.MIN_PERSONALITY_LENGTH == 10 +def test_bot_intents_members_and_presence(mock_discord: dict[str, MagicMock]) -> None: + """Test that members and presence intents are enabled.""" + intents = mock_discord["Intents"].default.return_value + intents.message_content = True + intents.members = True + intents.presences = True + assert intents.message_content is True + assert intents.members is True + assert intents.presences is True + + @patch("vibe_bot.main.tts_engine", None) def test_speak_tts_not_initialized(mock_ctx: MagicMock) -> None: """Test speak command when TTS engine is not initialized.""" @@ -804,3 +815,149 @@ def test_history_long_response_chunked( asyncio.run(main_module.history(mock_ctx, bot_name="alfred")) assert mock_ctx.send.call_count >= 1 + + +def test_debug_no_subcommand(mock_ctx: MagicMock) -> None: + """Test debug command without subcommand shows menu.""" + import asyncio + + import vibe_bot.main as main_module + + asyncio.run(main_module.debug(mock_ctx, subcommand=None)) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "Debug Menu" in call_args + assert "members" in call_args + + +def test_debug_members_no_guild(mock_ctx: MagicMock) -> None: + """Test debug members command when channel has no guild.""" + import asyncio + + import vibe_bot.main as main_module + + mock_ctx.channel.guild = None + + asyncio.run(main_module.debug(mock_ctx, subcommand="members")) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "No members found in this channel." in call_args + + +def test_debug_members_with_members(mock_ctx: MagicMock) -> None: + """Test debug members command with channel members.""" + import asyncio + + import vibe_bot.main as main_module + + mock_member = MagicMock() + mock_member.display_name = "Alice" + mock_member.name = "alice" + mock_member.nick = None + mock_member.global_name = None + mock_member.status = MagicMock(value="online") + + mock_ctx.channel.guild.members = [mock_member] + + asyncio.run(main_module.debug(mock_ctx, subcommand="members")) + + assert mock_ctx.send.called + call_args = mock_ctx.send.call_args[0][0] + assert "Alice" in call_args + assert "1 total" in call_args + + +def test_debug_unknown_subcommand(mock_ctx: MagicMock) -> None: + """Test debug command with unknown subcommand.""" + import asyncio + + import vibe_bot.main as main_module + + asyncio.run(main_module.debug(mock_ctx, subcommand="unknown")) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "Unknown debug sub-command" in call_args + + +def test_debug_members_many_chunks(mock_ctx: MagicMock) -> None: + """Test debug members command with many members that exceed 1900 chars.""" + import asyncio + + import vibe_bot.main as main_module + + mock_members = [] + for i in range(50): + mock_member = MagicMock() + mock_member.display_name = f"User{i}_with_a_very_long_display_name" + mock_member.name = f"user{i}" + mock_member.nick = None + mock_member.global_name = None + mock_member.status = MagicMock(value="online") + mock_members.append(mock_member) + + mock_ctx.channel.guild.members = mock_members + + asyncio.run(main_module.debug(mock_ctx, subcommand="members")) + + assert mock_ctx.send.call_count >= 2 + first_chunk = mock_ctx.send.call_args_list[0][0][0] + assert "Members in this channel (50 total)" in first_chunk + + +def test_debug_whoami(mock_ctx: MagicMock) -> None: + """Test debug whoami command shows user info.""" + import asyncio + + import vibe_bot.main as main_module + + asyncio.run(main_module.debug(mock_ctx, subcommand="whoami")) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "Username: testuser" in call_args + assert "User ID: 12345" in call_args + assert "Global Name: Test User" in call_args + assert "Nickname: tester" in call_args + + +def test_debug_whoami_minimal(mock_ctx: MagicMock) -> None: + """Test debug whoami command with minimal user data.""" + import asyncio + + import vibe_bot.main as main_module + + mock_ctx.author.global_name = None + mock_ctx.author.nick = None + mock_ctx.author.top_role.name = "@everyone" + mock_ctx.author.activities = [] + mock_ctx.author.joined_at = None + mock_ctx.author.created_at = None + + asyncio.run(main_module.debug(mock_ctx, subcommand="whoami")) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "Username: testuser" in call_args + assert "User ID: 12345" in call_args + assert "Global Name" not in call_args + assert "Nickname" not in call_args + assert "Activities" not in call_args + assert "Joined" not in call_args + + +def test_debug_tools(mock_ctx: MagicMock) -> None: + """Test debug tools command shows LLM tools.""" + import asyncio + + import vibe_bot.main as main_module + + asyncio.run(main_module.debug(mock_ctx, subcommand="tools")) + + mock_ctx.send.assert_called_once() + call_args = mock_ctx.send.call_args[0][0] + assert "LLM Tools" in call_args + assert "get_channel_members" in call_args + assert "members" in call_args.lower() diff --git a/vibe_bot/tests/test_tools.py b/vibe_bot/tests/test_tools.py index 6bcb22c..5a188ca 100644 --- a/vibe_bot/tests/test_tools.py +++ b/vibe_bot/tests/test_tools.py @@ -24,7 +24,7 @@ def test_get_channel_members_impl_returns_formatted_list() -> None: mock_member2.status = MagicMock(value="idle") mock_channel = MagicMock() - mock_channel.members = [mock_member1, mock_member2] + mock_channel.guild.members = [mock_member1, mock_member2] result = get_channel_members_impl(mock_channel) @@ -38,7 +38,7 @@ def test_get_channel_members_impl_returns_formatted_list() -> None: def test_get_channel_members_impl_empty() -> None: """Test get_channel_members_impl with no members.""" mock_channel = MagicMock() - mock_channel.members = [] + mock_channel.guild.members = [] result = get_channel_members_impl(mock_channel) @@ -55,7 +55,7 @@ def test_get_channel_members_impl_with_global_name() -> None: mock_member.status = MagicMock(value="dnd") mock_channel = MagicMock() - mock_channel.members = [mock_member] + mock_channel.guild.members = [mock_member] result = get_channel_members_impl(mock_channel) @@ -72,7 +72,7 @@ def test_get_channel_members_impl_no_status() -> None: mock_member.status = None mock_channel = MagicMock() - mock_channel.members = [mock_member] + mock_channel.guild.members = [mock_member] result = get_channel_members_impl(mock_channel) @@ -83,8 +83,7 @@ def test_get_channel_members_impl_no_status() -> None: def test_get_channel_members_impl_exception() -> None: """Test get_channel_members_impl handles exceptions gracefully.""" mock_channel = MagicMock() - mock_channel.members = None - type(mock_channel).members = property( + type(mock_channel.guild).members = property( lambda self: (_ for _ in ()).throw(Exception("test")) ) @@ -119,7 +118,7 @@ def test_get_channel_members_impl_sorted_by_display_name() -> None: mock_member_a.status = MagicMock(value="online") mock_channel = MagicMock() - mock_channel.members = [mock_member_z, mock_member_a] + mock_channel.guild.members = [mock_member_z, mock_member_a] result = get_channel_members_impl(mock_channel) @@ -138,7 +137,7 @@ def test_get_channel_members_impl_no_nick_when_same_as_display() -> None: mock_member.status = MagicMock(value="online") mock_channel = MagicMock() - mock_channel.members = [mock_member] + mock_channel.guild.members = [mock_member] result = get_channel_members_impl(mock_channel) @@ -179,3 +178,23 @@ def test_format_member_with_all_fields() -> None: assert "(nickname: Al)" in result assert "(global name: Alice Global)" in result assert "[online]" in result + + +def test_get_channel_members_impl_no_guild() -> None: + """Test get_channel_members_impl when channel has no guild (e.g. DM).""" + mock_channel = MagicMock() + mock_channel.guild = None + + result = get_channel_members_impl(mock_channel) + + assert "No members found in this channel." in result + + +def test_get_channel_members_impl_guild_members_none() -> None: + """Test get_channel_members_impl when guild.members is None.""" + mock_channel = MagicMock() + mock_channel.guild.members = None + + result = get_channel_members_impl(mock_channel) + + assert "No members found in this channel." in result diff --git a/vibe_bot/tools.py b/vibe_bot/tools.py index 0e2c7fc..f8f936e 100644 --- a/vibe_bot/tools.py +++ b/vibe_bot/tools.py @@ -44,7 +44,7 @@ def get_channel_members_impl(channel: Any) -> str: """ try: - members = channel.members + members = getattr(channel.guild, "members", None) if not members: return "No members found in this channel." @@ -55,7 +55,7 @@ def get_channel_members_impl(channel: Any) -> str: getattr(m, "display_name", "") or getattr(m, "name", "") ).lower(), ): - lines.append(f" - {_format_member(member)}") + lines.append(f"- {_format_member(member)}") return "\n".join(lines) except Exception: