Compare commits

...

11 Commits

Author SHA1 Message Date
6822d3ca71 requirements.txt 2025-08-23 23:49:46 -07:00
4900778a97 change " to ' 2025-08-23 23:42:00 -07:00
6f001f2924 refactor all parameters to commandline arguments 2025-08-23 23:34:31 -07:00
ede777ee42 clear reactions on /newchat 2025-08-22 18:29:34 -07:00
4ba6e64403 fixed newchat response when there's no prompt 2025-08-22 18:07:25 -07:00
fd08420f26 go back to sync sqlite; async sqlite leaves hanging processes 2025-08-22 00:51:09 -07:00
ad9c069993 changeprompt 2025-08-22 00:21:37 -07:00
8c750b2fbb async sqlite 2025-08-21 22:36:17 -07:00
dd47cc05a6 add deletion and prepopulated reacts 2025-08-21 17:23:20 -07:00
ae7843cf17 persistent db 2025-08-21 11:04:04 -07:00
8437cc6940 store message.id instead of message in reroll history 2025-08-21 10:36:14 -07:00
5 changed files with 291 additions and 130 deletions

129
bot.py
View File

@@ -1,32 +1,55 @@
import collections import collections
import discord import discord
from discord.ext import commands from discord.ext import commands
from openai import AsyncOpenAI
import os import os
import argparse import argparse
from typing import List, Dict, Any from typing import List, Dict, Any
from llm_client import Conversation from conversations import Conversation, ConversationManager
from database import Database
# --- Configuration --- # --- Configuration ---
DEFAULT_AVATAR = "https://cdn.discordapp.com/avatars/1406466525858369716/f1dfeaf2a1c361dbf981e2e899c7f981?size=256" DEFAULT_AVATAR = 'https://cdn.discordapp.com/avatars/1406466525858369716/f1dfeaf2a1c361dbf981e2e899c7f981?size=256'
# --- Command Line Arguments --- # --- Command Line Arguments ---
parser = argparse.ArgumentParser(description="Aoi Discord Bot") parser = argparse.ArgumentParser(description='Aoi Discord Bot')
parser.add_argument( parser.add_argument(
'--base_url', type=str, required=True, '--base_url', default='http://localhost:8080/v1',
help='The base URL for the OpenAI API.', help='The base URL for the OpenAI API server.',
) )
parser.add_argument( parser.add_argument(
'--discord_token', type=str, required=True, '--api_key', default='', help='The API key for OpenAI API.',
help='The Discord bot token.', )
parser.add_argument(
'--model', default='', help='The model to use from OpenAI API.',
)
parser.add_argument(
'--default_prompt',
default='you are a catboy named Aoi with dark blue fur and is a tsundere',
help='Default system prompt when not given in chat.',
)
parser.add_argument(
'--db', default='conversations.db', help='SQLite DB to use.',
)
parser.add_argument(
'--discord_token', required=True, help='The Discord bot token.',
) )
args = parser.parse_args() args = parser.parse_args()
# --- Bot Setup --- # --- Bot Setup ---
class AoiBot(commands.Bot):
async def setup_hook(self):
db = Database.get(args.db)
openai = AsyncOpenAI(base_url=args.base_url, api_key=args.api_key)
self.manager = ConversationManager(
openai, args.model, db, args.default_prompt,
)
intents = discord.Intents.default() intents = discord.Intents.default()
intents.messages = True intents.messages = True
intents.message_content = True intents.message_content = True
bot = commands.Bot(command_prefix="/", intents=intents) bot = AoiBot(command_prefix='/', intents=intents)
# --- Helpers --- # --- Helpers ---
@@ -44,7 +67,9 @@ async def discord_send(channel, text, name, avatar=DEFAULT_AVATAR):
) )
else: else:
message = await channel.send(content=chunk) message = await channel.send(content=chunk)
messages.append(message) messages.append(message.id)
await message.add_reaction('🔁')
await message.add_reaction('')
return messages return messages
async def webhook(channel): async def webhook(channel):
@@ -56,6 +81,15 @@ async def webhook(channel):
return await channel.create_webhook(name=f'aoi-{channel.id}') return await channel.create_webhook(name=f'aoi-{channel.id}')
return channel_hooks[0] return channel_hooks[0]
async def clear_reactions(channel, message_ids):
for message_id in message_ids:
try:
message = await channel.fetch_message(message_id)
await message.clear_reaction('🔁')
await message.clear_reaction('')
except (discord.NotFound, discord.Forbidden):
pass # Ignore if message is not found or we don't have perms
# --- Bot Events --- # --- Bot Events ---
@bot.event @bot.event
@@ -73,12 +107,12 @@ async def on_message(message):
bot_tag = f'<@{bot.user.id}>' bot_tag = f'<@{bot.user.id}>'
channel = message.channel channel = message.channel
conversation = await Conversation.get(channel.id) conversation = await bot.manager.get(channel.id)
user_message = message.content user_message = message.content
if user_message.startswith(bot_tag): if user_message.startswith(bot_tag):
user_message = user_message[len(bot_tag):] user_message = user_message[len(bot_tag):]
user_message = user_message.replace(bot_tag, conversation.bot_name).strip() user_message = user_message.replace(bot_tag, conversation.bot_name).strip()
print(f'> {message.author.name}: {user_message}') print(f'{channel.id}> {message.author.name}: {user_message}')
media = [] media = []
if message.attachments: if message.attachments:
@@ -88,52 +122,85 @@ async def on_message(message):
try: try:
async with channel.typing(): async with channel.typing():
response = await conversation.generate(user_message, media) response = await conversation.generate(user_message, media)
await clear_reactions(channel, conversation.last_messages)
conversation.last_messages = await discord_send( conversation.last_messages = await discord_send(
channel, response, conversation.bot_name, channel, response, conversation.bot_name,
) )
await conversation.save()
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") print(f'An error occurred: {e}')
await message.reply("Sorry, I had a little hiccup. Baka!") await message.reply('Sorry, I had a little hiccup. Baka!')
@bot.event @bot.event
async def on_reaction_add(reaction, user): async def on_reaction_add(reaction, user):
if reaction.emoji != "🔁": if reaction.emoji not in ('🔁', '') or user == bot.user:
return return
message = reaction.message message = reaction.message
channel = message.channel channel = message.channel
conversation = await Conversation.get(channel.id) conversation = await bot.manager.get(channel.id)
if message not in conversation.last_messages: if message.id not in conversation.last_messages:
await reaction.clear() await reaction.clear()
return return
print(f"_ {user}: {reaction}") print(f'_ {user}: {reaction}')
try: try:
async with channel.typing(): try:
for message in conversation.last_messages: messages = [
await message.delete() await channel.fetch_message(message_id)
response = await conversation.regenerate() for message_id in conversation.last_messages
conversation.last_messages = await discord_send( ]
channel, response, conversation.bot_name, except (discord.NotFound, discord.Forbidden):
) # don't do anything if any message in the list is not found
await reaction.clear()
return
for message in messages:
await message.delete()
if reaction.emoji == '':
await conversation.pop()
elif reaction.emoji == '🔁':
async with channel.typing():
response = await conversation.regenerate()
conversation.last_messages = await discord_send(
channel, response, conversation.bot_name,
)
await conversation.save()
except Exception as e: except Exception as e:
print(f"An error occurred: {e}") print(f'An error occurred: {e}')
await message.reply("Sorry, I had a little hiccup. Baka!") await message.reply('Sorry, I had a little hiccup. Baka!')
# --- Slash Commands --- # --- Slash Commands ---
@bot.tree.command( @bot.tree.command(
name="newchat", name='newchat',
description="Start a new chat with an optional system prompt." description='Start a new chat with an optional system prompt.'
) )
async def newchat(interaction: discord.Interaction, prompt: str = None): async def newchat(interaction: discord.Interaction, prompt: str = None):
await interaction.response.defer() await interaction.response.defer()
channel_id = interaction.channel_id channel_id = interaction.channel_id
conversation = await Conversation.create(channel_id, args.base_url, prompt) old_convo = await bot.manager.get(channel_id, create_if_missing=False)
if old_convo:
await clear_reactions(interaction.channel, old_convo.last_messages)
conversation = await bot.manager.new_conversation(channel_id, prompt)
await interaction.followup.send( await interaction.followup.send(
f'Starting a new chat with {conversation.bot_name}: "{prompt}"' f'Starting a new chat with {conversation.bot_name}: '
f'"{conversation.history[0]["content"]}"'
)
@bot.tree.command(
name='changeprompt',
description='Change the system prompt of the current conversation.'
)
async def changeprompt(interaction: discord.Interaction, prompt: str):
await interaction.response.defer()
channel_id = interaction.channel_id
conversation = await bot.manager.get(channel_id)
await conversation.update_prompt(prompt)
await interaction.followup.send(
f'Now chatting with {conversation.bot_name}: "{prompt}"'
) )
# --- Running the Bot --- # --- Running the Bot ---
if __name__ == "__main__": if __name__ == '__main__':
bot.run(args.discord_token) bot.run(args.discord_token)

