add a history command
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user