├── .gitattributes ├── .gitignore ├── README.md ├── actor.py ├── conversation_history.py ├── conversation_history_test.py ├── log.py ├── openai_discord.py ├── prompt_handler.py ├── prompt_handler_test.py └── requirements.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | checkpoint/* 3 | colab_downloaded/* 4 | aitextgen/* 5 | trained_model/* 6 | aitextgen.tokenizer.json 7 | contexts/* 8 | __pycache__ 9 | *.drawio 10 | experiments* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-openai-bot 2 | A simple Discord chatbot written in Python that uses [OpenAI's API](https://openai.com/api/) to generate conversation. 3 | 4 | # Dotenv values 5 | ``` 6 | DISCORD_TOKEN= 7 | OPENAI_API_KEY= 8 | ``` 9 | -------------------------------------------------------------------------------- /actor.py: -------------------------------------------------------------------------------- 1 | 2 | from xml.dom.minidom import Entity 3 | 4 | 5 | class Attribute: 6 | name = "" 7 | value = "" 8 | parent: Entity 9 | 10 | 11 | 12 | class Actor: 13 | pass 14 | -------------------------------------------------------------------------------- /conversation_history.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentError 2 | 3 | 4 | nl = "\n" 5 | class ConversationHistory: 6 | """ 7 | Stores history format str: f"{actor}: message " 8 | """ 9 | messages = [] 10 | max_messages = 5 11 | 12 | def append_message(self, actor: str, message: str) -> None: 13 | """ 14 | Add a message to conversation history. Abide max_messages by dropping oldest 15 | 16 | Arguments: 17 | message: the message 18 | actor: who wrote the message 19 | """ 20 | self.messages.append(f"{actor}: {message} {nl}") 21 | 22 | # Remove the last two from conversation history when it gets too long. 23 | if len(self.messages) > self.max_messages: 24 | self.messages.pop(0) 25 | 26 | def history_to_str(self) -> str: 27 | """ 28 | Returns a string of each actor's 29 | Returns "" if no history 30 | """ 31 | history_str = "" 32 | if len(self.messages) == 0: 33 | return "" 34 | 35 | for message in self.messages: 36 | history_str += message 37 | return history_str 38 | 39 | def reset_history(self): 40 | """ 41 | Sets history to empty array 42 | """ 43 | self.messages = [] 44 | 45 | def set_max_messages(self, number: int): 46 | """ 47 | Set total messages in history. If there's less space, drop the oldest. 48 | """ 49 | self.max_messages = number 50 | 51 | # max_messages changed from 10 to 5. Had 10 already, added one above to make 11 total. 52 | # Pop 0-5, leaving 6-10, which are now at index 0-5. range(11 - 5) = 0,1,2,3,4, 53 | if len(self.messages) - self.max_messages > 0: 54 | for x in range(len(self.messages) - self.max_messages): 55 | self.messages.pop(x) 56 | 57 | def history_command(self, content) -> str: 58 | helpstring = "Available commands:\n.history.reset" 59 | value = content[content.find(" "):].strip() if content.find(" ") != -1 else "" 60 | if ".history.reset" == content: 61 | self.reset_history() 62 | return "History was reset" 63 | if content.startswith(".history.max"): 64 | if "" == value: 65 | return Exception("Missing number in .history.max [number]") 66 | self.set_max_messages(int(value)) 67 | return f"Max history set to {value}" 68 | 69 | return f"Command wasnt recognized.\n{helpstring}" 70 | 71 | def __init__(self, max_messages) -> None: 72 | """ 73 | Create conversation history object with max_messages 74 | """ 75 | self.max_messages = max_messages -------------------------------------------------------------------------------- /conversation_history_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import conversation_history 3 | 4 | nl = "\n" 5 | 6 | def build_mock_conversation_history() -> conversation_history.ConversationHistory: 7 | ch = conversation_history.ConversationHistory(5) 8 | ch.append_message("HEAD KNIGHT", "Ni!") 9 | ch.append_message("KNIGHTS", "Ni! Ni! Ni!") 10 | ch.append_message("ARTHUR", "Who are you?") 11 | ch.append_message("HEAD KNIGHT", "We are the Knights Who Say... Ni!") 12 | ch.append_message("ARTHUR", "No! Not the Knights Who Say Ni!") 13 | return ch 14 | 15 | class TestConversationHistory(unittest.TestCase): 16 | def test_append_message(self): 17 | ch = build_mock_conversation_history() 18 | ch.reset_history() 19 | ch.append_message("HEAD KNIGHT", "Ni!") 20 | ch.append_message("KNIGHTS", "Ni! Ni! Ni!") 21 | ch.append_message("ARTHUR", "Who are you?") 22 | ch.append_message("HEAD KNIGHT", "We are the Knights Who Say... Ni!") 23 | ch.append_message("ARTHUR", "No! Not the Knights Who Say Ni!") 24 | ch.append_message("HEAD KNIGHT", "The same!") 25 | ch.append_message("BEDEVERE", "Who are they?") 26 | 27 | self.assertEqual(ch.messages[0], "ARTHUR: Who are you? \n") 28 | 29 | def test_history_to_str(self): 30 | ch = build_mock_conversation_history() 31 | output = ch.history_to_str() 32 | match_str = "HEAD KNIGHT: Ni! \nKNIGHTS: Ni! Ni! Ni! \nARTHUR: Who are you? \nHEAD KNIGHT: We are the Knights Who Say... Ni! \nARTHUR: No! Not the Knights Who Say Ni! \n" 33 | self.assertEqual(output, match_str) 34 | 35 | 36 | if __name__ == '__main__': 37 | unittest.main() -------------------------------------------------------------------------------- /log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Define the log format 4 | log_format = ( 5 | '[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s') 6 | 7 | # Define basic configuration 8 | logging.basicConfig( 9 | # Define logging level 10 | level=logging.INFO, 11 | # Declare the object we created to format the log messages 12 | format=log_format, 13 | # Declare handlers 14 | handlers=[ 15 | logging.StreamHandler() 16 | ] 17 | ) -------------------------------------------------------------------------------- /openai_discord.py: -------------------------------------------------------------------------------- 1 | from cmath import e 2 | import logging 3 | import os 4 | from click import FileError 5 | import discord 6 | from discord.ext.commands import Bot 7 | from discord.ext.commands import Context 8 | import openai 9 | from prompt_handler import PromptHandler 10 | from log import logging 11 | from dotenv import load_dotenv 12 | 13 | load_dotenv() 14 | openai.api_key = os.getenv("OPENAI_API_KEY") 15 | task_running = False 16 | 17 | # Use Bot instead of Discord Client for commands 18 | # Give it ALL THE POWER 19 | intents = discord.Intents.all() 20 | client = Bot("!", intents=intents) 21 | 22 | global_contexts = [] 23 | MAX_CONVERSATION_HISTORY = 3 24 | nl = "\n" 25 | 26 | async def send_message(ctx: Context, content: str): 27 | ''' 28 | Send Discord message. If >2000 characters, split message. 29 | ''' 30 | if len(content) > 2000: 31 | logging.warning("Message too long to send to discord. Splitting message") 32 | message = "" 33 | sentences = content.split(".") 34 | for sentence in sentences: 35 | if len(message) + len(sentence) < 2000: 36 | message += sentence 37 | else: 38 | await ctx.send(message) 39 | message = "" 40 | message += sentence 41 | else: 42 | message = content 43 | await ctx.send(message) 44 | 45 | async def getAIResponse(prompt): 46 | api_json = openai.Completion.create( 47 | engine="text-davinci-001", 48 | prompt=prompt, 49 | temperature=0.8, # big hot takes 50 | max_tokens=200, 51 | frequency_penalty=0.5, 52 | ) 53 | logging.debug(api_json) 54 | 55 | response = api_json.choices[0].text.strip('"') 56 | return response 57 | 58 | @client.event 59 | async def on_ready(): 60 | logging.warning("We have logged in as {0.user}".format(client)) 61 | 62 | @client.command(aliases=["ai", "generate"]) 63 | async def prompt(ctx, string: str): 64 | await generateSentence(ctx, prompt=string) 65 | 66 | @client.event 67 | async def on_message(message): 68 | global conversation_history 69 | global global_contexts 70 | await client.process_commands(message) 71 | content = message.clean_content 72 | ctx = await client.get_context(message) 73 | bot_name = client.user.name 74 | user_name = message.author.name 75 | 76 | # Escape 77 | if ( 78 | message.author == client.user 79 | or len(content) < 2 80 | or content.startswith("!") 81 | # or content.startswith(".") # uber bot prefix 82 | ): 83 | return 84 | 85 | if ( 86 | "stfu bot" in content 87 | and message.author.guild_permissions.administrator 88 | ): 89 | await message.reply("fine then fuck you") 90 | print("Stop command issued") 91 | await client.close() 92 | task_running = False # stop being sussy!! 93 | 94 | 95 | # Check if current user has a global context object for the chatbot in the channel they are communicating in. 96 | # If not, create one for the channel/user combo. This helps maintain separation of contexts so people's 97 | # fantasies don't play out in some public channel. Not that people would use this bot for anything perverse 98 | # they wouldnt share publicly. I totally didnt build this for that exact purpose. 99 | prompt_handler: PromptHandler 100 | if len(global_contexts) != 0: 101 | for context in global_contexts: 102 | if context.whisperword_user == user_name and context.whisperword_channel == ctx.channel.id: 103 | prompt_handler = context 104 | logging.info(f"on_message: using existing context for {user_name} in channel {ctx.channel.id}") 105 | if prompt_handler == None: 106 | global_contexts.append(PromptHandler(user_name, bot_name, ctx.channel.id, MAX_CONVERSATION_HISTORY)) 107 | prompt_handler = global_contexts[len(global_contexts)-1] 108 | logging.warning(f"on_message: created new context for {user_name} in channel {ctx.channel.id}") 109 | else: 110 | global_contexts.append(PromptHandler(user_name, bot_name, ctx.channel.id, MAX_CONVERSATION_HISTORY)) 111 | prompt_handler = global_contexts[len(global_contexts)-1] 112 | logging.warning(f"on_message: created new context for {user_name} in channel {ctx.channel.id}") 113 | 114 | # Handle .commands 115 | if content.startswith(".context"): 116 | await send_message(ctx, prompt_handler.context_command(content)) 117 | return 118 | 119 | if content.startswith(".history"): 120 | await send_message(ctx, prompt_handler.conversation_history.history_command(content)) 121 | return 122 | 123 | 124 | bot_mention = "@" + client.user.display_name 125 | logging.debug("on_message: mention " + bot_mention) 126 | if content.startswith(bot_mention): 127 | content = content[len(bot_mention):].strip() 128 | 129 | # Start the prompt with necessary context 130 | # whisperword represents keywords to change the nature of the prompt. We're gating this with a special keyword. 131 | if prompt_handler.whisperword == True and prompt_handler.whisperword_user == user_name and prompt_handler.whisperword_channel == ctx.channel.id: 132 | prompt_handler.proces_content_for_triggers(content) 133 | user_prompt = prompt_handler.get_prompt(content) 134 | logging.info(f"on_message: user_prompt:{nl}{user_prompt}") 135 | else: 136 | user_prompt = f"on_message: Your name is {bot_name}. You are talking to {user_name}. " 137 | 138 | response = await generateSentence(ctx, user_prompt) 139 | 140 | # Save history 141 | prompt_handler.conversation_history.append_message(bot_name, content) 142 | prompt_handler.conversation_history.append_message(user_name, response) 143 | 144 | # either reply to a user or just send a message 145 | if message != None: 146 | await message.reply(response) 147 | else: 148 | await send_message(ctx, response) 149 | 150 | 151 | 152 | # Actual function to generate AI sentences 153 | async def generateSentence(ctx: Context, prompt="") -> str: 154 | """ 155 | Fetch response from OpenAI 156 | 157 | Arguments 158 | ctx: the Discord Context 159 | prompt: prompt data 160 | """ 161 | global conversation_history 162 | if len(prompt) == 0: 163 | logging.error("generateSentence: Zero length response") 164 | return 165 | 166 | async with ctx.typing(): 167 | #print("Querying API...") 168 | try: 169 | response = await getAIResponse(prompt) 170 | except Exception as err: 171 | return err 172 | 173 | if response == "": 174 | return "Error generating sentence. Skill issue" 175 | 176 | # TODO: Handle these better so that the first response is extracted. 177 | # TODO: Reset automatically if responses are too similar. 178 | # Bot has a tendency to try and speak for the user... not sure why 179 | # Handling that here by slicing off everything at "user_name:". It's wasteful, but cant seem to ween the AI off the habit 180 | speak_over_user_string = f"{ctx.author.name}:" 181 | if speak_over_user_string in response: 182 | logging.error(f"generateSentence: Detected AI taking on user role.") 183 | logging.error(f"generateSentence: The prompt for this was: {prompt}") 184 | logging.error(f"generateSentence: The response was: {response}") 185 | response = response[:response.find(speak_over_user_string)] 186 | 187 | third_person_string = f"{ctx.me.name}: " 188 | if third_person_string in response: 189 | logging.error(f"generateSentence: Detected AI talking in third person.") 190 | logging.error(f"generateSentence: The prompt for this was: {prompt}") 191 | logging.error(f"generateSentence: The response was: {response}") 192 | response = response[response.find(third_person_string) + len(third_person_string): ] 193 | 194 | response_log_str = response.replace("/n","") 195 | logging.debug(f"generateSentence: response: {response_log_str}") 196 | 197 | return response.strip() 198 | 199 | def split_multiple_dialogs(content, bot_name, user_name): 200 | """ 201 | Find bot name 202 | """ 203 | pass 204 | 205 | 206 | if __name__ == '__main__': 207 | # Run bot 208 | print("Logging in...") 209 | client.run(os.environ["DISCORD_TOKEN"]) 210 | -------------------------------------------------------------------------------- /prompt_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | from conversation_history import ConversationHistory 3 | import json 4 | from log import logging 5 | import spacy 6 | nlp = spacy.load("en_core_web_sm") 7 | 8 | 9 | nl = "\n" 10 | 11 | class PromptHandler: 12 | ''' 13 | Manages the custom attributes which are used to keep context about the AI and environment locally. 14 | Attributes are upated using trigger phrases within proces_triggers() coming in on content form Discord message. 15 | Each trigger is linked to a method that points at a specific attribute to update in the mapping table. 16 | 17 | Each attribute is essentially one human sentence to include with the call to OpenAI. 18 | Some attributes are multi-value and there's special handling for that. They get flattened out. 19 | Each attribute has its own pretty print phrase as the first element in the tuple. It's prefixed to the pretty 20 | print string in front of the data. 21 | 22 | Context should always be in this format: 23 | "You are {bot name}. {attribute strings}. You are talking to [user name]. {attribute strings}" 24 | 25 | Example: "You are Joebob. You are a junky clown. You are wearing a clown suit, a clown hat. You are talking to 26 | Jamin. Jamin is a human male. Jamin is wearing a bananahammock, a windbreaker." 27 | ''' 28 | 29 | user_attributes = [] 30 | bot_attributes = [] 31 | 32 | trigger_phrases_maps = {} 33 | 34 | whisperword = False 35 | whisperword_user = "" 36 | whisperword_channel = "" 37 | 38 | my_funcs = locals() 39 | 40 | CONTEXT_FOLDER =".\\contexts\\" 41 | 42 | """ 43 | Utility methods 44 | """ 45 | def trim_triggger_content(self, trigger_phrase, content): 46 | ''' 47 | Return the text after the trigger phrase 48 | ''' 49 | phrase_length = len(trigger_phrase) 50 | content_value = content[content.find(trigger_phrase) + phrase_length:].strip() 51 | return content_value 52 | 53 | def log_attribute_change(self, attribute, content_value): 54 | content_value_str = content_value 55 | if type(content_value) is list: 56 | content_value_str = " ".join(content_value) 57 | logging.info(f"{attribute}: {content_value_str}") 58 | 59 | """ 60 | Attribute update classes linked from trigger map. make sure all references to attribute_value use index [1] 61 | """ 62 | 63 | def remove_entry_from_attribute_list(self, attribute_value, content, trigger_phrase): 64 | ''' 65 | Find any entry containing content_value and remove the first match 66 | ''' 67 | content_value: str 68 | content_value = self.trim_triggger_content(trigger_phrase, content) 69 | 70 | # special conditions. Yay. 71 | content_value = content_value.replace("my","") 72 | content_value = content_value.replace("your","") 73 | content_value = content_value.replace("our","") 74 | content_value = content_value.replace("the","") 75 | 76 | if content_value.startswith("a "): 77 | content_value = content_value.replace("a ","") 78 | 79 | for index, value in enumerate(attribute_value[1]): 80 | if content_value in value: 81 | attribute_value[1].pop(index) 82 | self.log_attribute_change(attribute_value, content_value) 83 | return 84 | 85 | def add_entry_to_attribute_list(self, attribute_value, content, trigger_phrase: str): 86 | # Gate adding things only if sentence starts with them. 87 | if content.startswith(trigger_phrase.removesuffix("*")): 88 | content_value = content 89 | if trigger_phrase.endswith("*") == False: 90 | content_value = self.trim_triggger_content(trigger_phrase, content) 91 | 92 | user_name = self.user_attributes["name"][1] 93 | content_value = content_value.replace("i have",f"{user_name} has") 94 | content_value = content_value.replace("i am",f"{user_name} is") 95 | 96 | # attribute[1] is the value itself 97 | attribute_value[1].append(content_value) 98 | 99 | def set_attribute_value(self, attribute_value, content, trigger_phrase): 100 | content_value = self.trim_triggger_content(trigger_phrase, content) 101 | attribute_value[1] = content_value 102 | 103 | ''' 104 | Context Commands: read, write, update, delete(not yet implemented) 105 | ''' 106 | 107 | def save_context(self, bot_context_save_name) -> str: 108 | with open(f"{self.CONTEXT_FOLDER}{self.whisperword_user}_{bot_context_save_name}.botctx", "w") as file: 109 | output = json.dumps({ 110 | "user_attributes": self.user_attributes, 111 | "bot_attributes": self.bot_attributes, 112 | "whisperword": self.whisperword, 113 | "whisperword_user": self.whisperword_user, 114 | }) 115 | file.write(output) 116 | return output 117 | 118 | def load_context(self, bot_context_save_name): 119 | try: 120 | with open(f"{self.CONTEXT_FOLDER}{self.whisperword_user}_{bot_context_save_name}.botctx", "r") as file: 121 | context = json.loads(file.read()) 122 | except Exception as err: 123 | raise Exception(err) 124 | 125 | if self.whisperword_user == context["whisperword_user"]: 126 | for userattr in context["user_attributes"]: 127 | self.user_attributes[userattr][1] = context["user_attributes"][userattr][1] 128 | for botattr in context["bot_attributes"]: 129 | self.bot_attributes[botattr][1] = context["bot_attributes"][botattr][1] 130 | self.update_trigger_phrase_maps() 131 | return "Loaded successfully" 132 | else: 133 | raise NameError("Context is for a different user.") 134 | 135 | 136 | def list_context(self): 137 | file_list = os.listdir(f"{self.CONTEXT_FOLDER}") 138 | ctx_files = [ 139 | file.replace(".botctx","") for file in file_list 140 | if file.endswith(".botctx") and file.startswith(self.whisperword_user) 141 | ] 142 | ctx_files = [file.replace(f"{self.whisperword_user}_","") for file in ctx_files] 143 | return ctx_files 144 | 145 | def context_command(self, content) -> str: 146 | ''' 147 | Process an incoming .[command] message 148 | ''' 149 | bot_ctx = self 150 | 151 | if (".context.help" in content): 152 | return "context.[get[] save.[bot|user].[clothing|description] load.[bot|user].[clothing|description]]" 153 | 154 | if (".context" == content): 155 | return bot_ctx.get_prompt("") 156 | 157 | if (content.startswith(".context.load")): 158 | value = content[content.find(" "):].strip() 159 | try: 160 | self.load_context(value) 161 | return f"I've performed the operation: load the context as {value}" 162 | except Exception as err: 163 | return f"{err}" 164 | 165 | if (content.startswith(".context.save")): 166 | value = content[content.find(" "):].strip() 167 | if value == "": 168 | return "Not saved. Name was empty." 169 | bot_ctx.save_context(value) 170 | return f"I've performed the operation: save the context as {value}" 171 | 172 | if (".context.get" == content): 173 | return bot_ctx.get_prompt("") 174 | 175 | if (".context.list" == content): 176 | context_list = bot_ctx.list_context() 177 | return f"Here's the contexts I have: {context_list}" 178 | 179 | if (".context.reset" == content): 180 | bot_ctx.__init__(self.whisperword_user, self.bot_attributes["name"][1], self.whisperword_channel) 181 | return f"I've performed the operation: reset the context" 182 | 183 | def proces_content_for_triggers(self, content: str): 184 | ''' 185 | Trigger phrase Dict has the trigger phrases stored as keys with function calls to update attributes as the values. 186 | Why did I do it that way? Because i have no idea what I'm doing. 187 | 188 | Iterate over each trigger key and check to see if it matches something in the content. If it does, 189 | then call the matching method and pass the content along. 190 | 191 | Methods are called with the magic of locals() 192 | ''' 193 | 194 | content = content.lower() 195 | if content.endswith("."): 196 | content = content[:len(content)-1] 197 | 198 | for trigger_phrase in self.trigger_phrases_maps: 199 | trigger_phrase = trigger_phrase.lower() 200 | if trigger_phrase.replace("*", "") in content: 201 | attribute_value = self.trigger_phrases_maps[trigger_phrase][0] 202 | method = self.trigger_phrases_maps[trigger_phrase][1] 203 | self.my_funcs[method](self, attribute_value, content, trigger_phrase) 204 | self.save_context("autosave") 205 | logging.info(f"triggered {trigger_phrase}") 206 | break 207 | 208 | #TODO: Create a method to determine subject and object 209 | 210 | def get_prompt(self, content): 211 | ''' 212 | Get the AI prompt. 213 | 214 | Example: "You are Joebob. You are a junky clown. You are wearing a clown suit, a clown hat. You are talking to 215 | Jamin. Jamin is a human male. Jamin is wearing a bananahammock, a windbreaker." 216 | ''' 217 | bot_name = self.bot_attributes["name"][1] 218 | user_name = self.user_attributes["name"][1] 219 | context_str = "" 220 | bot_discussion_context_str = "" 221 | user_discussion_context_str = "" 222 | history_context_str = "" 223 | 224 | # Iterate all the bot attributes. 225 | # attr[0] is the prefix phrase, it should exist on all attributes 226 | # attr[1] is the attribute data, it might be empty. It can be a list or a string. 227 | for attr in self.bot_attributes.values(): 228 | if type(attr[1]) == list: 229 | if len(attr[1]) != 0: 230 | bot_discussion_context_str += attr[0].replace("bot_name", bot_name) 231 | bot_discussion_context_str += ", ".join(attr[1]) 232 | bot_discussion_context_str += ". " 233 | elif attr[1] != "": 234 | bot_discussion_context_str += attr[0].replace("bot_name", bot_name) 235 | bot_discussion_context_str += attr[1] 236 | bot_discussion_context_str += ". " 237 | 238 | # Same exact thing for user attributes. Just replace user_name keyword 239 | for attr in self.user_attributes.values(): 240 | if type(attr[1]) == list: 241 | if len(attr[1]) != 0: 242 | user_discussion_context_str += attr[0].replace("user_name", user_name) 243 | user_discussion_context_str += ", ".join(attr[1]) 244 | user_discussion_context_str += ". " 245 | elif attr[1] != "": 246 | user_discussion_context_str += attr[0].replace("user_name", user_name) 247 | user_discussion_context_str += attr[1] 248 | user_discussion_context_str += ". " 249 | 250 | context_str = f"Provide only one response as yourself \n" # Because the bot likes to respond for the user sometimes. 251 | 252 | # Make sure everything ends with a newling 253 | user_discussion_context_str += nl 254 | bot_discussion_context_str += nl 255 | 256 | # Get the history to append 257 | history_context_str = self.conversation_history.history_to_str() 258 | 259 | # Add prompts for the targets 260 | target_prompts = f"{user_name}: {content}{nl}{bot_name}: " 261 | 262 | # Bring everything together 263 | context_str = f"{bot_discussion_context_str}{user_discussion_context_str}{history_context_str}{target_prompts}" 264 | 265 | return context_str 266 | 267 | def update_trigger_phrase_maps(self): 268 | # attribute phrase appended with a * indicates the attribute phrase is persisted with the rest of the data. This generally requires 269 | # TODO: COMPLICATED: allow for multiple prepositional phrases. 270 | # TODO: Create environment attributes separate of user 271 | # TODO: Think about handling multi-value attributes that have a rolling nature like history for more ephemeral context 272 | self.trigger_phrases_maps = { 273 | "my name is" : [self.user_attributes["name"], "set_attribute_value"], 274 | "i put on" : [self.user_attributes["clothing"], "add_entry_to_attribute_list"], #TODO: use NLP to allow for "i put [a thing] on. Assume the object is myself if none." 275 | "i take off" : [self.user_attributes["clothing"], "remove_entry_from_attribute_list"], 276 | "you are wearing" : [self.bot_attributes["clothing"], "add_entry_to_attribute_list"], 277 | "you are no longer wearing" : [self.bot_attributes["clothing"], "remove_entry_from_attribute_list"], 278 | "put on" : [self.bot_attributes["clothing"], "add_entry_to_attribute_list"], 279 | "take off your" : [self.bot_attributes["clothing"], "remove_entry_from_attribute_list"], #TODO: I really need to channel "take off" phrase with subject object NLP conditions rather than handling it here 280 | "you are no longer" : [self.bot_attributes["description"], "remove_entry_from_attribute_list"], 281 | "you no longer have" : [self.bot_attributes["description"], "remove_entry_from_attribute_list"], 282 | "you are*" : [self.bot_attributes["description"], "add_entry_to_attribute_list"], 283 | "you have*" : [self.bot_attributes["description"], "add_entry_to_attribute_list"], 284 | "i am no longer" : [self.user_attributes["description"], "remove_entry_from_attribute_list"], 285 | "i no longer have" : [self.user_attributes["description"], "remove_entry_from_attribute_list"], 286 | "i am*" : [self.user_attributes["description"], "add_entry_to_attribute_list"], 287 | "i have*" : [self.user_attributes["description"], "add_entry_to_attribute_list"], 288 | "we are no longer " : [self.user_attributes["environment"], "remove_entry_from_attribute_list"], 289 | "we are*" : [self.user_attributes["environment"], "add_entry_to_attribute_list"], 290 | "there are no longer" : [self.user_attributes["environment"], "add_entry_to_attribute_list"], 291 | "there are*" : [self.user_attributes["environment"], "add_entry_to_attribute_list"], 292 | "there is no longer" : [self.user_attributes["environment"], "add_entry_to_attribute_list"], 293 | "there is*" : [self.user_attributes["environment"], "add_entry_to_attribute_list"], 294 | } 295 | ''' 296 | Notes on remodeling this using NLP or some other possibly better method. 297 | Problem 1: reconstructing the sentence in the proper voice - first, second, or third person. 298 | Ex: User input: "I am a squirrel with a voracious appetite." 299 | More Simplistic Approach: have just one list of attribute phrases, which are stored when they hit trigger phrases. 300 | Is removing stuff mor interesting in this approach? 301 | 302 | One thing to mention about the existing approach is that there's really two types of triggers going on here: 303 | 1. Imperative: put on, take off. These initiate a change in state. 304 | a. this could be extended further into "can you", "would you" 305 | 2. Declaratives: you are, i am, i have, there is, we are 306 | Each of these then has a negated counterpart. 307 | 308 | Single state objects: a specific user can only be in one location 309 | I would love to be able to handle state based stuff like this 310 | "I sit down on the bed". This could be a single entry string and update as things are going on. Could use NLP here 311 | ''' 312 | 313 | 314 | def __init__(self, user_name, bot_name, channel_id, max_conversation_history: int): 315 | """ 316 | Create a new bot context for the user in the specific channel. 317 | """ 318 | 319 | self.user_attributes = { 320 | "name": ["You are talking to ",""], 321 | "description": ["", []], 322 | "clothing": [f"user_name is wearing ", []], 323 | "environment":["", []], 324 | } 325 | 326 | self.bot_attributes = { 327 | "name": ["Your name is ", ""], 328 | "description":["",[]], 329 | "clothing":["You are wearing ", []], 330 | "mood":["You are feeling ", []], 331 | } 332 | 333 | self.update_trigger_phrase_maps() 334 | 335 | self.whisperword = True 336 | self.whisperword_user = user_name 337 | self.whisperword_channel = channel_id 338 | self.bot_attributes["name"][1] = bot_name 339 | self.user_attributes["name"][1] = user_name 340 | self.conversation_history = ConversationHistory(max_conversation_history) 341 | 342 | -------------------------------------------------------------------------------- /prompt_handler_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from prompt_handler import PromptHandler 3 | import json 4 | 5 | nl = "\n" 6 | 7 | def build_tc_mock(): 8 | tc = PromptHandler("knight","shrubbery","nih", 3) 9 | content = [ 10 | "My name is jamin", 11 | "I am a tall man", 12 | "You are a short woman", 13 | "I put on a hat", 14 | "I put on a cape", 15 | "Put on a robe", 16 | "Put on a jacket", 17 | ] 18 | 19 | for case in content: 20 | tc.proces_content_for_triggers(case) 21 | 22 | return tc 23 | 24 | class TestPromptHandler(unittest.TestCase): 25 | def setUp(self) -> None: 26 | self.tc = build_tc_mock() 27 | 28 | def test_set_user_attribute(self): 29 | self.assertEqual(self.tc.user_attributes["name"][1], "jamin") 30 | self.assertEqual(self.tc.user_attributes["description"][1][0], "jamin is a tall man") 31 | self.assertEqual(self.tc.user_attributes["clothing"][1][0], "a hat") 32 | self.assertEqual(self.tc.user_attributes["clothing"][1][1], "a cape") 33 | 34 | def test_set_bot_attribute(self): 35 | self.assertEqual(self.tc.bot_attributes["name"][1], "shrubbery") 36 | self.assertEqual(self.tc.bot_attributes["description"][1][0], "you are a short woman") 37 | self.assertEqual(self.tc.bot_attributes["clothing"][1][0], "a robe") 38 | self.assertEqual(self.tc.bot_attributes["clothing"][1][1], "a jacket") 39 | 40 | def test_delete_attrbibutes(self): 41 | pass 42 | 43 | def test_save_context(self): 44 | json_string = self.tc.save_context("test_context") 45 | obj = json.loads(json_string) 46 | 47 | self.assertEqual(obj["user_attributes"]["name"][1], "jamin") 48 | self.assertEqual(obj["user_attributes"]["description"][1][0], "jamin is a tall man") 49 | self.assertEqual(obj["user_attributes"]["clothing"][1][0], "a hat") 50 | self.assertEqual(obj["user_attributes"]["clothing"][1][1], "a cape") 51 | 52 | 53 | def test_load_context(self): 54 | fresh_tc = PromptHandler("knight","spam","eggs and spam", 3) 55 | self.tc.save_context("test_context") 56 | fresh_tc.load_context("test_context") 57 | 58 | self.assertEqual(fresh_tc.user_attributes["name"][1], "jamin") 59 | self.assertEqual(fresh_tc.user_attributes["description"][1][0], "jamin is a tall man") 60 | self.assertEqual(fresh_tc.user_attributes["clothing"][1][0], "a hat") 61 | self.assertEqual(fresh_tc.user_attributes["clothing"][1][1], "a cape") 62 | 63 | def test_list_context(self): 64 | self.assertTrue("test_context" in self.tc.list_context()) 65 | 66 | def test_remove_entry_from_attribute_list(self): 67 | self.tc.proces_content_for_triggers("I put on a tshirt") 68 | self.tc.proces_content_for_triggers("Put on a bracelet") 69 | 70 | self.assertTrue(len([item for item in self.tc.user_attributes["clothing"][1] if "tshirt" in item]) != 0) 71 | self.assertTrue(len([item for item in self.tc.bot_attributes["clothing"][1] if "bracelet" in item]) != 0) 72 | 73 | self.tc.proces_content_for_triggers("I take off my tshirt") 74 | self.tc.proces_content_for_triggers("Take off your bracelet") 75 | 76 | self.assertEqual([item for item in self.tc.user_attributes["clothing"][1] if "tshirt" in item], []) 77 | self.assertEqual([item for item in self.tc.bot_attributes["clothing"][1] if "bracelet" in item], []) 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord 2 | asyncio 3 | python-dotenv 4 | openai 5 | --------------------------------------------------------------------------------