Compare commits

...

14 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
3d9c5d8e71 fix convo and hook lookup 2025-08-21 10:18:25 -07:00
452bd41e7e refactor llm out of bot 2025-08-21 09:37:35 -07:00
5ecf47a451 beautify 2025-08-21 01:22:30 -07:00
4 changed files with 303 additions and 138 deletions

248
bot.py
View File

@@ -3,118 +3,56 @@ import discord
from discord.ext import commands
from openai import AsyncOpenAI
import os
import base64
import aiohttp
import argparse
from typing import List, Dict, Any
from conversations import Conversation, ConversationManager
from database import Database
# --- Configuration ---
OPENAI_API_KEY = "eh"
MODEL = "p620"
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"
DEFAULT_NAME = "Aoi"
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 ---
parser = argparse.ArgumentParser(description="Aoi Discord Bot")
parser = argparse.ArgumentParser(description='Aoi Discord Bot')
parser.add_argument(
'--base_url',
type=str,
required=True,
help='The base URL for the OpenAI API.',
'--base_url', default='http://localhost:8080/v1',
help='The base URL for the OpenAI API server.',
)
parser.add_argument(
'--discord_token',
type=str,
required=True,
help='The Discord bot token.',
'--api_key', default='', help='The API key for OpenAI API.',
)
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()
# --- 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.messages = True
intents.message_content = True
bot = commands.Bot(command_prefix="/", intents=intents)
bot = AoiBot(command_prefix='/', intents=intents)
# --- OpenAI Client ---
client = AsyncOpenAI(
base_url=args.base_url,
api_key=OPENAI_API_KEY,
)
# --- Helpers ---
async def get_user_from_id(ctx, userid):
if ctx.guild:
user = await ctx.guild.fetch_member(userid)
else:
user = await bot.fetch_user(userid)
return user.display_name
async def get_user_from_mention(ctx, mention):
match = re.findall(r"<@!?(\d+)>", mention)
if not match:
return mention
return await get_user_from_id(ctx, int(match[0]))
class Conversation:
def __init__(self, prompt, name):
self.history = [{"role": "system", "content": prompt}]
self.bot_name = name
self.last_messages = []
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 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 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
async def discord_send(channel, text, name, avatar=DEFAULT_AVATAR):
chunks = [text[i:i+2000] for i in range(0, len(text), 2000)]
messages = []
@@ -129,20 +67,28 @@ async def discord_send(channel, text, name, avatar=DEFAULT_AVATAR):
)
else:
message = await channel.send(content=chunk)
messages.append(message)
messages.append(message.id)
await message.add_reaction('🔁')
await message.add_reaction('')
return messages
# --- Data Storage ---
# Keyed by channel ID
conversation_history: Dict[int, Conversation] = collections.defaultdict(
lambda: Conversation(prompt=DEFAULT_SYSTEM_PROMPT, name=DEFAULT_NAME),
)
_webhooks = {}
async def webhook(channel):
if channel.id not in _webhooks:
_webhooks[channel.id] = await channel.create_webhook(name=f'aoi-{channel.id}')
return _webhooks[channel.id]
name = f'aoi-{channel.id}'
channel_hooks = [
hook for hook in (await channel.webhooks()) if hook.name == name
]
if not channel_hooks:
return await channel.create_webhook(name=f'aoi-{channel.id}')
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 ---
@@ -161,12 +107,12 @@ async def on_message(message):
bot_tag = f'<@{bot.user.id}>'
channel = message.channel
conversation = conversation_history[channel.id]
conversation = await bot.manager.get(channel.id)
user_message = message.content
if user_message.startswith(bot_tag):
user_message = user_message[len(bot_tag):]
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 = []
if message.attachments:
@@ -176,59 +122,85 @@ async def on_message(message):
try:
async with channel.typing():
response = await conversation.generate(user_message, media)
conversation.last_messages = await discord_send(
channel, response, conversation.bot_name,
)
await clear_reactions(channel, conversation.last_messages)
conversation.last_messages = await discord_send(
channel, response, conversation.bot_name,
)
await conversation.save()
except Exception as e:
print(f"An error occurred: {e}")
await message.reply("Sorry, I had a little hiccup. Baka!")
print(f'An error occurred: {e}')
await message.reply('Sorry, I had a little hiccup. Baka!')
@bot.event
async def on_reaction_add(reaction, user):
if reaction.emoji != "🔁":
if reaction.emoji not in ('🔁', '') or user == bot.user:
return
message = reaction.message
channel = message.channel
conversation = conversation_history[channel.id]
if message not in conversation.last_messages:
conversation = await bot.manager.get(channel.id)
if message.id not in conversation.last_messages:
await reaction.clear()
return
print(f"_ {user}: {reaction}")
print(f'_ {user}: {reaction}')
try:
async with channel.typing():
for message in conversation.last_messages:
await message.delete()
response = await conversation.regenerate()
conversation.last_messages = await discord_send(
channel, response, conversation.bot_name,
)
try:
messages = [
await channel.fetch_message(message_id)
for message_id in conversation.last_messages
]
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:
print(f"An error occurred: {e}")
await message.reply("Sorry, I had a little hiccup. Baka!")
print(f'An error occurred: {e}')
await message.reply('Sorry, I had a little hiccup. Baka!')
# --- Slash Commands ---
@bot.tree.command(
name="newchat",
description="Start a new chat with an optional system prompt."
name='newchat',
description='Start a new chat with an optional system prompt.'
)
async def newchat(interaction: discord.Interaction, prompt: str = None):
await interaction.response.defer()
channel_id = interaction.channel_id
system_prompt = prompt or DEFAULT_SYSTEM_PROMPT
name_response = await client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": NAME_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(
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}"'
)
name = name_response.choices[0].message.content.split('\n')[0]
print(f'$ name={name}')
conversation_history[channel_id] = Conversation(prompt=prompt, name=name)
await interaction.followup.send(f'Starting a new chat with: "{prompt}"')
# --- Running the Bot ---
if __name__ == "__main__":
if __name__ == '__main__':
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,),
)

2
requirements.txt Normal file
View File

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