135
conversations.py Normal file
View File

@@ -0,0 +1,135 @@
import aiohttp
import base64
from database import Database
API_KEY = "eh"
MODEL = "p620"
DEFAULT_NAME = "Aoi"
NAME_PROMPT = "reply with your name, nothing else, no punctuation"
async def get_name(client, model, prompt):
"""Generates an assistant name for the given prompt."""
name_response = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": NAME_PROMPT}
],
)
return name_response.choices[0].message.content.split('\n')[0]
class ConversationManager:
"""Creates and retrieves Conversations."""
def __init__(self, openai_client, model, db, default_prompt):
self.model = model
self.client = openai_client
self.db = db
self.default_prompt = default_prompt
async def get(self, key, create_if_missing=True):
"""Gets a conversation based on |key|, optionally create when not found."""
convo_data = self.db.get_conversation(key)
if convo_data:
history, bot_name, last_messages = convo_data
return Conversation(
key, bot_name, history, last_messages,
self.client, self.model, self.db,
)
if create_if_missing:
return await self.new_conversation(key, self.default_prompt)
return None
async def new_conversation(self, key, prompt = None):
"""Creates a new Conversation with key based on given prompt."""
prompt = prompt or self.default_prompt
name = await get_name(self.client, self.model, prompt)
history = [{"role": "system", "content": prompt}]
last_messages = []
convo = Conversation(
key, name, history, last_messages, self.client, self.model, self.db,
)
await convo.save()
return convo
class Conversation:
"""Holds data about a conversation thread."""
def __init__(
self, convo_id, name, history, last_messages, api_client, model, db,
):
self.id = convo_id
self.bot_name = name
self.history = history
self.last_messages = last_messages
self.client = api_client
self.model = model
self.db = db
async def save(self):
"""Saves the conversation to the DB."""
self.db.save(self.id, self.history, self.bot_name, self.last_messages)
def add_message_pair(self, user, assistant):
"""Adds a user/assistant convesation turn pair."""
self.history.extend([
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
])
async def pop(self):
"""Removes one user/assistant converation turn pair."""
if len(self.history) >= 3:
self.history = self.history[:-2]
await self.save()
async def update_prompt(self, prompt):
"""Changes current prompt to a new one, keeping the rest of history."""
self.history[0] = {"role": "system", "content": prompt}
self.bot_name = await get_name(self.client, self.model, prompt)
await self.save()
async def generate(self, text, media=tuple()):
"""Generates next assistant conversation turn."""
# prepare text part
if text:
openai_content = [{"type": "text", "text": text}]
else:
openai_content = [{"type": "text", "text": "."}]
# prepare images part
async with aiohttp.ClientSession() as session:
for (content_type, url) in media:
if "image" not in content_type:
continue
try:
async with session.get(url) as resp:
resp.raise_for_status()
image_data = await resp.read()
b64_image = base64.b64encode(image_data).decode('utf-8')
b64_url = f"data:{content_type};base64,{b64_image}"
openai_content.append({
"type": "image_url",
"image_url": {"url": b64_url}
})
except Exception as e:
print(f"Error downloading or processing attachment: {e}")
# send request to openai api and return response
request = self.history + [{"role": "user", "content": openai_content}]
llm_response = await self.client.chat.completions.create(
model=MODEL, messages=request,
)
response = llm_response.choices[0].message.content
self.add_message_pair(openai_content, response)
return response
async def regenerate(self):
"""Regenerates the last assistant turn."""
llm_response = await self.client.chat.completions.create(
model=MODEL, messages=self.history[:-1]
)
response = llm_response.choices[0].message.content
self.history[-1] = {"role": "assistant", "content": response}
return response

