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)
|
- [Text-to-Speech](#text-to-speech)
|
||||||
- [Image Commands](#image-commands)
|
- [Image Commands](#image-commands)
|
||||||
- [Bot Conversations](#bot-conversations)
|
- [Bot Conversations](#bot-conversations)
|
||||||
|
- [Chat History](#chat-history)
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Setup](#setup)
|
- [Setup](#setup)
|
||||||
- [Prerequisites](#prerequisites)
|
- [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` |
|
| `!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
|
## Features
|
||||||
|
|
||||||
- **Long-term chat history storage**: Persistent storage of all bot interactions in SQLite
|
- **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 generation**: Generate images from text prompts via OpenAI-compatible API
|
||||||
- **Image editing**: Edit uploaded images with text instructions
|
- **Image editing**: Edit uploaded images with text instructions
|
||||||
- **Bot conversations**: Two custom bots can discuss a topic autonomously
|
- **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
|
- **Automatic message cleanup**: Configurable limits on stored messages
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -158,7 +166,7 @@ uv run python -m vibe_bot.main
|
|||||||
The system uses SQLite with three tables:
|
The system uses SQLite with three tables:
|
||||||
|
|
||||||
1. **chat_messages**: Stores message metadata
|
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
|
2. **message_embeddings**: Stores vector embeddings for RAG
|
||||||
- `message_id` (PK), `embedding` (binary blob of float32 values)
|
- `message_id` (PK), `embedding` (binary blob of float32 values)
|
||||||
|
|||||||
+79
-3
@@ -68,6 +68,7 @@ class ChatDatabase:
|
|||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
username TEXT,
|
username TEXT,
|
||||||
content TEXT,
|
content TEXT,
|
||||||
|
bot_name TEXT,
|
||||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
channel_id TEXT,
|
channel_id TEXT,
|
||||||
guild_id TEXT
|
guild_id TEXT
|
||||||
@@ -76,6 +77,17 @@ class ChatDatabase:
|
|||||||
)
|
)
|
||||||
logger.info("chat_messages table initialized successfully")
|
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
|
# Create embeddings table for RAG
|
||||||
logger.info("Creating message_embeddings table if not exists")
|
logger.info("Creating message_embeddings table if not exists")
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@@ -143,6 +155,7 @@ class ChatDatabase:
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
username: str,
|
username: str,
|
||||||
content: str,
|
content: str,
|
||||||
|
bot_name: str | None = None,
|
||||||
channel_id: str | None = None,
|
channel_id: str | None = None,
|
||||||
guild_id: str | None = None,
|
guild_id: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -160,10 +173,18 @@ class ChatDatabase:
|
|||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR REPLACE INTO chat_messages
|
INSERT OR REPLACE INTO chat_messages
|
||||||
(message_id, user_id, username, content, channel_id, guild_id)
|
(message_id, user_id, username, content, bot_name, channel_id, guild_id)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
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)
|
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)
|
results.sort(key=lambda x: x[2], reverse=True)
|
||||||
return results[:top_k]
|
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]]:
|
def get_user_history(self, user_id: str, limit: int = 20) -> list[tuple[str, str]]:
|
||||||
"""Get message history for a specific user."""
|
"""Get message history for a specific user."""
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ async def _speak_with_bot(
|
|||||||
user_id=str(ctx.author.id),
|
user_id=str(ctx.author.id),
|
||||||
username=ctx.author.name,
|
username=ctx.author.name,
|
||||||
content=f"User: {text_to_speak}",
|
content=f"User: {text_to_speak}",
|
||||||
|
bot_name=bot_name,
|
||||||
channel_id=str(ctx.channel.id),
|
channel_id=str(ctx.channel.id),
|
||||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
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),
|
user_id=str(ctx.bot.user.id),
|
||||||
username=ctx.bot.user.name,
|
username=ctx.bot.user.name,
|
||||||
content=bot_response,
|
content=bot_response,
|
||||||
|
bot_name=bot_name,
|
||||||
channel_id=str(ctx.channel.id),
|
channel_id=str(ctx.channel.id),
|
||||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
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)
|
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")
|
@bot.command(name="talkforme")
|
||||||
async def talkforme(ctx: CommandsContext[Bot], *, message: str) -> None:
|
async def talkforme(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||||
"""Have two bots talk to each other about a topic.
|
"""Have two bots talk to each other about a topic.
|
||||||
@@ -714,6 +769,7 @@ async def handle_chat(
|
|||||||
user_id=str(ctx.author.id),
|
user_id=str(ctx.author.id),
|
||||||
username=ctx.author.name,
|
username=ctx.author.name,
|
||||||
content=f"User: {message}",
|
content=f"User: {message}",
|
||||||
|
bot_name=bot_name,
|
||||||
channel_id=str(ctx.channel.id),
|
channel_id=str(ctx.channel.id),
|
||||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
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),
|
user_id=str(ctx.bot.user.id),
|
||||||
username=ctx.bot.user.name,
|
username=ctx.bot.user.name,
|
||||||
content=bot_response,
|
content=bot_response,
|
||||||
|
bot_name=bot_name,
|
||||||
channel_id=str(ctx.channel.id),
|
channel_id=str(ctx.channel.id),
|
||||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
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"))
|
asyncio.run(main_module.talkforme(mock_ctx, message="bot1 bot2 abc topic"))
|
||||||
call_args = mock_ctx.send.call_args[0][0]
|
call_args = mock_ctx.send.call_args[0][0]
|
||||||
assert "must be an integer" in call_args
|
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