add a history command

This commit is contained in:
2026-05-24 00:20:42 -04:00
parent 4eea8583de
commit 833927c66e
4 changed files with 244 additions and 4 deletions
+9 -1
View File
@@ -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 <bot1> <bot2> <n> <topic>` | Have two bots discuss a topic for n replies | `!talkforme alfred jarvis 4 the meaning of life` |
### Chat History
| Command | Description | Example Usage |
| --------------------- | ------------------------------------- | ----------------- |
| `!history <bot_name>` | 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)
+79 -3
View File
@@ -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)
+57
View File
@@ -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 <bot_name>
"""
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,
)
+99
View File
@@ -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