56
database.py Normal file
View File

@@ -0,0 +1,56 @@
import sqlite3
import json
class Database:
def __init__(self, db_conn):
self.conn = db_conn
self._create_table()
@classmethod
def get(cls, db_path):
"""Creates and returns a connected Database instance."""
print(f"Initializing DB connection to: {db_path}")
return Database(sqlite3.connect(db_path))
def _create_table(self):
with self.conn:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
history TEXT NOT NULL,
bot_name TEXT NOT NULL,
last_messages TEXT NOT NULL
)
""")
def get_conversation(self, conversation_id):
with self.conn:
cursor = self.conn.cursor()
cursor.execute(
"SELECT history, bot_name, last_messages FROM conversations WHERE id = ?",
(conversation_id,)
)
row = cursor.fetchone()
if row:
history = json.loads(row[0])
bot_name = row[1]
last_messages = json.loads(row[2])
return history, bot_name, last_messages
return None
def save(self, conversation_id, history, bot_name, last_messages):
with self.conn:
self.conn.execute(
"INSERT OR REPLACE INTO conversations "
"(id, history, bot_name, last_messages) VALUES (?, ?, ?, ?)",
(
conversation_id, json.dumps(history),
bot_name, json.dumps(last_messages)
),
)
def delete(self, conversation_id):
with self.conn:
self.conn.execute(
"DELETE FROM conversations WHERE id = ?", (conversation_id,),
)

View File

@@ -1,99 +0,0 @@
import aiohttp
import base64
from openai import AsyncOpenAI
API_KEY = "eh"
MODEL = "p620"
DEFAULT_NAME = "Aoi"
DEFAULT_SYSTEM_PROMPT = (
"you are a catboy named Aoi with dark blue fur and is a tsundere"
)
NAME_PROMPT = "reply with your name, nothing else, no punctuation"
conversations = {}
class Conversation:
def __init__(self, client, name, prompt):
self.history = [{"role": "system", "content": prompt}]
self.bot_name = name
self.last_messages = []
self.client = client
@classmethod
async def get(cls, key):
if key not in conversations:
conversations[key] = await Conversation.create(args.base_url)
return conversations[key]
@classmethod
async def create(cls, channel_id, base_url, prompt=None):
client = AsyncOpenAI(base_url=base_url, api_key=API_KEY)
if not prompt:
convo = cls(client, DEFAULT_NAME, DEFAULT_SYSTEM_PROMPT)
else:
convo = cls(client, await cls.get_name(client, prompt), prompt)
conversations[channel_id] = convo
return convo
@classmethod
async def get_name(self, client, system_prompt):
name_response = await client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": NAME_PROMPT}
],
)
return name_response.choices[0].message.content.split('\n')[0]
def add_message_pair(self, user, assistant):
self.history.extend([
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
])
async def generate(self, text, media=tuple()):
# prepare text part
if text:
openai_content = [{"type": "text", "text": text}]
else:
openai_content = [{"type": "text", "text": "."}]
# prepare images part
async with aiohttp.ClientSession() as session:
for (content_type, url) in media:
if "image" not in content_type:
continue
try:
async with session.get(url) as resp:
if resp.status != 200:
raise IOError(f"{url} --> {resp.status}")
image_data = await resp.read()
b64_image = base64.b64encode(image_data).decode('utf-8')
b64_url = f"data:{content_type};base64,{b64_image}"
openai_content.append({
"type": "image_url",
"image_url": {"url": b64_url}
})
except Exception as e:
print(f"Error downloading or processing attachment: {e}")
# send request to openai api and return response
request = self.history + [{"role": "user", "content": openai_content}]
llm_response = await self.client.chat.completions.create(
model=MODEL, messages=request,
)
response = llm_response.choices[0].message.content
self.add_message_pair(openai_content, response)
return response
async def regenerate(self):
llm_response = await self.client.chat.completions.create(
model=MODEL, messages=self.history[:-1]
)
response = llm_response.choices[0].message.content
self.history[-1] = {"role": "assistant", "content": response}
return response

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
discord
openai