From 833927c66e66032f5fa87d3b66f7b04407d9fd9c Mon Sep 17 00:00:00 2001 From: ducoterra Date: Sun, 24 May 2026 00:20:42 -0400 Subject: [PATCH] add a history command --- README.md | 10 +++- vibe_bot/database.py | 82 ++++++++++++++++++++++++++++-- vibe_bot/main.py | 57 +++++++++++++++++++++ vibe_bot/tests/test_main.py | 99 +++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 369ead9..511416a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Discord bot that stores long-term chat history using SQLite with RAG (Retrieva - [Text-to-Speech](#text-to-speech) - [Image Commands](#image-commands) - [Bot Conversations](#bot-conversations) + - [Chat History](#chat-history) - [Features](#features) - [Setup](#setup) - [Prerequisites](#prerequisites) @@ -69,6 +70,12 @@ Once you create a custom bot, interact with it by prefixing your message with th | -------------------------------------- | ------------------------------------------- | ------------------------------------------------ | | `!talkforme ` | Have two bots discuss a topic for n replies | `!talkforme alfred jarvis 4 the meaning of life` | +### Chat History + +| Command | Description | Example Usage | +| --------------------- | ------------------------------------- | ----------------- | +| `!history ` | View the chat history of a custom bot | `!history alfred` | + ## Features - **Long-term chat history storage**: Persistent storage of all bot interactions in SQLite @@ -78,6 +85,7 @@ Once you create a custom bot, interact with it by prefixing your message with th - **Image generation**: Generate images from text prompts via OpenAI-compatible API - **Image editing**: Edit uploaded images with text instructions - **Bot conversations**: Two custom bots can discuss a topic autonomously +- **Chat history**: View the full conversation history of any custom bot with `!history` - **Automatic message cleanup**: Configurable limits on stored messages ## Setup @@ -158,7 +166,7 @@ uv run python -m vibe_bot.main The system uses SQLite with three tables: 1. **chat_messages**: Stores message metadata - - `message_id`, `user_id`, `username`, `content`, `timestamp`, `channel_id`, `guild_id` + - `message_id`, `user_id`, `username`, `content`, `bot_name`, `timestamp`, `channel_id`, `guild_id` 2. **message_embeddings**: Stores vector embeddings for RAG - `message_id` (PK), `embedding` (binary blob of float32 values) diff --git a/vibe_bot/database.py b/vibe_bot/database.py index 4850548..d9ad452 100644 --- a/vibe_bot/database.py +++ b/vibe_bot/database.py @@ -68,6 +68,7 @@ class ChatDatabase: user_id TEXT, username TEXT, content TEXT, + bot_name TEXT, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, channel_id TEXT, guild_id TEXT @@ -76,6 +77,17 @@ class ChatDatabase: ) logger.info("chat_messages table initialized successfully") + # Migrate: add bot_name column if it doesn't exist + logger.info("Checking for bot_name column migration") + cursor.execute("PRAGMA table_info(chat_messages)") + columns = [row[1] for row in cursor.fetchall()] + if "bot_name" not in columns: + logger.info("Adding bot_name column to chat_messages table") + cursor.execute( + "ALTER TABLE chat_messages ADD COLUMN bot_name TEXT", + ) + logger.info("bot_name column added successfully") + # Create embeddings table for RAG logger.info("Creating message_embeddings table if not exists") cursor.execute( @@ -143,6 +155,7 @@ class ChatDatabase: user_id: str, username: str, content: str, + bot_name: str | None = None, channel_id: str | None = None, guild_id: str | None = None, ) -> bool: @@ -160,10 +173,18 @@ class ChatDatabase: cursor.execute( """ INSERT OR REPLACE INTO chat_messages - (message_id, user_id, username, content, channel_id, guild_id) - VALUES (?, ?, ?, ?, ?, ?) + (message_id, user_id, username, content, bot_name, channel_id, guild_id) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (message_id, user_id, username, content, channel_id, guild_id), + ( + message_id, + user_id, + username, + content, + bot_name, + channel_id, + guild_id, + ), ) logger.debug("Message %s inserted into chat_messages table", message_id) @@ -330,6 +351,61 @@ class ChatDatabase: results.sort(key=lambda x: x[2], reverse=True) return results[:top_k] + def get_bot_history(self, bot_name: str, limit: int = 20) -> list[tuple[str, str]]: + """Get message history for a specific custom bot. + + Args: + bot_name: The name of the custom bot. + limit: Maximum number of messages to retrieve. + + Returns: + List of (user_message, bot_response) tuples. + + """ + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + logger.info( + "Fetching last %d messages for bot %r", + limit, + bot_name, + ) + cursor.execute( + """ + SELECT message_id, content, timestamp + FROM chat_messages + WHERE bot_name = ? AND message_id NOT LIKE '%%_response' + ORDER BY timestamp DESC + LIMIT ? + """, + (bot_name, limit), + ) + + messages = cursor.fetchall() + + conversations: list[tuple[str, str]] = [] + for message in messages: + msg_content = message[1] + logger.debug("Finding response for %s...", msg_content[:50]) + cursor.execute( + """ + SELECT content + FROM chat_messages + WHERE message_id = ? + ORDER BY timestamp DESC + """, + (f"{message[0]}_response",), + ) + response_row = cursor.fetchone() + if response_row: + logger.debug("Found response: %s...", response_row[0][:50]) + conversations.append((msg_content, response_row[0])) + else: + logger.debug("No response found") + conn.close() + + return conversations + def get_user_history(self, user_id: str, limit: int = 20) -> list[tuple[str, str]]: """Get message history for a specific user.""" conn = sqlite3.connect(self.db_path) diff --git a/vibe_bot/main.py b/vibe_bot/main.py index 1e1c7f8..8bac2ac 100644 --- a/vibe_bot/main.py +++ b/vibe_bot/main.py @@ -410,6 +410,7 @@ async def _speak_with_bot( user_id=str(ctx.author.id), username=ctx.author.name, content=f"User: {text_to_speak}", + bot_name=bot_name, channel_id=str(ctx.channel.id), guild_id=str(ctx.guild.id) if ctx.guild else None, ) @@ -420,6 +421,7 @@ async def _speak_with_bot( user_id=str(ctx.bot.user.id), username=ctx.bot.user.name, content=bot_response, + bot_name=bot_name, channel_id=str(ctx.channel.id), guild_id=str(ctx.guild.id) if ctx.guild else None, ) @@ -551,6 +553,59 @@ async def retcon(ctx: CommandsContext[Bot], *, message: str) -> None: await ctx.send(file=send_img) +@bot.command(name="history") +async def history(ctx: CommandsContext[Bot], bot_name: str) -> None: + """View the chat history of a custom bot. + + Usage: !history + """ + logger.info( + "History command triggered by %s for bot %r", + ctx.author.name, + bot_name, + ) + + custom_bot_manager = CustomBotManager() + bot_info = custom_bot_manager.get_custom_bot(bot_name) + + if not bot_info: + await ctx.send(f"Custom bot '{bot_name}' not found.") + return + + db = get_database() + history = db.get_bot_history(bot_name=bot_name, limit=20) + + if not history: + await ctx.send(f"No chat history found for **{bot_name}**. ") + return + + history.reverse() + + formatted_history: list[str] = [] + for user_msg, bot_resp in history: + formatted_history.append(user_msg) + formatted_history.append(f"{bot_name}: {bot_resp}") + + header = f"Chat History for **{bot_name}**:\n\n" + full_text = header + "\n---\n".join(formatted_history) + + chunk_size = 1900 + chunks: list[str] = [] + current_chunk = full_text + 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) + + @bot.command(name="talkforme") async def talkforme(ctx: CommandsContext[Bot], *, message: str) -> None: """Have two bots talk to each other about a topic. @@ -714,6 +769,7 @@ async def handle_chat( user_id=str(ctx.author.id), username=ctx.author.name, content=f"User: {message}", + bot_name=bot_name, channel_id=str(ctx.channel.id), guild_id=str(ctx.guild.id) if ctx.guild else None, ) @@ -724,6 +780,7 @@ async def handle_chat( user_id=str(ctx.bot.user.id), username=ctx.bot.user.name, content=bot_response, + bot_name=bot_name, channel_id=str(ctx.channel.id), guild_id=str(ctx.guild.id) if ctx.guild else None, ) diff --git a/vibe_bot/tests/test_main.py b/vibe_bot/tests/test_main.py index 46b9a89..0c9d6ce 100644 --- a/vibe_bot/tests/test_main.py +++ b/vibe_bot/tests/test_main.py @@ -536,3 +536,102 @@ def test_talkforme_invalid_limit( asyncio.run(main_module.talkforme(mock_ctx, message="bot1 bot2 abc topic")) call_args = mock_ctx.send.call_args[0][0] assert "must be an integer" in call_args + + +def test_history_bot_not_found( + mock_ctx: MagicMock, + mock_custom_bot_manager: MagicMock, +) -> None: + """Test history command when bot doesn't exist.""" + import asyncio + + import vibe_bot.main as main_module + + mock_custom_bot_manager.get_custom_bot.return_value = None + + asyncio.run(main_module.history(mock_ctx, bot_name="nonexistent")) + call_args = mock_ctx.send.call_args[0][0] + assert "not found" in call_args + + +def test_history_no_history( + mock_ctx: MagicMock, + mock_custom_bot_manager: MagicMock, + mock_database: MagicMock, +) -> None: + """Test history command when bot has no chat history.""" + import asyncio + + import vibe_bot.main as main_module + + mock_custom_bot_manager.get_custom_bot.return_value = ( + "alfred", + "british butler", + "user-123", + "2024-01-01", + ) + mock_database.get_bot_history.return_value = [] + + asyncio.run(main_module.history(mock_ctx, bot_name="alfred")) + call_args = mock_ctx.send.call_args[0][0] + assert "No chat history" in call_args + assert "**alfred**" in call_args + + +def test_history_with_data( + mock_ctx: MagicMock, + mock_custom_bot_manager: MagicMock, + mock_database: MagicMock, +) -> None: + """Test history command when bot has chat history.""" + import asyncio + + import vibe_bot.main as main_module + + mock_custom_bot_manager.get_custom_bot.return_value = ( + "alfred", + "british butler", + "user-123", + "2024-01-01", + ) + mock_database.get_bot_history.return_value = [ + ("hello", "yes master?"), + ("what time is it", "it is currently 3pm"), + ] + + asyncio.run(main_module.history(mock_ctx, bot_name="alfred")) + + assert mock_ctx.send.call_count >= 1 + first_call = mock_ctx.send.call_args_list[0][0][0] + assert "Chat History for **alfred**" in first_call + assert "hello" in first_call + assert "alfred: yes master?" in first_call + assert "what time is it" in first_call + assert "alfred: it is currently 3pm" in first_call + + +def test_history_long_response_chunked( + mock_ctx: MagicMock, + mock_custom_bot_manager: MagicMock, + mock_database: MagicMock, +) -> None: + """Test that long history responses are sent in chunks.""" + import asyncio + + import vibe_bot.main as main_module + + mock_custom_bot_manager.get_custom_bot.return_value = ( + "alfred", + "british butler", + "user-123", + "2024-01-01", + ) + long_user = "x" * 500 + long_bot = "y" * 500 + mock_database.get_bot_history.return_value = [ + (long_user, long_bot), + ] + + asyncio.run(main_module.history(mock_ctx, bot_name="alfred")) + + assert mock_ctx.send.call_count >= 1