From 7e9fb97cf6d08abb890ce2e79fce4e06c67e9252 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 7 Aug 2023 15:32:19 -0500 Subject: [PATCH 01/19] tests: add suite for chatgpt cog Learned a lot about how to test pycord internals with this --- tests/test_chatgpt_commands.py | 154 +++++++++++++++++++++++++++++++++ tests/test_chatgpt_gptuser.py | 71 +++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 tests/test_chatgpt_commands.py create mode 100644 tests/test_chatgpt_gptuser.py diff --git a/tests/test_chatgpt_commands.py b/tests/test_chatgpt_commands.py new file mode 100644 index 0000000..b120bb5 --- /dev/null +++ b/tests/test_chatgpt_commands.py @@ -0,0 +1,154 @@ +import datetime +import io +from unittest.mock import Mock, MagicMock + +import discord +import discord.iterators +import pytest + +from cogs.chatgpt import ChatGPT +from util.chatgpt import GPTUser + + +class TestChatGPTCommands: + @pytest.fixture + def bot(self): + bot = MagicMock() + bot.config = {"ChatGPT": {"api_key": "foo", "system_prompt": "System prompt"}} + bot.user = MagicMock() + bot.user.display_name = "Bot" + return bot + + @pytest.fixture + def cog(self, bot): + cog = ChatGPT(bot) + cog.users = {123: GPTUser(123, "User", "My prompt")} + return cog + + @pytest.fixture + def gu_mini_conversation(self, cog): + gu: GPTUser = cog.users[123] + gu.push_conversation({"role": "user", "content": "Hello"}) + gu.push_conversation({"role": "assistant", "content": "Hi"}) + return gu + + # Tests that the bot resets the conversation history + @pytest.mark.asyncio + async def test_reset(self, mocker, cog): + cog.reset.cog = cog + ctx = MagicMock(spec=discord.ApplicationContext) + ctx.author.id = 123 + ctx.author.display_name = "Nuvie" + ctx.respond = mocker.AsyncMock() + ctx.command = MagicMock(spec=discord.ApplicationCommand) + assert 123 in cog.users + await cog.reset(ctx, "") + assert "Nuvie" in cog.users[123].conversation[0]["content"] + assert len(cog.users[123].conversation) == 1 + ctx.respond.assert_called_once_with( + "Your conversation history has been reset.", ephemeral=True + ) + + # Tests that stale conversations can be resumed + @pytest.mark.asyncio + async def test_continue(self, mocker, cog): + cog.continue_conversation.cog = cog + ctx = mocker.Mock() + ctx.respond = mocker.AsyncMock() + ctx.author.id = 123 + cog.users[123].staleseen = True + + await cog.continue_conversation(ctx) + + assert not cog.users[123].staleseen + assert isinstance(cog.users[123].last, datetime.datetime) + ctx.respond.assert_called_once_with( + "Your conversation has been resumed.", ephemeral=True + ) + + # Tests that the bot shows the conversation history + @pytest.mark.asyncio + async def test_show_conversation(self, mocker, cog): + cog.show_conversation.cog = cog + ctx = Mock(spec=discord.ApplicationContext) + ctx.author.id = 123 + ctx.author.send = mocker.AsyncMock() + ctx.respond = mocker.AsyncMock() + cog.format_conversation = mocker.Mock(return_value="User: Hello\nBot: Hi") + await cog.show_conversation(ctx) + ctx.author.send.assert_called_once_with("Here is your conversation with Bot:") + ctx.respond.assert_called_once_with( + "I've sent you a private message with your conversation history.", + ephemeral=True, + ) + + # Tests that the bot saves the conversation history to a text file + @pytest.mark.asyncio + async def test_save_conversation(self, mocker, cog, gu_mini_conversation): + cog.save_conversation.cog = cog + ctx = Mock(spec=discord.ApplicationContext) + ctx.author.id = 123 + ctx.author.send = mocker.AsyncMock() + ctx.respond = mocker.AsyncMock() + + data = io.BytesIO() + data.write(gu_mini_conversation.format_conversation("Bot").encode()) + data.seek(0) + discord_file = discord.File(data, filename="conversation.txt") + + await cog.save_conversation(ctx) + + ctx.author.send.assert_called_once() + assert ctx.author.send.call_args[0][0] == "Here is your conversation with Bot:" + actual_file_sent = ctx.author.send.call_args[1]["file"] + assert actual_file_sent.fp.read() == discord_file.fp.read() + ctx.respond.assert_called_once_with( + "I've sent you a private message with your conversation history as a text file.", + ephemeral=True, + ) + + # Tests that the bot summarizes the last n messages in the current channel + @pytest.mark.asyncio + async def test_summarize_chat(self, mocker, cog): + cog.summarize_chat.cog = cog + cog.send_to_chatgpt = mocker.AsyncMock(return_value="Summary") + + channel = Mock(spec=discord.TextChannel) + channel.is_nsfw.return_value = False + channel.history.return_value = mocker.AsyncMock( + spec=discord.iterators.HistoryIterator + ) + channel.typing.return_value.__aenter__ = mocker.AsyncMock() + channel.typing.return_value.__aexit__ = mocker.AsyncMock() + author = Mock(spec=discord.Member) + author.name = "User" # Can't set name in the constructor + channel.history.return_value.flatten.return_value = [ + Mock( + spec=discord.Message, + author=author, + content="Hello", + ) + ] + ctx = Mock(spec=discord.ApplicationContext) + ctx.channel = channel + ctx.respond = mocker.AsyncMock() + ctx.send = mocker.AsyncMock() + ctx.send.return_value = Mock(spec=discord.Message) + ctx.send.return_value.edit = mocker.AsyncMock() + + await cog.summarize_chat(ctx, 1, "") + + cog.send_to_chatgpt.assert_called_once() + assert cog.send_to_chatgpt.call_args[0][0] == [ + { + "role": "system", + "content": "The following is a conversation between various people in a Discord chat. It is " + "formatted such that each line begins with the name of the speaker, a colon, " + "and then whatever the speaker said. Please provide a summary of the conversation " + "beginning below: \nUser: Hello\n", + } + ] + ctx.send.assert_called_with("Now generating summary of the last 1 messages…") + ctx.send.return_value.edit.assert_called_with( + content="Summary of the last 1 messages:\n\nSummary" + ) diff --git a/tests/test_chatgpt_gptuser.py b/tests/test_chatgpt_gptuser.py new file mode 100644 index 0000000..b712ca3 --- /dev/null +++ b/tests/test_chatgpt_gptuser.py @@ -0,0 +1,71 @@ +from datetime import datetime, timedelta +from hashlib import sha256 + +from util.souls import Soul +from util.chatgpt import GPTUser + + +class TestGPTUser: + # Tests that a GPTUser object is created with the correct attributes + def test_create_GPTUser_object(self): + user = GPTUser(1, "John", "Hello.") + assert user.id == 1 + assert user.name == "John" + assert user.namehash == sha256(str(1).encode("utf-8")).hexdigest() + assert user._conversation == [ + { + "role": "system", + "content": "Hello. The user's name is John and it should be used wherever possible.", + } + ] + assert isinstance(user.last, datetime) + assert user.staleseen is False + assert user._soul is None + assert user.telepathy is False + + # Tests that the conversation history is properly formatted + def test_format_conversation(self): + user = GPTUser(1, "John", "Hello") + user.push_conversation({"role": "user", "content": "Hi there"}) + user.push_conversation({"role": "assistant", "content": "How can I help you?"}) + formatted_conversation = user.format_conversation("Bot") + expected_output = "John: Hi there\nBot: How can I help you?" + assert formatted_conversation == expected_output + + # Tests that a new soul can be assigned to a GPTUser object + def test_assign_new_soul(self): + user = GPTUser(1, "John", "Hello") + soul = Soul("John", "short", "long", "plan") + user.soul = soul + assert user._soul == soul + assert len(user._conversation) == 1 + + # # Tests that the conversation history is properly truncated when it exceeds the maximum length + # def test_truncate_conversation_history(self): + # user = GPTUser(1, "John", "Hello") + # message = {"role": "user", "content": "a" * 500} + # for i in range(10): + # user.push_conversation(message) + # assert len(user._conversation) == 10 + # assert user.oversized == True + # user.push_conversation(message) + # assert len(user._conversation) == 2 + # assert user.oversized == False + + # Tests that the is_stale property returns True when the last message was sent more than 6 hours ago + def test_is_stale_property(self): + user = GPTUser(1, "John", "Hello") + assert user.is_stale is False + user.last = datetime.utcnow() - timedelta(hours=7) + assert user.is_stale is True + + # Tests that the oversized property returns True when the conversation history exceeds the maximum length + def test_oversized_property(self): + user = GPTUser(1, "John", "Hello") + message = {"role": "user", "content": "a" * 50} + for i in range(100): + user.push_conversation(message) + assert user.oversized is True + for i in range(100): + user.pop_conversation() + assert user.oversized is False From 93041a0c00dab6e34f4fc45b367320ec00e288f9 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Tue, 8 Aug 2023 15:59:55 -0500 Subject: [PATCH 02/19] c.chatgpt: move continue to GPTuser, show custom system prompt on reset - Updated the response message in `ChatGPT` to inform users about saving conversations using the `/ai save command`(Typo fix) - Modified the `reset_conversation` method in `ChatGPT` to include the system prompt if provided - Added a new method `freshen` to the `GPTUser` class, which clears stale seen flag and updates last message time --- cogs/chatgpt.py | 21 ++++++++++----------- util/chatgpt.py | 5 +++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index e4ff48d..e233f38 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -167,7 +167,7 @@ async def on_message(self, message: discord.Message): if gu.is_stale: response = ( "*This conversation is pretty old so the next time you talk to me, it will be a fresh " - "start. Please take this opportunity to save our conversation using the /ai save commands " + "start. Please take this opportunity to save our conversation using the /ai save command " "if you wish, or use /ai continue to keep this conversation going.*\n\n" + response ) @@ -202,12 +202,16 @@ async def reset( ), ): user_id = ctx.author.id + user_name = ctx.author.display_name self.users[user_id] = GPTUser( user_id, - ctx.author.display_name, - f"{system_prompt}" if system_prompt else self.config["system_prompt"], + user_name, + system_prompt if system_prompt else self.config["system_prompt"], ) - await ctx.respond("Your conversation history has been reset.", ephemeral=True) + response = "Your conversation history has been reset." + if system_prompt: + response += f"\nSystem prompt set to: {system_prompt}" + await ctx.respond(response, ephemeral=True) @gpt.command( name="continue", @@ -216,17 +220,12 @@ async def reset( ) async def continue_conversation(self, ctx): user_id = ctx.author.id - if user_id not in self.users: + if not self.users.get(user_id): await ctx.respond( "You have no active conversation to continue.", ephemeral=True ) return - - gu = self.users[user_id] - gu.staleseen = False - gu.last = datetime.utcnow() - self.users[user_id] = gu - + self.users[user_id].freshen() await ctx.respond("Your conversation has been resumed.", ephemeral=True) @gpt.command( diff --git a/util/chatgpt.py b/util/chatgpt.py index d6ce84b..b65c3da 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -108,6 +108,11 @@ def pop_conversation(self, index: int = -1): self.conversation = self._conversation # Trigger the setter return p + def freshen(self): + """Clear the stale seen flag and set the last message time to now""" + self.staleseen = False + self.last = datetime.utcnow() + MAX_LENGTH = 4097 MAX_TOKENS = 512 From d9b24bdc968015f7728b3c3fa8f34eefcba0c82d Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Tue, 8 Aug 2023 16:59:53 -0500 Subject: [PATCH 03/19] c.chatgpt: fix token calculation, first step on per-user configuration - Refactored the `send_to_chatgpt` method in the `ChatGPT` class to accept an optional list of messages and a `GPTUser` object as parameters. - Updated the usage of the `send_to_chatgpt` method in the `ChatGPT` class to pass None for messages and the user object. - Added a new named tuple called `Model` in the `util.chatgpt` module to represent the model configuration with default values. - Updated the constructor of the `GPTUser` class to accept an optional model parameter with default values from the new `Model` named tuple. - Removed unused imports from various modules. --- Pipfile | 1 + cogs/chatgpt.py | 22 +++++++++++--------- tests/test_chatgpt_gptuser.py | 14 +------------ util/chatgpt.py | 39 +++++++++++++++++++++++++++-------- 4 files changed, 44 insertions(+), 32 deletions(-) diff --git a/Pipfile b/Pipfile index 600d504..8c6ecc6 100644 --- a/Pipfile +++ b/Pipfile @@ -10,6 +10,7 @@ blitzdb = "~=0.4.4" PyYAML = "*" aiohttp = "*" openai = "*" +tiktoken = "~=0.4.0" [dev-packages] pytest = "*" diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index e233f38..18aace7 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -1,6 +1,5 @@ import io from typing import List, Optional, Dict -from datetime import datetime import discord import openai @@ -10,7 +9,7 @@ from discord.ext import commands import util -from util.chatgpt import GPTUser, MAX_TOKENS +from util.chatgpt import GPTUser, MAX_LENGTH from util.souls import Soul, REMEMBRANCE_PROMPT MAX_MESSAGE_LENGTH = 2000 @@ -26,16 +25,16 @@ def __init__(self, bot): openai.api_key = self.config["api_key"] bot.logger.info("ChatGPT integration initialized") - async def send_to_chatgpt(self, messages: List[dict], user: str) -> Optional[str]: + async def send_to_chatgpt( + self, messages: Optional[List[dict]], user: GPTUser + ) -> Optional[str]: try: response = await ChatCompletion.acreate( - model=self.config["model_name"], - messages=messages, - max_tokens=MAX_TOKENS, + **user.model._asdict(), # Name, max_tokens, temperature + messages=messages or user.conversation, n=1, stop=None, - temperature=0.5, - user=user, + user=user.idhash, ) return response.choices[0]["message"]["content"] except Exception as e: @@ -139,7 +138,7 @@ async def on_message(self, message: discord.Message): overflow.append(gu.pop_conversation(0)) async with message.channel.typing(): - response = await self.send_to_chatgpt(gu.conversation, gu.namehash) + response = await self.send_to_chatgpt(None, gu) telembed = None if gu.soul: response, telepathy = util.souls.format_from_soul(response) @@ -315,6 +314,9 @@ async def summarize_chat( default=None, ), ): + gu = self.users.get(ctx.author.id) or GPTUser( + ctx.author.id, ctx.author.name, "" + ) if ctx.channel.is_nsfw(): await ctx.respond( "Sorry, can't operate in NSFW channels (OpenAI TOS)", ephemeral=True @@ -355,7 +357,7 @@ async def summarize_chat( loading_message = await ctx.send( f"Now generating summary of the last {num_messages} messages…" ) - summary = await self.send_to_chatgpt(conversation, "0") + summary = await self.send_to_chatgpt(conversation, gu) if summary: await loading_message.edit( content=f"Summary of the last {num_messages} messages:\n\n{summary}" diff --git a/tests/test_chatgpt_gptuser.py b/tests/test_chatgpt_gptuser.py index b712ca3..a7aa506 100644 --- a/tests/test_chatgpt_gptuser.py +++ b/tests/test_chatgpt_gptuser.py @@ -11,7 +11,7 @@ def test_create_GPTUser_object(self): user = GPTUser(1, "John", "Hello.") assert user.id == 1 assert user.name == "John" - assert user.namehash == sha256(str(1).encode("utf-8")).hexdigest() + assert user.idhash == sha256(str(1).encode("utf-8")).hexdigest() assert user._conversation == [ { "role": "system", @@ -40,18 +40,6 @@ def test_assign_new_soul(self): assert user._soul == soul assert len(user._conversation) == 1 - # # Tests that the conversation history is properly truncated when it exceeds the maximum length - # def test_truncate_conversation_history(self): - # user = GPTUser(1, "John", "Hello") - # message = {"role": "user", "content": "a" * 500} - # for i in range(10): - # user.push_conversation(message) - # assert len(user._conversation) == 10 - # assert user.oversized == True - # user.push_conversation(message) - # assert len(user._conversation) == 2 - # assert user.oversized == False - # Tests that the is_stale property returns True when the last message was sent more than 6 hours ago def test_is_stale_property(self): user = GPTUser(1, "John", "Hello") diff --git a/util/chatgpt.py b/util/chatgpt.py index b65c3da..adc7a8c 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -1,35 +1,52 @@ +from collections import namedtuple from datetime import datetime, timedelta from hashlib import sha256 from typing import List, Dict, Optional from util.souls import Soul, SOUL_PROMPT +import tiktoken +Model = namedtuple( + "Model", "model max_tokens temperature", defaults=("gpt-4", 512, 0.5) +) class GPTUser: __slots__ = [ "id", "name", - "namehash", + "idhash", "_conversation", "last", "staleseen", "_soul", "telepathy", + "model", + "_encoding", ] id: int name: str - namehash: str + idhash: str _conversation: List[Dict[str, str]] last: datetime stale: bool staleseen: bool _soul: Optional[Soul] telepathy: bool - - def __init__(self, uid: int, uname: str, sysprompt: str, suffix: bool = True): + model: Model + _encoding: tiktoken.Encoding + + # noinspection PyArgumentList + def __init__( + self, + uid: int, + uname: str, + sysprompt: str, + suffix: bool = True, + model: Model = Model(), + ): self.id = uid self.name = uname - self.namehash = sha256(str(uid).encode("utf-8")).hexdigest() + self.idhash = sha256(str(uid).encode("utf-8")).hexdigest() self.staleseen = False prompt_suffix = ( f" The user's name is {self.name} and it should be used wherever possible." @@ -43,6 +60,8 @@ def __init__(self, uid: int, uname: str, sysprompt: str, suffix: bool = True): self.last = datetime.utcnow() self._soul = None self.telepathy = False + self.model = model + self._encoding = tiktoken.encoding_for_model(model.model) @property def conversation(self): @@ -88,11 +107,13 @@ def soul(self, new_soul: Soul): @property def _conversation_len(self): - cl = 0 if self.conversation: - for entry in self.conversation: - cl += len(entry["content"]) - return cl + return sum( + len(self._encoding.encode(entry["content"])) + for entry in self.conversation + ) + else: + return 0 def push_conversation(self, utterance: dict[str, str], copy=False): """Append the given line of dialogue to this conversation""" From 1645f2d83d93a1a01393ccf32aed6247918ad5a0 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Tue, 8 Aug 2023 17:01:49 -0500 Subject: [PATCH 04/19] c.chatgpt: add user flags, statistics support --- cogs/chatgpt.py | 8 ++++++++ util/chatgpt.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 18aace7..f326e6f 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -178,6 +178,14 @@ async def on_message(self, message: discord.Message): "longer useful.*\n\n" + response ) gu.conversation = overflow + gu.conversation + if gu.config.SHOWSTATS: + response = ( + response + + f"\n\n*📏{gu._conversation_len}/{MAX_LENGTH}{'(❗)' if gu.oversized else ''} " + f"{'👼' + gu.soul.name if gu.soul else ''} " + f"🗣️{gu.model.model} " + f"*" + ) else: response = "Sorry, can't talk to OpenAI right now." gu.pop_conversation() # GPT didn't get the last thing the user said, so forget it diff --git a/util/chatgpt.py b/util/chatgpt.py index adc7a8c..f8bbe80 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from hashlib import sha256 from typing import List, Dict, Optional +from enum import Flag, auto from util.souls import Soul, SOUL_PROMPT @@ -10,6 +11,12 @@ Model = namedtuple( "Model", "model max_tokens temperature", defaults=("gpt-4", 512, 0.5) ) + + +class UserConfig(Flag): + SHOWSTATS = auto() + + class GPTUser: __slots__ = [ "id", @@ -21,6 +28,7 @@ class GPTUser: "_soul", "telepathy", "model", + "config", "_encoding", ] id: int @@ -33,6 +41,7 @@ class GPTUser: _soul: Optional[Soul] telepathy: bool model: Model + config: UserConfig _encoding: tiktoken.Encoding # noinspection PyArgumentList @@ -61,6 +70,7 @@ def __init__( self._soul = None self.telepathy = False self.model = model + self.config = UserConfig.SHOWSTATS self._encoding = tiktoken.encoding_for_model(model.model) @property From ca9a95b3806b0d8889d0a504eb9aad80fa4c5584 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 28 Aug 2023 10:47:42 -0500 Subject: [PATCH 05/19] c.chatgpt: massive refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Model is now a dataclass and ties up information like maximum tokens and context window - Conversation length is now part of GPTUser rather then being naïvely calculated every time the property is accessed - Conversation is now a list of typed dictionaries - Moved soul telepathy setting into user configuration flags - Removed unnecessary constants - Centralized most access to GPTUsers to a new function - More tests and docstrings --- cogs/chatgpt.py | 197 +++++++++++++++++--------------- tests/test_chatgpt_commands.py | 12 +- tests/test_chatgpt_gptuser.py | 9 +- tests/test_chatgpt_onmessage.py | 55 +++++++++ tests/test_util.py | 99 ++++++++++++++++ util/__init__.py | 52 ++++++--- util/chatgpt.py | 101 +++++++++------- 7 files changed, 377 insertions(+), 148 deletions(-) create mode 100644 tests/test_chatgpt_onmessage.py create mode 100644 tests/test_util.py diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index f326e6f..33fed9b 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -9,11 +9,9 @@ from discord.ext import commands import util -from util.chatgpt import GPTUser, MAX_LENGTH +from util.chatgpt import GPTUser, UserConfig, ConversationLine, Model from util.souls import Soul, REMEMBRANCE_PROMPT -MAX_MESSAGE_LENGTH = 2000 - class ChatGPT(commands.Cog): gpt = SlashCommandGroup("ai", "AI chatbot", guild_ids=util.guilds) @@ -25,13 +23,21 @@ def __init__(self, bot): openai.api_key = self.config["api_key"] bot.logger.info("ChatGPT integration initialized") - async def send_to_chatgpt( - self, messages: Optional[List[dict]], user: GPTUser - ) -> Optional[str]: + async def send_to_chatgpt(self, user, conversation=None) -> Optional[str]: + """Sends a conversation to OpenAI for chat completion and returns what the model said in reply. The model + details will be read from the provided GPTUser. If a conversation is provided, it will be sent to the model. + Otherwise, the conversation will be read from the user object. + :param conversation: A specific conversation to be replied to, rather than the user's conversation + :type conversation: List[ConversationLine] or None + :param GPTUser user: The user object associated with this conversation + :return: + """ try: response = await ChatCompletion.acreate( - **user.model._asdict(), # Name, max_tokens, temperature - messages=messages or user.conversation, + model=user.model.model, + max_tokens=user.model.max_tokens, + temperature=user.model.temperature, + messages=conversation or user.conversation, n=1, stop=None, user=user.idhash, @@ -45,6 +51,32 @@ def remove_bot_mention(self, content: str) -> str: mention = self.bot.user.mention return content.replace(mention, "").strip() + def get_user_from_context(self, context, force_new=False, **kwargs) -> GPTUser: + """Returns a new or existing GPTUser based on the `author` of the provided context. + Generally, you should be using this rather than reaching directly into `self.users` + + :param context: Something with an .author.id property that resolves to a Discord user ID + :type context: discord.Message or discord.ApplicationContext + :param force_new: If true, force creation of a new user object even if one already exists in `self.users` + :param kwargs: + sysprompt str: Overrides the system prompt for a new user. Implies `force_new` + """ + uid = context.author.id + if context.guild: + config = self.config.get(context.guild.id, self.config["default"]) + else: + config = self.config["default"] + if uid in self.users and not force_new: + return self.users[uid] + else: + sysprompt = kwargs.pop("sysprompt", config["system_prompt"]) + return GPTUser( + uid=uid, + uname=context.author.display_name, + sysprompt=sysprompt or config["system_prompt"], + model=Model(config["model_name"]), + ) + def should_reply(self, message: discord.Message) -> bool: """Determine whether the given message should be replied to. TL;DR: DON'T reply to system messages, bot messages, @everyone pings, or anything in a NSFW channel. DO reply to direct messages where we @@ -69,6 +101,10 @@ def should_reply(self, message: discord.Message) -> bool: return False def copy_public_reply(self, message: discord.Message): + """Helper function for dereferencing and copying the content of another user's bot reply. This allows for + reasonable replies should a user reply to a message directed at another user since conversations are usually + isolated. + """ if message.reference: replied_to = message.reference.resolved if replied_to and replied_to.author == self.bot.user: @@ -79,32 +115,21 @@ def copy_public_reply(self, message: discord.Message): self.users[message.author.id].push_conversation(last_bot_msg, True) @staticmethod - async def reply( - message: discord.Message, content: str, em: Optional[discord.Embed] - ): - """Replies to the given Message depending on its type. Do a full reply and - mention the author if the message was sent in public, or just send to the - channel if it was a direct message or thread.""" - while len(content) > 0: - # If message is too large, find the last newline before the limit - if len(content) > MAX_MESSAGE_LENGTH: - split_index = content[:MAX_MESSAGE_LENGTH].rfind("\n") - # If no newline is found, just split at the max length - if split_index == -1: - split_index = MAX_MESSAGE_LENGTH - else: - split_index = len(content) - - chunk = content[:split_index] - # Remove the chunk from the original content - content = content[split_index:].lstrip() - - # Send chunk + async def reply(message, content, em): + """Replies to the given `Message` depending on its type, and automatically break up the replies to stay under + Discord's maximum message length. Does a full reply and mentions the author if the message was sent in public, + or just sends to the channel if it was a direct message or thread. + :param discord.Message message: The message to reply to + :param str content: Text to send as a reply + :param em: An embed to send with the content. If `content` had to be split, it gets sent with the first message. + :type em: discord.Embed or None + """ + for chunk in util.split_content(content): if isinstance(message.channel, (discord.DMChannel, discord.Thread)): await message.channel.send(chunk, embed=em) else: await message.reply(chunk, embed=em) - em = None # Only send the embed with the first chunk + em = None @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -114,23 +139,20 @@ async def on_message(self, message: discord.Message): self.copy_public_reply(message) user_id = message.author.id - gu = self.users.get(user_id) or GPTUser( - user_id, message.author.display_name, self.config["system_prompt"] - ) + gu = self.get_user_from_context(message) message.content = self.remove_bot_mention(message.content) if gu.is_stale: if gu.staleseen: - del self.users[user_id] - gu = GPTUser( - user_id, message.author.display_name, self.config["system_prompt"] - ) + gu = self.get_user_from_context(message, True) + gu.push_conversation({"role": "user", "content": message.content}) if gu.soul: + # noinspection PyProtectedMember gu.push_conversation( { "role": "system", - "content": REMEMBRANCE_PROMPT.format(**dict(gu.soul)), + "content": REMEMBRANCE_PROMPT.format(**gu.soul._asdict()), } ) overflow = [] @@ -138,8 +160,10 @@ async def on_message(self, message: discord.Message): overflow.append(gu.pop_conversation(0)) async with message.channel.typing(): - response = await self.send_to_chatgpt(None, gu) + response = await self.send_to_chatgpt(gu) telembed = None + warnings = "" + stats = "" if gu.soul: response, telepathy = util.souls.format_from_soul(response) telembed = ( @@ -151,7 +175,7 @@ async def on_message(self, message: discord.Message): thought=telepathy[1], analysis=telepathy[2], ) - if gu.telepathy + if gu.config & UserConfig.TELEPATHY else None ) @@ -164,24 +188,28 @@ async def on_message(self, message: discord.Message): ] # Throw out any system prompts but the first one gu.push_conversation({"role": "assistant", "content": response}) if gu.is_stale: - response = ( - "*This conversation is pretty old so the next time you talk to me, it will be a fresh " - "start. Please take this opportunity to save our conversation using the /ai save command " - "if you wish, or use /ai continue to keep this conversation going.*\n\n" - + response - ) + if gu.config & UserConfig.TERSEWARNINGS: + warnings += "⏰❗ " + else: + warnings += ( + "*This conversation is pretty old so the next time you talk to me, it will be a fresh " + "start. Please take this opportunity to save our conversation using the /ai save command " + "if you wish, or use /ai continue to keep this conversation going.*\n" + ) gu.staleseen = True if overflow: - response = ( - "*Our conversation is getting too long so I had to forget some of the earlier context. You " - "may wish to reset and/or save our conversation using the /ai commands if it is no " - "longer useful.*\n\n" + response - ) + if gu.config & UserConfig.TERSEWARNINGS: + warnings += "📏❗ " + else: + warnings += ( + "*Our conversation is getting too long so I had to forget some of the earlier context. You " + "may wish to reset and/or save our conversation using the /ai commands if it is no " + "longer useful.*\n" + ) gu.conversation = overflow + gu.conversation - if gu.config.SHOWSTATS: - response = ( - response - + f"\n\n*📏{gu._conversation_len}/{MAX_LENGTH}{'(❗)' if gu.oversized else ''} " + if gu.config & UserConfig.SHOWSTATS: + stats = ( + f"\n\n*📏{gu.conversation_len}/{gu.model.max_context}{'(❗)' if gu.oversized else ''} " f"{'👼' + gu.soul.name if gu.soul else ''} " f"🗣️{gu.model.model} " f"*" @@ -189,10 +217,8 @@ async def on_message(self, message: discord.Message): else: response = "Sorry, can't talk to OpenAI right now." gu.pop_conversation() # GPT didn't get the last thing the user said, so forget it - if gu.soul: - gu.pop_conversation() # We have to clear the remembrance prompt as well + response = f"{warnings}\n{response}\n{stats}" self.users[user_id] = gu - await self.reply(message, response, telembed) @gpt.command( @@ -208,13 +234,9 @@ async def reset( default=None, ), ): + gu = self.get_user_from_context(ctx, True, sysprompt=system_prompt) user_id = ctx.author.id - user_name = ctx.author.display_name - self.users[user_id] = GPTUser( - user_id, - user_name, - system_prompt if system_prompt else self.config["system_prompt"], - ) + self.users[user_id] = gu response = "Your conversation history has been reset." if system_prompt: response += f"\nSystem prompt set to: {system_prompt}" @@ -248,7 +270,7 @@ async def show_conversation(self, ctx): ) return - gu = self.users[user_id] + gu = self.get_user_from_context(ctx) bot_display_name = self.bot.user.display_name formatted_conversation = gu.format_conversation(bot_display_name) @@ -281,7 +303,7 @@ async def save_conversation(self, ctx): ) return - gu = self.users[user_id] + gu = self.get_user_from_context(ctx) bot_display_name = self.bot.user.display_name formatted_conversation = gu.format_conversation(bot_display_name) @@ -322,9 +344,7 @@ async def summarize_chat( default=None, ), ): - gu = self.users.get(ctx.author.id) or GPTUser( - ctx.author.id, ctx.author.name, "" - ) + gu = self.get_user_from_context(ctx) if ctx.channel.is_nsfw(): await ctx.respond( "Sorry, can't operate in NSFW channels (OpenAI TOS)", ephemeral=True @@ -354,7 +374,7 @@ async def summarize_chat( ) ) - conversation = [ + conversation: List[ConversationLine] = [ { "role": "system", "content": sysprompt, @@ -365,7 +385,9 @@ async def summarize_chat( loading_message = await ctx.send( f"Now generating summary of the last {num_messages} messages…" ) - summary = await self.send_to_chatgpt(conversation, gu) + # noinspection PyTypeChecker + # This is a lint bug + summary = await self.send_to_chatgpt(gu, conversation) if summary: await loading_message.edit( content=f"Summary of the last {num_messages} messages:\n\n{summary}" @@ -387,16 +409,13 @@ async def load_core( telepathy: discord.Option(bool, default=True, description="Show thinking"), ): try: - y = yaml.safe_load(open(f"cores/{core.split(' ')[0]}")) - s = Soul(**y) - # noinspection PyTypeChecker - ca: discord.Member = ( - ctx.author - ) # We know this is a Member since this is a slash command - gu = self.users.get(ca.id) or GPTUser(ca.id, ca.nick, "") - gu.soul = s - gu.telepathy = telepathy - self.users[ca.id] = gu + with open(f"cores/{core.split(' ')[0]}") as file: + core_data = yaml.safe_load(file) + loaded_soul = Soul(**core_data) + gu = self.get_user_from_context(ctx, True) + gu.soul = loaded_soul + gu.config |= UserConfig.TELEPATHY if telepathy else gu.config + self.users[gu.id] = gu except Exception as e: await ctx.respond(f"Failed to load {core}: {repr(e)}", ephemeral=True) return @@ -408,14 +427,14 @@ async def display_help(self, ctx: discord.ApplicationContext): help_embed.description = f"""I can use AI to hold a conversation. Just @mention me! I also accept DMs if you are in a server with me. -Conversations are specific to each person and are not stored. Additionally, openai has committed to deleting -conversations after 30 days and not using them to further train the AI. The bot will only see text that specifically -mentions it. - -Conversations timeout after six hours and will be reset after that time unless the continue command is used. - -Important commands (Others are in the / pop-up, these require additional explanation): -""" + Conversations are specific to each person and are not stored. Additionally, openai has committed to deleting + conversations after 30 days and not using them to further train the AI. The bot will only see text that + specifically mentions it. + + Conversations timeout after six hours and will be reset after that time unless the continue command is used. + + Important commands (Others are in the / pop-up, these require additional explanation): + """ help_embed.add_field( name="load_core", value="EXPERIMENTAL: load a soul core to have a conversation with a specific personality. Resets your " diff --git a/tests/test_chatgpt_commands.py b/tests/test_chatgpt_commands.py index b120bb5..9cbebee 100644 --- a/tests/test_chatgpt_commands.py +++ b/tests/test_chatgpt_commands.py @@ -14,7 +14,15 @@ class TestChatGPTCommands: @pytest.fixture def bot(self): bot = MagicMock() - bot.config = {"ChatGPT": {"api_key": "foo", "system_prompt": "System prompt"}} + bot.config = { + "ChatGPT": { + "api_key": "foo", + "default": { + "system_prompt": "System prompt", + "model_name": "gpt-3.5-turbo", + }, + } + } bot.user = MagicMock() bot.user.display_name = "Bot" return bot @@ -139,7 +147,7 @@ async def test_summarize_chat(self, mocker, cog): await cog.summarize_chat(ctx, 1, "") cog.send_to_chatgpt.assert_called_once() - assert cog.send_to_chatgpt.call_args[0][0] == [ + assert cog.send_to_chatgpt.call_args[0][1] == [ { "role": "system", "content": "The following is a conversation between various people in a Discord chat. It is " diff --git a/tests/test_chatgpt_gptuser.py b/tests/test_chatgpt_gptuser.py index a7aa506..701a431 100644 --- a/tests/test_chatgpt_gptuser.py +++ b/tests/test_chatgpt_gptuser.py @@ -2,7 +2,7 @@ from hashlib import sha256 from util.souls import Soul -from util.chatgpt import GPTUser +from util.chatgpt import GPTUser, UserConfig class TestGPTUser: @@ -21,7 +21,7 @@ def test_create_GPTUser_object(self): assert isinstance(user.last, datetime) assert user.staleseen is False assert user._soul is None - assert user.telepathy is False + assert not UserConfig.TELEPATHY & user.config # Tests that the conversation history is properly formatted def test_format_conversation(self): @@ -50,7 +50,10 @@ def test_is_stale_property(self): # Tests that the oversized property returns True when the conversation history exceeds the maximum length def test_oversized_property(self): user = GPTUser(1, "John", "Hello") - message = {"role": "user", "content": "a" * 50} + message = { + "role": "user", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit " * 50, + } for i in range(100): user.push_conversation(message) assert user.oversized is True diff --git a/tests/test_chatgpt_onmessage.py b/tests/test_chatgpt_onmessage.py new file mode 100644 index 0000000..dbde79a --- /dev/null +++ b/tests/test_chatgpt_onmessage.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock + +import discord +from cogs import chatgpt + +# Dependencies: +# pip install pytest-mock +import pytest + +from cogs.chatgpt import ChatGPT +from util.chatgpt import GPTUser + + +class TestOnMessage: + # Test that the method correctly handles the case when a user sends a message that should be replied to. + @pytest.fixture + def bot(self): + bot = MagicMock() + bot.config = {"ChatGPT": {"api_key": "foo", "system_prompt": "System prompt"}} + bot.user = MagicMock() + bot.user.display_name = "Bot" + return bot + + @pytest.fixture + def cog(self, bot): + cog = ChatGPT(bot) + cog.users = {123: GPTUser(123, "User", "My prompt")} + return cog + + @pytest.mark.asyncio + async def test_happy_path(self, mocker, cog): + message = MagicMock(spec=discord.Message) + message.author.id = 123 + message.content = "Hello" + message.channel.typing.return_value.__aenter__ = mocker.AsyncMock() + message.channel.typing.return_value.__aexit__ = mocker.AsyncMock() + gu = GPTUser(123, "TestUser", "System Prompt") + gu.push_conversation({"role": "user", "content": "Hello"}) + cog.should_reply = mocker.MagicMock(return_value=True) + cog.copy_public_reply = mocker.MagicMock() + cog.get_user_from_context = mocker.MagicMock(return_value=gu) + cog.remove_bot_mention = mocker.MagicMock(return_value="Hello") + cog.send_to_chatgpt = mocker.AsyncMock(return_value="Hi") + cog.reply = mocker.AsyncMock() + + await cog.on_message(message) + + cog.should_reply.assert_called_once_with(message) + cog.copy_public_reply.assert_called_once_with(message) + cog.get_user_from_context.assert_called_once_with(message) + cog.remove_bot_mention.assert_called_once_with("Hello") + cog.send_to_chatgpt.assert_called_once_with(gu) + cog.reply.assert_called_once_with( + message, "\nHi\n\n\n*📏20/4097 🗣️gpt-3.5-turbo *", None + ) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..0027667 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,99 @@ +from util import split_content, MAX_MESSAGE_LENGTH, mkembed +import discord +import pytest + + +class TestSplitContent: + # Tests that the function correctly splits a content string that is shorter than MAX_MESSAGE_LENGTH. + def test_short_content(self): + content = "This is a short content string." + result = list(split_content(content)) + assert len(result) == 1 + assert result[0] == content + + # Tests that the function correctly splits a content string that is exactly MAX_MESSAGE_LENGTH. + def test_exact_content(self): + content = "A" * MAX_MESSAGE_LENGTH + result = list(split_content(content)) + assert len(result) == 1 + assert result[0] == content + + # Tests that the function correctly splits a content string that is longer than MAX_MESSAGE_LENGTH. + def test_long_content(self): + content = "A" * (MAX_MESSAGE_LENGTH + 1) + result = list(split_content(content)) + assert len(result) == 2 + assert result[0] == "A" * MAX_MESSAGE_LENGTH + assert result[1] == "A" + + # Tests that the function correctly splits a content string that has multiple newlines. + def test_multiple_newlines(self): + content = "Line 1\nLine 2\nLine 3\nLine 4" + result = list(split_content(content)) + assert len(result) == 1 + assert result[0] == content + + # Tests that the function correctly handles an empty content string. + + def test_empty_content(self): + content = "" + result = list(split_content(content)) + assert len(result) == 0 + + # Tests that the function correctly handles a content string that has only one character. + def test_single_character_content(self): + content = "A" + result = list(split_content(content)) + assert len(result) == 1 + assert result[0] == content + + +class TestMkembed: + # Tests that the function creates an embed with "done" kind and the provided description + def test_embed_with_done_kind_and_description(self): + kind = "done" + description = "This is a done embed" + embed = mkembed(kind, description) + assert embed.title == "Done" + assert embed.description == description + assert embed.color == discord.Color.green() + + # Tests that the function creates an embed with "error" kind and the provided description + def test_embed_with_error_kind_and_description(self): + kind = "error" + description = "This is an error embed" + embed = mkembed(kind, description) + assert embed.title == "Error" + assert embed.description == description + assert embed.color == discord.Color.red() + + # Tests that the function creates an embed with "info" kind and the provided description + def test_embed_with_info_kind_and_description(self): + kind = "info" + description = "This is an info embed" + embed = mkembed(kind, description) + assert embed.title == "Info" + assert embed.description == description + assert embed.color == discord.Color.blue() + + # Tests that the function creates an embed with a custom title and the provided fields + def test_embed_with_custom_title_and_fields(self): + kind = "done" + description = "This is a done embed" + title = "Custom Title" + fields = {"Field 1": "Value 1", "Field 2": "Value 2"} + embed = mkembed(kind, description, title=title, **fields) + assert embed.title == title + assert embed.description == description + assert embed.color == discord.Color.green() + for name, value in fields.items(): + assert any( + field.name == name and field.value == value for field in embed.fields + ) + + # Tests that the function raises a ValueError when an invalid kind is provided + def test_embed_with_invalid_kind(self): + kind = "invalid" + description = "This is an invalid kind" + with pytest.raises(ValueError): + mkembed(kind, description) diff --git a/util/__init__.py b/util/__init__.py index 01024aa..9a06abb 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -1,21 +1,29 @@ import discord guilds = [] +MAX_MESSAGE_LENGTH = 2000 + + +discord_color_mapping = { + "done": discord.Color.green(), + "error": discord.Color.red(), + "info": discord.Color.blue(), +} def mkembed(kind: str, description: str, **kwargs) -> discord.Embed: - """Creates a discordpy Embed with some sane defaults. "Kind" must be "done", "error", or "info".""" - kindmap = { - "done": discord.Color.green(), - "error": discord.Color.red(), - "info": discord.Color.blue(), - } - if kind not in kindmap: - raise ValueError(f"kind must be one of {kindmap}") + """Creates a discord.py rich embed with sane defaults + :param kind: The kind of embed to create. Must be one of "done", "error", or "info". + :param description: The description of the embed. + :param kwargs: Additional key-value pairs to add as fields to the embed. + :return: A discord.Embed object representing the specified embed. + """ + if kind not in discord_color_mapping: + raise ValueError(f"kind must be one of {discord_color_mapping.keys()}") e = discord.Embed( - title=kwargs.pop("title", None) or kind.capitalize(), + title=kwargs.pop("title", kind.capitalize()), description=description, - color=kindmap[kind], + color=discord_color_mapping[kind], ) for k, v in kwargs.items(): e.add_field(name=k, value=v) @@ -27,12 +35,28 @@ def has_role(user: discord.Member, rolename: str) -> bool: def has_roles(user: discord.Member, roles: list[str]) -> bool: - return any( - has_role(user, rolestr) - for rolestr in roles - ) + return any(has_role(user, rolestr) for rolestr in roles) def update_guilds(guildlist: list): global guilds guilds = guildlist + + +def split_content(content: str): + """ + Split the given content string into chunks that fit within Discord's max message length. + + :param content: The content to split. + :return: A generator that yields each chunk of the split content. + """ + + while content: + split_index = min(len(content), MAX_MESSAGE_LENGTH) + newline_index = content[:split_index].rfind("\n") + + if newline_index != -1 and len(content) > MAX_MESSAGE_LENGTH: + split_index = newline_index + + chunk, content = content[:split_index], content[split_index:].lstrip("\n") + yield chunk diff --git a/util/chatgpt.py b/util/chatgpt.py index f8bbe80..1fd7782 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -1,20 +1,32 @@ -from collections import namedtuple +from dataclasses import dataclass from datetime import datetime, timedelta from hashlib import sha256 -from typing import List, Dict, Optional +from typing import List, Optional, TypedDict, Literal from enum import Flag, auto from util.souls import Soul, SOUL_PROMPT import tiktoken -Model = namedtuple( - "Model", "model max_tokens temperature", defaults=("gpt-4", 512, 0.5) -) + +class ConversationLine(TypedDict): + role: Literal["user", "system", "assistant"] + content: str class UserConfig(Flag): SHOWSTATS = auto() + TELEPATHY = auto() + NAMESUFFIX = auto() + TERSEWARNINGS = auto() + + +@dataclass +class Model: + model: str = "gpt-3.5-turbo" + max_tokens: int = 768 + temperature: float = 0.5 + max_context: int = 4097 class GPTUser: @@ -26,52 +38,45 @@ class GPTUser: "last", "staleseen", "_soul", - "telepathy", - "model", + "_model", "config", "_encoding", + "_conversation_len", ] id: int name: str idhash: str - _conversation: List[Dict[str, str]] + _conversation: List[ConversationLine] last: datetime - stale: bool staleseen: bool _soul: Optional[Soul] - telepathy: bool - model: Model + _model: Model config: UserConfig _encoding: tiktoken.Encoding + _conversation_len: int - # noinspection PyArgumentList - def __init__( - self, - uid: int, - uname: str, - sysprompt: str, - suffix: bool = True, - model: Model = Model(), - ): + def __init__(self, uid: int, uname: str, sysprompt: str, model: Model = Model()): self.id = uid self.name = uname self.idhash = sha256(str(uid).encode("utf-8")).hexdigest() self.staleseen = False + self.config = UserConfig.SHOWSTATS | UserConfig.NAMESUFFIX prompt_suffix = ( f" The user's name is {self.name} and it should be used wherever possible." ) self._conversation = [ { "role": "system", - "content": sysprompt + prompt_suffix if suffix else sysprompt, + "content": sysprompt + prompt_suffix + if self.config & UserConfig.NAMESUFFIX + else sysprompt, } ] self.last = datetime.utcnow() self._soul = None - self.telepathy = False - self.model = model - self.config = UserConfig.SHOWSTATS + self._model = model self._encoding = tiktoken.encoding_for_model(model.model) + self._conversation_len = self._calculate_conversation_len() @property def conversation(self): @@ -81,6 +86,9 @@ def conversation(self): def conversation(self, value): self._conversation = value self.last = datetime.utcnow() + # It would be more efficient to increment/decrement the length as needed, but we have too many use cases where + # we need to directly modify the conversation, so recalculating on every update is an intentional choice here. + self._conversation_len = self._calculate_conversation_len() def format_conversation(self, bot_name: str) -> str: """Returns a pretty-printed version of user's conversation history with system prompts removed""" @@ -102,7 +110,10 @@ def is_stale(self): @property def oversized(self): - return self._conversation_len + MAX_TOKENS >= MAX_LENGTH + """ + Returns if the current conversation is or is about to be too large to fit into the current model's context + """ + return self.conversation_len + self.model.max_tokens >= self.model.max_context @property def soul(self): @@ -115,35 +126,45 @@ def soul(self, new_soul: Soul): {"role": "system", "content": SOUL_PROMPT.format(**new_soul._asdict())} ] - @property - def _conversation_len(self): - if self.conversation: + def _calculate_conversation_len(self) -> int: + if self._conversation: return sum( len(self._encoding.encode(entry["content"])) - for entry in self.conversation + for entry in self._conversation ) else: return 0 - def push_conversation(self, utterance: dict[str, str], copy=False): - """Append the given line of dialogue to this conversation""" + def push_conversation(self, utterance: ConversationLine, copy=False): + """Append the given line of dialogue to this user's conversation""" if copy: self._conversation.insert(-1, utterance) else: self._conversation.append(utterance) - self.conversation = self._conversation # Trigger the setter + self._conversation_len += len(self._encoding.encode(utterance["content"])) - def pop_conversation(self, index: int = -1): - """Pop lines of dialogue from this conversation""" - p = self._conversation.pop(index) - self.conversation = self._conversation # Trigger the setter - return p + def pop_conversation(self, index: int = -1) -> ConversationLine: + """Pop lines of dialogue from this user's conversation""" + popped_item = self._conversation.pop(index) + self._conversation_len -= len(self._encoding.encode(popped_item["content"])) + return popped_item + + @property + def conversation_len(self): + """Return the length of this user's conversation in tokens""" + return self._conversation_len def freshen(self): """Clear the stale seen flag and set the last message time to now""" self.staleseen = False self.last = datetime.utcnow() - -MAX_LENGTH = 4097 -MAX_TOKENS = 512 + @property + def model(self): + return self._model + + @model.setter + def model(self, new_model: Model): + self._encoding = tiktoken.encoding_for_model(new_model.model) + self._model = new_model + self._conversation_len = self._calculate_conversation_len() From b1148da49fe1f7b8b457c2326cad0f1a49e4b7e7 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 28 Aug 2023 10:50:56 -0500 Subject: [PATCH 06/19] c.chatgpt: Add toggle_flags command This commit adds a new command `toggle_flags` to the `ChatGPT` cog. The `toggle_flags` command allows users to toggle their flags on or off. It takes a flag as an argument and toggles it accordingly. If the flag is already set, it unsets it, and if the flag is not set, it sets it. The updated code also includes error handling for unknown flags and updates the user's configuration accordingly. Co-authored-by: AI Assistant (JetBrains) --- cogs/chatgpt.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 33fed9b..a095dce 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -448,6 +448,38 @@ async def display_help(self, ctx: discord.ApplicationContext): ) await ctx.respond(embed=help_embed, ephemeral=True) + @gpt.command( + name="toggle_flags", + description="Toggles user flags on/off", + guild_ids=util.guilds, + ) + async def toggle_flags( + self, + ctx, + flag: str = Option( + description="The flag to toggle", + choices=UserConfig.__members__.keys(), + required=True, + ), + ): + gu = self.get_user_from_context(ctx) + + try: + flag_to_toggle = UserConfig[flag.upper()] + except KeyError: + await ctx.respond(f"Unknown flag: {flag}", ephemeral=True) + return + + if gu.config & flag_to_toggle: + gu.config &= ~flag_to_toggle # If the flag is set, unset it + else: + gu.config |= flag_to_toggle # If the flag is not set, set it + + self.users[gu.id] = gu + await ctx.respond( + f"{flag} has been set {bool(gu.config & flag_to_toggle)}.", ephemeral=True + ) + def setup(bot): bot.add_cog(ChatGPT(bot)) From ff7a3521130fb48bb1590d3d3bb2f4d677bffec4 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 28 Aug 2023 11:04:23 -0500 Subject: [PATCH 07/19] Update config.yml.example - Commented out the "cogs.chatgpt" module - Added a new guild ID under "guilds" - Commented out the specific configuration for Bonk in a particular server - Updated the default configuration for ChatGPT with a new model name and system prompt --- config.yml.example | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/config.yml.example b/config.yml.example index 6404db0..e5d076f 100644 --- a/config.yml.example +++ b/config.yml.example @@ -8,11 +8,13 @@ system: - cogs.yoink - cogs.ftime - cogs.roller - - cogs.chatgpt + #- cogs.chatgpt #- cogs.roleconcat #- cogs.bonk admin_roles: - Moderator + guilds: + - Insert your server ID here RandomNowPlaying: intervalmin: 60 intervalmax: 900 @@ -23,13 +25,18 @@ RandomNowPlaying: - Megaman Battle Network Bonk: - 709655247357739048: - channel: 778310784450691142 - sticker: 943515690235752459 +# 709655247357739048: +# channel: 778310784450691142 +# sticker: 943515690235752459 ChatGPT: api_key: 0 - model_name: gpt-3.5-turbo - system_prompt: "You are a helpful assistant." + default: + model_name: gpt-3.5-turbo + system_prompt: You are a helpful assistant +# 709655247357739048: +# model_name: gpt-4 +# system_prompt: + #sentry: # init_url: \ No newline at end of file From f545d3322245f864a40b30c81627c506a17d0c79 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 28 Aug 2023 11:08:43 -0500 Subject: [PATCH 08/19] Update dependencies, add coverage to dev packages --- Pipfile | 1 + Pipfile.lock | 247 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 218 insertions(+), 30 deletions(-) diff --git a/Pipfile b/Pipfile index 8c6ecc6..868cbdd 100644 --- a/Pipfile +++ b/Pipfile @@ -17,6 +17,7 @@ pytest = "*" pytest-mock = "*" pytest-asyncio = "*" black = "*" +coverage = "*" [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index ebdbbee..074dd59 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7c4ab4e9a29f7e511f4a2c9668d538e230d2e53220642808d93cbb96de82520a" + "sha256": "f5c28efa800439dadb5db6073064548947ec51263ffc535bd98ae78f2f5ec2e0" }, "pipfile-spec": 6, "requires": { @@ -119,11 +119,11 @@ }, "async-timeout": { "hashes": [ - "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15", - "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c" + "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", + "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028" ], - "markers": "python_version >= '3.6'", - "version": "==4.0.2" + "markers": "python_version >= '3.7'", + "version": "==4.0.3" }, "attrs": { "hashes": [ @@ -394,11 +394,11 @@ }, "openai": { "hashes": [ - "sha256:2483095c7db1eee274cebac79e315a986c4e55207bb4fa7b82d185b3a2ed9536", - "sha256:e0a7c2f7da26bdbe5354b03c6d4b82a2f34bd4458c7a17ae1a7092c3e397e03c" + "sha256:6a3cf8e276d1a6262b50562fbc0cba7967cfebb78ed827d375986b48fdad6475", + "sha256:b687761c82f5ebb6f61efc791b2083d2d068277b94802d4d1369efe39851813d" ], "index": "pypi", - "version": "==0.27.8" + "version": "==0.27.9" }, "py-cord": { "hashes": [ @@ -454,6 +454,100 @@ "index": "pypi", "version": "==6.0.1" }, + "regex": { + "hashes": [ + "sha256:0085da0f6c6393428bf0d9c08d8b1874d805bb55e17cb1dfa5ddb7cfb11140bf", + "sha256:06c57e14ac723b04458df5956cfb7e2d9caa6e9d353c0b4c7d5d54fcb1325c46", + "sha256:09b7f4c66aa9d1522b06e31a54f15581c37286237208df1345108fcf4e050c18", + "sha256:0c59122ceccb905a941fb23b087b8eafc5290bf983ebcb14d2301febcbe199c7", + "sha256:1005c60ed7037be0d9dea1f9c53cc42f836188227366370867222bda4c3c6bd7", + "sha256:14898830f0a0eb67cae2bbbc787c1a7d6e34ecc06fbd39d3af5fe29a4468e2c9", + "sha256:14dc6f2d88192a67d708341f3085df6a4f5a0c7b03dec08d763ca2cd86e9f559", + "sha256:1e7d84d64c84ad97bf06f3c8cb5e48941f135ace28f450d86af6b6512f1c9a71", + "sha256:2162ae2eb8b079622176a81b65d486ba50b888271302190870b8cc488587d280", + "sha256:22283c769a7b01c8ac355d5be0715bf6929b6267619505e289f792b01304d898", + "sha256:239c3c2a339d3b3ddd51c2daef10874410917cd2b998f043c13e2084cb191684", + "sha256:293352710172239bf579c90a9864d0df57340b6fd21272345222fb6371bf82b3", + "sha256:2ae54a338191e1356253e7883d9d19f8679b6143703086245fb14d1f20196be9", + "sha256:2e73e5243af12d9cd6a9d6a45a43570dbe2e5b1cdfc862f5ae2b031e44dd95a8", + "sha256:2e9216e0d2cdce7dbc9be48cb3eacb962740a09b011a116fd7af8c832ab116ca", + "sha256:3026cbcf11d79095a32d9a13bbc572a458727bd5b1ca332df4a79faecd45281c", + "sha256:3611576aff55918af2697410ff0293d6071b7e00f4b09e005d614686ac4cd57c", + "sha256:3ae646c35cb9f820491760ac62c25b6d6b496757fda2d51be429e0e7b67ae0ab", + "sha256:3b8e6ea6be6d64104d8e9afc34c151926f8182f84e7ac290a93925c0db004bfd", + "sha256:3d370ff652323c5307d9c8e4c62efd1956fb08051b0e9210212bc51168b4ff56", + "sha256:3f7454aa427b8ab9101f3787eb178057c5250478e39b99540cfc2b889c7d0586", + "sha256:40f029d73b10fac448c73d6eb33d57b34607f40116e9f6e9f0d32e9229b147d7", + "sha256:423adfa872b4908843ac3e7a30f957f5d5282944b81ca0a3b8a7ccbbfaa06103", + "sha256:4873ef92e03a4309b3ccd8281454801b291b689f6ad45ef8c3658b6fa761d7ac", + "sha256:48c640b99213643d141550326f34f0502fedb1798adb3c9eb79650b1ecb2f177", + "sha256:4ae594c66f4a7e1ea67232a0846649a7c94c188d6c071ac0210c3e86a5f92109", + "sha256:4b694430b3f00eb02c594ff5a16db30e054c1b9589a043fe9174584c6efa8033", + "sha256:51d8ea2a3a1a8fe4f67de21b8b93757005213e8ac3917567872f2865185fa7fb", + "sha256:54de2619f5ea58474f2ac211ceea6b615af2d7e4306220d4f3fe690c91988a61", + "sha256:551ad543fa19e94943c5b2cebc54c73353ffff08228ee5f3376bd27b3d5b9800", + "sha256:5543c055d8ec7801901e1193a51570643d6a6ab8751b1f7dd9af71af467538bb", + "sha256:5cd9cd7170459b9223c5e592ac036e0704bee765706445c353d96f2890e816c8", + "sha256:5ec4b3f0aebbbe2fc0134ee30a791af522a92ad9f164858805a77442d7d18570", + "sha256:67ecd894e56a0c6108ec5ab1d8fa8418ec0cff45844a855966b875d1039a2e34", + "sha256:6ab2ed84bf0137927846b37e882745a827458689eb969028af8032b1b3dac78e", + "sha256:704f63b774218207b8ccc6c47fcef5340741e5d839d11d606f70af93ee78e4d4", + "sha256:7098c524ba9f20717a56a8d551d2ed491ea89cbf37e540759ed3b776a4f8d6eb", + "sha256:7aed90a72fc3654fba9bc4b7f851571dcc368120432ad68b226bd593f3f6c0b7", + "sha256:7ce606c14bb195b0e5108544b540e2c5faed6843367e4ab3deb5c6aa5e681208", + "sha256:7eb95fe8222932c10d4436e7a6f7c99991e3fdd9f36c949eff16a69246dee2dc", + "sha256:80b80b889cb767cc47f31d2b2f3dec2db8126fbcd0cff31b3925b4dc6609dcdb", + "sha256:82cd0a69cd28f6cc3789cc6adeb1027f79526b1ab50b1f6062bbc3a0ccb2dbc3", + "sha256:83215147121e15d5f3a45d99abeed9cf1fe16869d5c233b08c56cdf75f43a504", + "sha256:88900f521c645f784260a8d346e12a1590f79e96403971241e64c3a265c8ecdb", + "sha256:91129ff1bb0619bc1f4ad19485718cc623a2dc433dff95baadbf89405c7f6b57", + "sha256:920974009fb37b20d32afcdf0227a2e707eb83fe418713f7a8b7de038b870d0b", + "sha256:9233ac249b354c54146e392e8a451e465dd2d967fc773690811d3a8c240ac601", + "sha256:941460db8fe3bd613db52f05259c9336f5a47ccae7d7def44cc277184030a116", + "sha256:942f8b1f3b223638b02df7df79140646c03938d488fbfb771824f3d05fc083a8", + "sha256:964b16dcc10c79a4a2be9f1273fcc2684a9eedb3906439720598029a797b46e6", + "sha256:9691a549c19c22d26a4f3b948071e93517bdf86e41b81d8c6ac8a964bb71e5a6", + "sha256:96979d753b1dc3b2169003e1854dc67bfc86edf93c01e84757927f810b8c3c93", + "sha256:987b9ac04d0b38ef4f89fbc035e84a7efad9cdd5f1e29024f9289182c8d99e09", + "sha256:988631b9d78b546e284478c2ec15c8a85960e262e247b35ca5eaf7ee22f6050a", + "sha256:9a96edd79661e93327cfeac4edec72a4046e14550a1d22aa0dd2e3ca52aec921", + "sha256:9b7408511fca48a82a119d78a77c2f5eb1b22fe88b0d2450ed0756d194fe7a9a", + "sha256:9dd6082f4e2aec9b6a0927202c85bc1b09dcab113f97265127c1dc20e2e32495", + "sha256:a2ad5add903eb7cdde2b7c64aaca405f3957ab34f16594d2b78d53b8b1a6a7d6", + "sha256:a8c65c17aed7e15a0c824cdc63a6b104dfc530f6fa8cb6ac51c437af52b481c7", + "sha256:aadf28046e77a72f30dcc1ab185639e8de7f4104b8cb5c6dfa5d8ed860e57236", + "sha256:b076da1ed19dc37788f6a934c60adf97bd02c7eea461b73730513921a85d4235", + "sha256:b2aeab3895d778155054abea5238d0eb9a72e9242bd4b43f42fd911ef9a13470", + "sha256:b82edc98d107cbc7357da7a5a695901b47d6eb0420e587256ba3ad24b80b7d0b", + "sha256:b8a0ccc8f2698f120e9e5742f4b38dc944c38744d4bdfc427616f3a163dd9de5", + "sha256:b993b6f524d1e274a5062488a43e3f9f8764ee9745ccd8e8193df743dbe5ee61", + "sha256:bb34d1605f96a245fc39790a117ac1bac8de84ab7691637b26ab2c5efb8f228c", + "sha256:bd3366aceedf274f765a3a4bc95d6cd97b130d1dda524d8f25225d14123c01db", + "sha256:c12f6f67495ea05c3d542d119d270007090bad5b843f642d418eb601ec0fa7be", + "sha256:c662a4cbdd6280ee56f841f14620787215a171c4e2d1744c9528bed8f5816c96", + "sha256:c884d1a59e69e03b93cf0dfee8794c63d7de0ee8f7ffb76e5f75be8131b6400a", + "sha256:ca339088839582d01654e6f83a637a4b8194d0960477b9769d2ff2cfa0fa36d2", + "sha256:cd2b6c5dfe0929b6c23dde9624483380b170b6e34ed79054ad131b20203a1a63", + "sha256:ce0f9fbe7d295f9922c0424a3637b88c6c472b75eafeaff6f910494a1fa719ef", + "sha256:cf0633e4a1b667bfe0bb10b5e53fe0d5f34a6243ea2530eb342491f1adf4f739", + "sha256:cf9273e96f3ee2ac89ffcb17627a78f78e7516b08f94dc435844ae72576a276e", + "sha256:d909b5a3fff619dc7e48b6b1bedc2f30ec43033ba7af32f936c10839e81b9217", + "sha256:d9b6627408021452dcd0d2cdf8da0534e19d93d070bfa8b6b4176f99711e7f90", + "sha256:de35342190deb7b866ad6ba5cbcccb2d22c0487ee0cbb251efef0843d705f0d4", + "sha256:e51c80c168074faa793685656c38eb7a06cbad7774c8cbc3ea05552d615393d8", + "sha256:e6bd1e9b95bc5614a7a9c9c44fde9539cba1c823b43a9f7bc11266446dd568e3", + "sha256:e7a9aaa5a1267125eef22cef3b63484c3241aaec6f48949b366d26c7250e0357", + "sha256:e951d1a8e9963ea51efd7f150450803e3b95db5939f994ad3d5edac2b6f6e2b4", + "sha256:e9941a4ada58f6218694f382e43fdd256e97615db9da135e77359da257a7168b", + "sha256:f0640913d2c1044d97e30d7c41728195fc37e54d190c5385eacb52115127b882", + "sha256:f0ccf3e01afeb412a1a9993049cb160d0352dba635bbca7762b2dc722aa5742a", + "sha256:f2181c20ef18747d5f4a7ea513e09ea03bdd50884a11ce46066bb90fe4213675", + "sha256:f2200e00b62568cfd920127782c61bc1c546062a879cdc741cfcc6976668dfcf", + "sha256:fcbdc5f2b0f1cd0f6a56cdb46fe41d2cce1e644e3b68832f3eeebc5fb0f7712e" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.8.8" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -464,11 +558,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:6bdb25bd9092478d3a817cb0d01fa99e296aea34d404eac3ca0037faa5c2aa0a", - "sha256:dcd88c68aa64dae715311b5ede6502fd684f70d00a7cd4858118f0ba3153a3ae" + "sha256:3e17215d8006612e2df02b0e73115eb8376c37e3f586d8436fa41644e605074d", + "sha256:a99ee105384788c3f228726a88baf515fe7b5f1d2d0f215a03d194369f158df7" ], "index": "pypi", - "version": "==1.28.1" + "version": "==1.29.2" }, "six": { "hashes": [ @@ -478,13 +572,48 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "tiktoken": { + "hashes": [ + "sha256:00d662de1e7986d129139faf15e6a6ee7665ee103440769b8dedf3e7ba6ac37f", + "sha256:08efa59468dbe23ed038c28893e2a7158d8c211c3dd07f2bbc9a30e012512f1d", + "sha256:176cad7f053d2cc82ce7e2a7c883ccc6971840a4b5276740d0b732a2b2011f8a", + "sha256:1b6bce7c68aa765f666474c7c11a7aebda3816b58ecafb209afa59c799b0dd2d", + "sha256:1e8fa13cf9889d2c928b9e258e9dbbbf88ab02016e4236aae76e3b4f82dd8288", + "sha256:2ca30367ad750ee7d42fe80079d3092bd35bb266be7882b79c3bd159b39a17b0", + "sha256:329f548a821a2f339adc9fbcfd9fc12602e4b3f8598df5593cfc09839e9ae5e4", + "sha256:3dc3df19ddec79435bb2a94ee46f4b9560d0299c23520803d851008445671197", + "sha256:450d504892b3ac80207700266ee87c932df8efea54e05cefe8613edc963c1285", + "sha256:4d980fa066e962ef0f4dad0222e63a484c0c993c7a47c7dafda844ca5aded1f3", + "sha256:55e251b1da3c293432179cf7c452cfa35562da286786be5a8b1ee3405c2b0dd2", + "sha256:5727d852ead18b7927b8adf558a6f913a15c7766725b23dbe21d22e243041b28", + "sha256:59b20a819969735b48161ced9b92f05dc4519c17be4015cfb73b65270a243620", + "sha256:5a73286c35899ca51d8d764bc0b4d60838627ce193acb60cc88aea60bddec4fd", + "sha256:64e1091c7103100d5e2c6ea706f0ec9cd6dc313e6fe7775ef777f40d8c20811e", + "sha256:8d1d97f83697ff44466c6bef5d35b6bcdb51e0125829a9c0ed1e6e39fb9a08fb", + "sha256:9c15d9955cc18d0d7ffcc9c03dc51167aedae98542238b54a2e659bd25fe77ed", + "sha256:9c6dd439e878172dc163fced3bc7b19b9ab549c271b257599f55afc3a6a5edef", + "sha256:9ec161e40ed44e4210d3b31e2ff426b4a55e8254f1023e5d2595cb60044f8ea6", + "sha256:b1a038cee487931a5caaef0a2e8520e645508cde21717eacc9af3fbda097d8bb", + "sha256:ba16698c42aad8190e746cd82f6a06769ac7edd415d62ba027ea1d99d958ed93", + "sha256:bb2341836b725c60d0ab3c84970b9b5f68d4b733a7bcb80fb25967e5addb9920", + "sha256:c06cd92b09eb0404cedce3702fa866bf0d00e399439dad3f10288ddc31045422", + "sha256:c835d0ee1f84a5aa04921717754eadbc0f0a56cf613f78dfc1cf9ad35f6c3fea", + "sha256:d0394967d2236a60fd0aacef26646b53636423cc9c70c32f7c5124ebe86f3093", + "sha256:dae2af6f03ecba5f679449fa66ed96585b2fa6accb7fd57d9649e9e398a94f44", + "sha256:e063b988b8ba8b66d6cc2026d937557437e79258095f52eaecfafb18a0a10c03", + "sha256:e87751b54eb7bca580126353a9cf17a8a8eaadd44edaac0e01123e1513a33281", + "sha256:f3020350685e009053829c1168703c346fb32c70c57d828ca3742558e94827a9" + ], + "index": "pypi", + "version": "==0.4.0" + }, "tqdm": { "hashes": [ - "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", - "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" + "sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386", + "sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7" ], "markers": "python_version >= '3.7'", - "version": "==4.65.0" + "version": "==4.66.1" }, "typing-extensions": { "hashes": [ @@ -614,11 +743,11 @@ }, "click": { "hashes": [ - "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd", - "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5" + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], "markers": "python_version >= '3.7'", - "version": "==8.1.6" + "version": "==8.1.7" }, "colorama": { "hashes": [ @@ -628,13 +757,71 @@ "markers": "platform_system == 'Windows'", "version": "==0.4.6" }, + "coverage": { + "hashes": [ + "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34", + "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e", + "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7", + "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b", + "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3", + "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985", + "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95", + "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2", + "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a", + "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74", + "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd", + "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af", + "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54", + "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865", + "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214", + "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54", + "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe", + "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0", + "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321", + "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446", + "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e", + "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527", + "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12", + "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f", + "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f", + "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84", + "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479", + "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e", + "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873", + "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70", + "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0", + "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977", + "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51", + "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28", + "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1", + "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254", + "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1", + "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd", + "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689", + "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d", + "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543", + "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9", + "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637", + "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071", + "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482", + "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1", + "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b", + "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5", + "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a", + "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393", + "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a", + "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba" + ], + "index": "pypi", + "version": "==7.3.0" + }, "exceptiongroup": { "hashes": [ - "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", - "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" + "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", + "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" ], "markers": "python_version < '3.11'", - "version": "==1.1.2" + "version": "==1.1.3" }, "iniconfig": { "hashes": [ @@ -662,27 +849,27 @@ }, "pathspec": { "hashes": [ - "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", - "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" + "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20", + "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3" ], "markers": "python_version >= '3.7'", - "version": "==0.11.1" + "version": "==0.11.2" }, "platformdirs": { "hashes": [ - "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421", - "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f" + "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", + "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" ], "markers": "python_version >= '3.7'", - "version": "==3.9.1" + "version": "==3.10.0" }, "pluggy": { "hashes": [ - "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", - "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" + "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", + "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" ], - "markers": "python_version >= '3.7'", - "version": "==1.2.0" + "markers": "python_version >= '3.8'", + "version": "==1.3.0" }, "pytest": { "hashes": [ From 5b3375a493c9b3a9df5abe42933cace2b0286bd4 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 28 Aug 2023 16:08:22 -0500 Subject: [PATCH 09/19] c.chatgpt: Update function parameters How did this ever work? --- cogs/chatgpt.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index a095dce..4365c6a 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -229,7 +229,8 @@ async def on_message(self, message: discord.Message): async def reset( self, ctx, - system_prompt: str = Option( + system_prompt: Option( + str, description="The system (initial) prompt for the new conversation", default=None, ), @@ -336,10 +337,11 @@ async def save_conversation(self, ctx): async def summarize_chat( self, ctx: discord.ApplicationContext, - num_messages: int = Option( - default=50, description="Number of messages to summarize" + num_messages: Option( + int, default=50, description="Number of messages to summarize" ), - prompt: str = Option( + prompt: Option( + str, description="Custom prompt to use for the summary (Actual chat is inserted after these words)", default=None, ), @@ -405,8 +407,8 @@ async def summarize_chat( async def load_core( self, ctx: discord.ApplicationContext, - core: discord.Option(str, autocomplete=util.souls.scan_cores, required=True), - telepathy: discord.Option(bool, default=True, description="Show thinking"), + core: Option(str, autocomplete=util.souls.scan_cores, required=True), + telepathy: Option(bool, default=True, description="Show thinking"), ): try: with open(f"cores/{core.split(' ')[0]}") as file: @@ -456,7 +458,8 @@ async def display_help(self, ctx: discord.ApplicationContext): async def toggle_flags( self, ctx, - flag: str = Option( + flag: Option( + str, description="The flag to toggle", choices=UserConfig.__members__.keys(), required=True, @@ -464,11 +467,7 @@ async def toggle_flags( ): gu = self.get_user_from_context(ctx) - try: - flag_to_toggle = UserConfig[flag.upper()] - except KeyError: - await ctx.respond(f"Unknown flag: {flag}", ephemeral=True) - return + flag_to_toggle = UserConfig[flag.upper()] if gu.config & flag_to_toggle: gu.config &= ~flag_to_toggle # If the flag is set, unset it From ba5fabe3dae70c7784a6d76b48e348c94619bfad Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Fri, 1 Sep 2023 07:54:07 -0500 Subject: [PATCH 10/19] c.chatgpt: add persistence, prompt descriptions, translate command - Added persistence functionality to store user data in a database - Implemented prompt descriptions for system prompts - Added a new "translate" command for translating text between languages --- cogs/chatgpt.py | 124 ++++++++++++++++++++++++++------ tests/test_chatgpt_commands.py | 2 +- tests/test_chatgpt_gptuser.py | 12 ++-- tests/test_chatgpt_onmessage.py | 13 ++-- util/chatgpt.py | 56 +++++++++++---- 5 files changed, 157 insertions(+), 50 deletions(-) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 4365c6a..8769638 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -1,4 +1,5 @@ import io +from contextlib import contextmanager from typing import List, Optional, Dict import discord @@ -7,12 +8,18 @@ from openai import ChatCompletion from discord.commands import SlashCommandGroup, Option from discord.ext import commands +from blitzdb import Document, FileBackend import util -from util.chatgpt import GPTUser, UserConfig, ConversationLine, Model +from util.chatgpt import GPTUser, UserConfig, ConversationLine, Model, DEFAULT_FLAGS from util.souls import Soul, REMEMBRANCE_PROMPT +class PersistentUser(Document): + class Meta(Document.Meta): + primary_key = "uid" + + class ChatGPT(commands.Cog): gpt = SlashCommandGroup("ai", "AI chatbot", guild_ids=util.guilds) @@ -20,9 +27,21 @@ def __init__(self, bot): self.bot = bot self.config = bot.config["ChatGPT"] self.users: Dict[int, GPTUser] = {} + self.backend = FileBackend("db") + self.backend.autocommit = True openai.api_key = self.config["api_key"] bot.logger.info("ChatGPT integration initialized") + @contextmanager + def get_persistent_userdata(self, userid: int) -> PersistentUser: + """Searches for a user's persistent data in the DB by id, returning it if found, or a new minimal set if not.""" + try: + pu = self.backend.get(PersistentUser, {"uid": userid}) + except PersistentUser.DoesNotExist: + pu = PersistentUser({"uid": userid, "config": DEFAULT_FLAGS.value}) + yield pu + self.backend.save(pu) + async def send_to_chatgpt(self, user, conversation=None) -> Optional[str]: """Sends a conversation to OpenAI for chat completion and returns what the model said in reply. The model details will be read from the provided GPTUser. If a conversation is provided, it will be sent to the model. @@ -30,7 +49,7 @@ async def send_to_chatgpt(self, user, conversation=None) -> Optional[str]: :param conversation: A specific conversation to be replied to, rather than the user's conversation :type conversation: List[ConversationLine] or None :param GPTUser user: The user object associated with this conversation - :return: + :return: The response from the model, or none if there was a problem """ try: response = await ChatCompletion.acreate( @@ -59,23 +78,28 @@ def get_user_from_context(self, context, force_new=False, **kwargs) -> GPTUser: :type context: discord.Message or discord.ApplicationContext :param force_new: If true, force creation of a new user object even if one already exists in `self.users` :param kwargs: - sysprompt str: Overrides the system prompt for a new user. Implies `force_new` + sysprompt str: Overrides the system prompt for a new user. + promptinfo str: A short description of the provided system prompt """ uid = context.author.id if context.guild: - config = self.config.get(context.guild.id, self.config["default"]) + server_config = self.config.get(context.guild.id, self.config["default"]) else: - config = self.config["default"] - if uid in self.users and not force_new: - return self.users[uid] - else: - sysprompt = kwargs.pop("sysprompt", config["system_prompt"]) - return GPTUser( - uid=uid, - uname=context.author.display_name, - sysprompt=sysprompt or config["system_prompt"], - model=Model(config["model_name"]), - ) + server_config = self.config["default"] + sysprompt = kwargs.pop("sysprompt", None) + promptinfo = kwargs.pop("promptinfo", None) + gu = self.users.get(uid) + if (not gu) or force_new or sysprompt: + with self.get_persistent_userdata(uid) as pu: + gu = GPTUser( + uid=uid, + uname=context.author.display_name, + sysprompt=sysprompt or server_config["system_prompt"], + prompt_info=promptinfo, + model=Model(server_config["model_name"]), + config=UserConfig(pu.config), + ) + return gu def should_reply(self, message: discord.Message) -> bool: """Determine whether the given message should be replied to. TL;DR: DON'T reply to system messages, @@ -210,8 +234,9 @@ async def on_message(self, message: discord.Message): if gu.config & UserConfig.SHOWSTATS: stats = ( f"\n\n*📏{gu.conversation_len}/{gu.model.max_context}{'(❗)' if gu.oversized else ''} " - f"{'👼' + gu.soul.name if gu.soul else ''} " + f"{'👼' + gu.soul.name + ' ' if gu.soul else ''}" f"🗣️{gu.model.model} " + f"📝{'Default' if not gu.prompt_info else gu.prompt_info} " f"*" ) else: @@ -235,7 +260,12 @@ async def reset( default=None, ), ): - gu = self.get_user_from_context(ctx, True, sysprompt=system_prompt) + gu = self.get_user_from_context( + ctx, + True, + sysprompt=system_prompt, + promptinfo="Custom" if system_prompt else None, + ) user_id = ctx.author.id self.users[user_id] = gu response = "Your conversation history has been reset." @@ -450,6 +480,7 @@ async def display_help(self, ctx: discord.ApplicationContext): ) await ctx.respond(embed=help_embed, ephemeral=True) + # noinspection PyTypeHints @gpt.command( name="toggle_flags", description="Toggles user flags on/off", @@ -466,8 +497,7 @@ async def toggle_flags( ), ): gu = self.get_user_from_context(ctx) - - flag_to_toggle = UserConfig[flag.upper()] + flag_to_toggle = UserConfig[flag] if gu.config & flag_to_toggle: gu.config &= ~flag_to_toggle # If the flag is set, unset it @@ -475,9 +505,63 @@ async def toggle_flags( gu.config |= flag_to_toggle # If the flag is not set, set it self.users[gu.id] = gu + with self.get_persistent_userdata(gu.id) as pu: + pu.config = gu.config.value await ctx.respond( - f"{flag} has been set {bool(gu.config & flag_to_toggle)}.", ephemeral=True + f"{flag} has been {'enabled' if gu.config & flag_to_toggle else 'disaled'}.", + ephemeral=True, + ) + + @gpt.command( + name="show_flags", + description="Show your AI settings", + guild_ids=util.guilds, + ) + async def show_flags(self, ctx): + gu = self.get_user_from_context(ctx) + out = f"```md\n# AI settings for {gu.name}:\n" + for k in UserConfig.__members__.keys(): + f = UserConfig[k] # FOO rather than UserConfig.FOO + out += f"{f.name}: {'enabled' if gu.config & f else 'disaled'}\n" + out += "```" + await ctx.respond(out, ephemeral=True) + + @gpt.command( + name="translate", + description="Translate across languages", + guild_ids=util.guilds, + ) + async def translate( + self, + ctx, + to_language: Option(str, "The language to translate to", required=True), + text: Option(str, "The text to be translated", required=True), + keep_going: Option( + bool, + "Stay in translation mode after translating line (warning: resets conversation)", + default=False, + ), + ): + await ctx.defer() + prompt = ( + f"You are an expert translator, fluent in both English and {to_language}. " + f"Whenever the user says something, repeat it back to them, and then repeat it again in the {to_language} language." ) + gu = self.get_user_from_context( + ctx, + True, + sysprompt=prompt, + promptinfo=f"Translator: English -> {to_language}", + ) + gu.push_conversation({"role": "user", "content": text}) + async with ctx.channel.typing(): + response = await self.send_to_chatgpt(gu) + if not response: + response = "Sorry, could not communicate with OpenAI. Please try again." + gu.pop_conversation() + await ctx.respond(response) + if keep_going: + self.users[ctx.author.id] = gu def setup(bot): diff --git a/tests/test_chatgpt_commands.py b/tests/test_chatgpt_commands.py index 9cbebee..69c28d3 100644 --- a/tests/test_chatgpt_commands.py +++ b/tests/test_chatgpt_commands.py @@ -30,7 +30,7 @@ def bot(self): @pytest.fixture def cog(self, bot): cog = ChatGPT(bot) - cog.users = {123: GPTUser(123, "User", "My prompt")} + cog.users = {123: GPTUser(123, "User", "My prompt", None)} return cog @pytest.fixture diff --git a/tests/test_chatgpt_gptuser.py b/tests/test_chatgpt_gptuser.py index 701a431..8ac792e 100644 --- a/tests/test_chatgpt_gptuser.py +++ b/tests/test_chatgpt_gptuser.py @@ -8,14 +8,14 @@ class TestGPTUser: # Tests that a GPTUser object is created with the correct attributes def test_create_GPTUser_object(self): - user = GPTUser(1, "John", "Hello.") + user = GPTUser(1, "John", "Hello.", None) assert user.id == 1 assert user.name == "John" assert user.idhash == sha256(str(1).encode("utf-8")).hexdigest() assert user._conversation == [ { "role": "system", - "content": "Hello. The user's name is John and it should be used wherever possible.", + "content": "Hello.\nThe user's name is John and it should be used wherever possible.", } ] assert isinstance(user.last, datetime) @@ -25,7 +25,7 @@ def test_create_GPTUser_object(self): # Tests that the conversation history is properly formatted def test_format_conversation(self): - user = GPTUser(1, "John", "Hello") + user = GPTUser(1, "John", "Hello", None) user.push_conversation({"role": "user", "content": "Hi there"}) user.push_conversation({"role": "assistant", "content": "How can I help you?"}) formatted_conversation = user.format_conversation("Bot") @@ -34,7 +34,7 @@ def test_format_conversation(self): # Tests that a new soul can be assigned to a GPTUser object def test_assign_new_soul(self): - user = GPTUser(1, "John", "Hello") + user = GPTUser(1, "John", "Hello", None) soul = Soul("John", "short", "long", "plan") user.soul = soul assert user._soul == soul @@ -42,14 +42,14 @@ def test_assign_new_soul(self): # Tests that the is_stale property returns True when the last message was sent more than 6 hours ago def test_is_stale_property(self): - user = GPTUser(1, "John", "Hello") + user = GPTUser(1, "John", "Hello", None) assert user.is_stale is False user.last = datetime.utcnow() - timedelta(hours=7) assert user.is_stale is True # Tests that the oversized property returns True when the conversation history exceeds the maximum length def test_oversized_property(self): - user = GPTUser(1, "John", "Hello") + user = GPTUser(1, "John", "Hello", None) message = { "role": "user", "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit " * 50, diff --git a/tests/test_chatgpt_onmessage.py b/tests/test_chatgpt_onmessage.py index dbde79a..953c619 100644 --- a/tests/test_chatgpt_onmessage.py +++ b/tests/test_chatgpt_onmessage.py @@ -1,16 +1,13 @@ from unittest.mock import MagicMock import discord -from cogs import chatgpt - -# Dependencies: -# pip install pytest-mock import pytest from cogs.chatgpt import ChatGPT from util.chatgpt import GPTUser +# noinspection PyUnresolvedReferences class TestOnMessage: # Test that the method correctly handles the case when a user sends a message that should be replied to. @pytest.fixture @@ -24,7 +21,7 @@ def bot(self): @pytest.fixture def cog(self, bot): cog = ChatGPT(bot) - cog.users = {123: GPTUser(123, "User", "My prompt")} + cog.users = {123: GPTUser(123, "User", "My prompt", None)} return cog @pytest.mark.asyncio @@ -34,8 +31,8 @@ async def test_happy_path(self, mocker, cog): message.content = "Hello" message.channel.typing.return_value.__aenter__ = mocker.AsyncMock() message.channel.typing.return_value.__aexit__ = mocker.AsyncMock() - gu = GPTUser(123, "TestUser", "System Prompt") - gu.push_conversation({"role": "user", "content": "Hello"}) + gu = GPTUser(123, "TestUser", "System Prompt", None) + # gu.push_conversation({"role": "user", "content": "Hello"}) cog.should_reply = mocker.MagicMock(return_value=True) cog.copy_public_reply = mocker.MagicMock() cog.get_user_from_context = mocker.MagicMock(return_value=gu) @@ -51,5 +48,5 @@ async def test_happy_path(self, mocker, cog): cog.remove_bot_mention.assert_called_once_with("Hello") cog.send_to_chatgpt.assert_called_once_with(gu) cog.reply.assert_called_once_with( - message, "\nHi\n\n\n*📏20/4097 🗣️gpt-3.5-turbo *", None + message, "\nHi\n\n\n*📏20/4097 🗣️gpt-3.5-turbo 📝Default *", None ) diff --git a/util/chatgpt.py b/util/chatgpt.py index 1fd7782..7b927b7 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -21,6 +21,9 @@ class UserConfig(Flag): TERSEWARNINGS = auto() +DEFAULT_FLAGS = UserConfig.SHOWSTATS | UserConfig.NAMESUFFIX + + @dataclass class Model: model: str = "gpt-3.5-turbo" @@ -42,6 +45,7 @@ class GPTUser: "config", "_encoding", "_conversation_len", + "prompt_info", ] id: int name: str @@ -54,29 +58,39 @@ class GPTUser: config: UserConfig _encoding: tiktoken.Encoding _conversation_len: int - - def __init__(self, uid: int, uname: str, sysprompt: str, model: Model = Model()): + prompt_info: Optional[str] + + def __init__( + self, + uid: int, + uname: str, + sysprompt: str, + prompt_info: Optional[str], + model: Model = Model(), + config: UserConfig = DEFAULT_FLAGS, + ): + """ + :param config: A UserConfig bitfield. + :param uid: The unique ID of the user (Usually a Discord snowflake). + :param uname: The username of the user. + :param sysprompt: The system prompt to be used for conversation generation. + :param prompt_info: A very short description of the system prompt. + :param model: The model object to be used for conversation generation. + """ self.id = uid self.name = uname self.idhash = sha256(str(uid).encode("utf-8")).hexdigest() self.staleseen = False - self.config = UserConfig.SHOWSTATS | UserConfig.NAMESUFFIX - prompt_suffix = ( - f" The user's name is {self.name} and it should be used wherever possible." - ) - self._conversation = [ - { - "role": "system", - "content": sysprompt + prompt_suffix - if self.config & UserConfig.NAMESUFFIX - else sysprompt, - } - ] + self.config = config + self._conversation = [{"role": "system", "content": sysprompt}] self.last = datetime.utcnow() self._soul = None + self.prompt_info = prompt_info self._model = model self._encoding = tiktoken.encoding_for_model(model.model) self._conversation_len = self._calculate_conversation_len() + if UserConfig.NAMESUFFIX in config: + self._add_namesuffix() @property def conversation(self): @@ -103,7 +117,8 @@ def format_conversation(self, bot_name: str) -> str: return formatted_conversation @property - def is_stale(self): + def is_stale(self) -> bool: + """Check if the user conversation is stale (More than six hours old)""" current_time = datetime.utcnow() age = current_time - self.last return age > timedelta(hours=6) @@ -125,6 +140,7 @@ def soul(self, new_soul: Soul): self.conversation = [ {"role": "system", "content": SOUL_PROMPT.format(**new_soul._asdict())} ] + self.prompt_info = "Soul" def _calculate_conversation_len(self) -> int: if self._conversation: @@ -168,3 +184,13 @@ def model(self, new_model: Model): self._encoding = tiktoken.encoding_for_model(new_model.model) self._model = new_model self._conversation_len = self._calculate_conversation_len() + + def _add_namesuffix(self): + """Apply the user's name to the end of the first system prompt.""" + self.conversation[0][ + "content" + ] += ( + f"\nThe user's name is {self.name} and it should be used wherever possible." + ) + # This didn't trigger the setter for conversation, manually update length + self._conversation_len = self._calculate_conversation_len() From e371abf128e197277fd6a4ab5cc78956771407fe Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 4 Sep 2023 07:41:23 -0500 Subject: [PATCH 11/19] c.reminder: Add reminder functionality This commit adds the code for setting reminders in a Discord bot. The `Reminder` cog is created with commands to add and clear reminders. Reminders can be set for a specific time, with options to deliver them publicly or privately. Reminders are stored in a database using BlitzDB. A background task periodically checks for due reminders and sends them to the appropriate channels or users. Co-authored-by: ChatGPT https://chat.openai.com/share/df07e19e-fd72-4fcd-b3a6-60de92daab3f c.reminder: Add reminder functionality This commit adds the code for setting reminders in a Discord bot. The `Reminder` cog is created with commands to add and clear reminders. Reminders can be set for a specific time, with options to deliver them publicly or privately. Reminders are stored in a database using BlitzDB. A background task periodically checks for due reminders and sends them to the appropriate channels or users. Co-authored-by: ChatGPT https://chat.openai.com/share/df07e19e-fd72-4fcd-b3a6-60de92daab3f --- Pipfile | 2 + Pipfile.lock | 70 +++++++++++++++++++---- cogs/reminder.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 cogs/reminder.py diff --git a/Pipfile b/Pipfile index 868cbdd..03b1e91 100644 --- a/Pipfile +++ b/Pipfile @@ -11,6 +11,8 @@ PyYAML = "*" aiohttp = "*" openai = "*" tiktoken = "~=0.4.0" +dateparser = "*" +pytz = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 074dd59..8ff3434 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f5c28efa800439dadb5db6073064548947ec51263ffc535bd98ae78f2f5ec2e0" + "sha256": "a85729c5c2478c40ec48fe696bf7a69264d0b13457c7ba8d505210446cc17084" }, "pipfile-spec": 6, "requires": { @@ -237,6 +237,14 @@ "markers": "platform_system == 'Windows'", "version": "==0.4.6" }, + "dateparser": { + "hashes": [ + "sha256:070b29b5bbf4b1ec2cd51c96ea040dc68a614de703910a91ad1abba18f9f379f", + "sha256:86b8b7517efcc558f085a142cdb7620f0921543fcabdb538c8a4c4001d8178e3" + ], + "index": "pypi", + "version": "==1.1.8" + }, "frozenlist": { "hashes": [ "sha256:007df07a6e3eb3e33e9a1fe6a9db7af152bbd8a185f9aaa6ece10a3529e3e1c6", @@ -394,11 +402,11 @@ }, "openai": { "hashes": [ - "sha256:6a3cf8e276d1a6262b50562fbc0cba7967cfebb78ed827d375986b48fdad6475", - "sha256:b687761c82f5ebb6f61efc791b2083d2d068277b94802d4d1369efe39851813d" + "sha256:417b78c4c2864ba696aedaf1ccff77be1f04a581ab1739f0a56e0aae19e5a794", + "sha256:d207ece78469be5648eb87b825753282225155a29d0eec6e02013ddbf8c31c0c" ], "index": "pypi", - "version": "==0.27.9" + "version": "==0.28.0" }, "py-cord": { "hashes": [ @@ -408,9 +416,27 @@ "index": "pypi", "version": "==2.4.1" }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "pytz": { + "hashes": [ + "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", + "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb" + ], + "index": "pypi", + "version": "==2023.3" + }, "pyyaml": { "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", @@ -418,7 +444,10 @@ "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", @@ -426,9 +455,12 @@ "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", @@ -443,7 +475,9 @@ "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", @@ -558,11 +592,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:3e17215d8006612e2df02b0e73115eb8376c37e3f586d8436fa41644e605074d", - "sha256:a99ee105384788c3f228726a88baf515fe7b5f1d2d0f215a03d194369f158df7" + "sha256:2e53ad63f96bb9da6570ba2e755c267e529edcf58580a2c0d2a11ef26e1e678b", + "sha256:7dc873b87e1faf4d00614afd1058bfa1522942f33daef8a59f90de8ed75cd10c" ], "index": "pypi", - "version": "==1.29.2" + "version": "==1.30.0" }, "six": { "hashes": [ @@ -623,6 +657,22 @@ "markers": "python_version < '3.11'", "version": "==4.7.1" }, + "tzdata": { + "hashes": [ + "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a", + "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda" + ], + "markers": "platform_system == 'Windows'", + "version": "==2023.3" + }, + "tzlocal": { + "hashes": [ + "sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803", + "sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f" + ], + "markers": "python_version >= '3.7'", + "version": "==5.0.1" + }, "urllib3": { "hashes": [ "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11", @@ -873,11 +923,11 @@ }, "pytest": { "hashes": [ - "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32", - "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a" + "sha256:2f2301e797521b23e4d2585a0a3d7b5e50fdddaaf7e7d6773ea26ddb17c213ab", + "sha256:460c9a59b14e27c602eb5ece2e47bec99dc5fc5f6513cf924a7d03a578991b1f" ], "index": "pypi", - "version": "==7.4.0" + "version": "==7.4.1" }, "pytest-asyncio": { "hashes": [ diff --git a/cogs/reminder.py b/cogs/reminder.py new file mode 100644 index 0000000..ff46fc1 --- /dev/null +++ b/cogs/reminder.py @@ -0,0 +1,144 @@ +from datetime import datetime +from typing import List + +import dateparser +import discord +from blitzdb import Document, FileBackend +from discord import SlashCommandGroup, Option +from discord.ext import commands, tasks + +import util +from util import mkembed + + +class ReminderEntry(Document): + pass + + +class ReminderInteractedUser(Document): + pass + + +class Reminder(commands.Cog): + reminder = SlashCommandGroup( + "reminder", "Set reminders for yourself or publicly", guild_ids=util.guilds + ) + + def __init__(self, bot): + self.bot = bot + self.backend = FileBackend("db") + self.backend.autocommit = True + self.check_reminders.start() + bot.logger.info("Reminder ready") + + async def do_disclaimer(self, ctx: discord.ApplicationContext) -> bool: + # noinspection PyTypeChecker + user: discord.Member = ctx.author + interacted = self.backend.filter(ReminderInteractedUser, {"user_id": user.id}) + if not interacted: + try: + await user.send( + "Hey I have to let you know since you just set a reminder for the first time, reminders are a " + "best-effort service. Do not rely on it for anything critical or life-threatening. Discord " + "occasionally eats messages and glitches sometimes happen." + ) + except discord.Forbidden: + await ctx.respond( + "Private messages from server members seem to be disabled. Please enable this setting to receive " + "reminders. You will need to try setting your last reminder again.", + ephemeral=True, + ) + return False + new_user = ReminderInteractedUser({"user_id": user.id}) + self.backend.save(new_user) + return True + else: + return True + + async def fetch_reminders(self) -> List[ReminderEntry]: + """Retrieves a list of all reminders which are due to be delivered (has a UNIX timestamp in the past)""" + now_unix = int(datetime.utcnow().timestamp()) + return self.backend.filter(ReminderEntry, {"time": {"$lte": now_unix}}) + + async def send_reminders(self, reminders: List[ReminderEntry]): + for reminder in reminders: + channel = self.bot.get_channel(reminder["channel_id"]) + user = self.bot.get_user(reminder["user_id"]) + if reminder["location"] == "private": + await user.send( + f"On {reminder['created']} UTC you asked to be reminded: {reminder['text']}" + ) + else: + await channel.send( + f"On {reminder['created']} UTC, {user.mention} asked to be reminded: {reminder['text']}" + ) + self.backend.delete(reminder) + + @tasks.loop(seconds=10) + async def check_reminders(self): + reminders = await self.fetch_reminders() + await self.send_reminders(reminders) + + @reminder.command(name="add", guild_ids=util.guilds) + async def add( + self, + ctx: discord.ApplicationContext, + time: Option( + str, + "When to be reminded. Absolute with time zone (2020-01-01 15:00:00 UTC) or relative (in 5 minutes)", + required=True, + ), + text: str, + location: Option( + str, + "Deliver this reminder in public (this channel) or private DM", + choices=["public", "private"], + required=True, + ), + ): + """Adds a reminder""" + reminder_time = dateparser.parse(time, settings={"TIMEZONE": "UTC"}) + + if not reminder_time: + await ctx.respond( + embed=mkembed("error", "Could not understand the time format."), + ephemeral=True, + ) + return + + reminder_time_unix = int(reminder_time.timestamp()) + + if not await self.do_disclaimer(ctx): + return + + reminder = ReminderEntry( + { + "time": reminder_time_unix, + "created": datetime.utcnow(), + "text": text, + "user_id": ctx.author.id, + "channel_id": ctx.channel.id, + "location": location, + "nag": False, + } + ) + self.backend.save(reminder) + await ctx.respond( + embed=mkembed("done", f"Reminder set for {reminder_time} UTC"), + ephemeral=True, + ) + + @reminder.command(name="clear", guild_ids=util.guilds) + async def clear(self, ctx: discord.ApplicationContext): + """Removes all reminders""" + user_id = ctx.author.id + for reminder in self.backend.filter(ReminderEntry, {"user_id": user_id}): + self.backend.delete(reminder) + await ctx.respond( + embed=mkembed("done", "All your reminders have been cleared."), + ephemeral=True, + ) + + +def setup(bot): + bot.add_cog(Reminder(bot)) From 2a75fe9257a861ef8afc5f9202a79ef58484b6bc Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Mon, 4 Sep 2023 08:06:01 -0500 Subject: [PATCH 12/19] c.chatgpt: Add source language options to translation command This commit adds the ability to specify the language to translate from and to in the translation command. Now, users can provide both the source language and target language for accurate translations. The prompt message has been updated accordingly to reflect this change. --- cogs/chatgpt.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 8769638..010d044 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -534,6 +534,7 @@ async def show_flags(self, ctx): async def translate( self, ctx, + from_language: Option(str, "The language to translate from", required=True), to_language: Option(str, "The language to translate to", required=True), text: Option(str, "The text to be translated", required=True), keep_going: Option( @@ -544,14 +545,15 @@ async def translate( ): await ctx.defer() prompt = ( - f"You are an expert translator, fluent in both English and {to_language}. " - f"Whenever the user says something, repeat it back to them, and then repeat it again in the {to_language} language." + f"You are an expert translator, fluent in both {from_language} and {to_language}. " + f"The user will say something in {from_language}, you should repeat it back to them, " + f"and then repeat it again in {to_language}." ) gu = self.get_user_from_context( ctx, True, sysprompt=prompt, - promptinfo=f"Translator: English -> {to_language}", + promptinfo=f"Translator: {from_language} -> {to_language}", ) gu.push_conversation({"role": "user", "content": text}) async with ctx.channel.typing(): From a6c30e01354802ad368d94de3a381c01e2269c78 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Sat, 9 Sep 2023 16:54:46 -0500 Subject: [PATCH 13/19] util: add footer support to `mkembed` function This commit adds support for a footer in the `mkembed` function. If a footer is provided as a kw argument, it will be set as the footer text of the embed. This allows for more customization and flexibility when creating embedded messages. --- util/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/util/__init__.py b/util/__init__.py index 9a06abb..8511569 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -25,6 +25,9 @@ def mkembed(kind: str, description: str, **kwargs) -> discord.Embed: description=description, color=discord_color_mapping[kind], ) + f = kwargs.pop("footer", None) + if f: + e.set_footer(text=f) for k, v in kwargs.items(): e.add_field(name=k, value=v) return e From 44f2898f0a1342a456cb69cca16683857dff919f Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Sat, 9 Sep 2023 17:33:50 -0500 Subject: [PATCH 14/19] c.reminder: Add time zone, reoccurring event support --- Pipfile | 1 + Pipfile.lock | 16 ++- cogs/reminder.py | 270 +++++++++++++++++++++++++++++++++++++---------- 3 files changed, 231 insertions(+), 56 deletions(-) diff --git a/Pipfile b/Pipfile index 03b1e91..4426328 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,7 @@ openai = "*" tiktoken = "~=0.4.0" dateparser = "*" pytz = "*" +recurrent = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 8ff3434..6bfc8a9 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a85729c5c2478c40ec48fe696bf7a69264d0b13457c7ba8d505210446cc17084" + "sha256": "9741688172fecc6144f064695c5210190bbe043bc63e07d6b86110a792d79c2a" }, "pipfile-spec": 6, "requires": { @@ -408,6 +408,13 @@ "index": "pypi", "version": "==0.28.0" }, + "parsedatetime": { + "hashes": [ + "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", + "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" + ], + "version": "==2.6" + }, "py-cord": { "hashes": [ "sha256:0266c9d9a9d2397622a0e5ead09826690e688ba3cf14c470167b81e6cd2d8a56", @@ -488,6 +495,13 @@ "index": "pypi", "version": "==6.0.1" }, + "recurrent": { + "hashes": [ + "sha256:5452089d2cf1294dbaa51463798284bd8abd9a852ff0785dbb317851c5b596a6" + ], + "index": "pypi", + "version": "==0.4.1" + }, "regex": { "hashes": [ "sha256:0085da0f6c6393428bf0d9c08d8b1874d805bb55e17cb1dfa5ddb7cfb11140bf", diff --git a/cogs/reminder.py b/cogs/reminder.py index ff46fc1..8021ff6 100644 --- a/cogs/reminder.py +++ b/cogs/reminder.py @@ -1,8 +1,11 @@ from datetime import datetime -from typing import List +from typing import List, Optional import dateparser import discord +import pytz +from recurrent.event_parser import RecurringEvent +from dateutil import rrule from blitzdb import Document, FileBackend from discord import SlashCommandGroup, Option from discord.ext import commands, tasks @@ -19,10 +22,76 @@ class ReminderInteractedUser(Document): pass +async def _send_disclaimer( + user: discord.Member, new: bool, ctx: discord.ApplicationContext +) -> bool: + try: + await user.send( + "Hey I have to let you know since you just used reminders for the first time, reminders are a " + "best-effort service. Do not rely on them for anything critical or life-threatening. Discord " + "occasionally eats messages and glitches sometimes happen." + ) + if new: + await user.send( + "Also, this tool assumes UTC/GMT times. If you want to set your time zone so you don't " + "have to think about this in the future, please see the `/reminder timezone` command." + ) + except discord.Forbidden: + await ctx.respond( + "Private messages from server members seem to be disabled. Please enable this setting to receive " + "reminders. You will need to try again.", + ephemeral=True, + ) + return False + return True + + +def _parse_convert_dates(when, now, tz): + """ + Parses a date string, relative date, or recurrent date into localized times, formatted into epoch times + + :type when: str + :type now: datetime + :param when: The input date string or recurring event string. + :param now: The current date and time (naïve) used to calculate recurrent events. + :param tz: The time zone to apply to the dates. + :return: A tuple representing the localized current time, reminder time, and a list of instance times as timestamps + :rtype: tuple[int, int, list[int]] + :raises ValueError: if the provided time string cannot be parsed + """ + reminder_time = dateparser.parse(when) # Time zone naïve + recurring_handler = RecurringEvent(now_date=now) + instances = [] + + first_time = reminder_time if reminder_time else recurring_handler.parse(when) + if first_time is None: + raise ValueError("Unable to convert provided date") + if isinstance(first_time, str): + # If it's a string, it must be a reoccurring event. Convert to a datetime. + rr = rrule.rrulestr(first_time) + instances = list(rr) + first_time = rr.after(now) + if instances: + del instances[0] + + # Apply user time zone to current time and reminder times + localized_now = tz.localize(now) + localized_first_time = tz.localize(first_time) + instances = [tz.localize(t) for t in instances] + + # Since we have zone-aware times, conversion to timestamp implicitly converts to UTC + return ( + int(localized_now.timestamp()), + int(localized_first_time.timestamp()), + [int(x.timestamp()) for x in instances], + ) + + class Reminder(commands.Cog): reminder = SlashCommandGroup( "reminder", "Set reminders for yourself or publicly", guild_ids=util.guilds ) + local_tzinfo = datetime.now().astimezone().tzinfo def __init__(self, bot): self.bot = bot @@ -31,65 +100,99 @@ def __init__(self, bot): self.check_reminders.start() bot.logger.info("Reminder ready") - async def do_disclaimer(self, ctx: discord.ApplicationContext) -> bool: + async def init_user( + self, ctx: discord.ApplicationContext + ) -> Optional[ReminderInteractedUser]: # noinspection PyTypeChecker user: discord.Member = ctx.author - interacted = self.backend.filter(ReminderInteractedUser, {"user_id": user.id}) - if not interacted: - try: - await user.send( - "Hey I have to let you know since you just set a reminder for the first time, reminders are a " - "best-effort service. Do not rely on it for anything critical or life-threatening. Discord " - "occasionally eats messages and glitches sometimes happen." - ) - except discord.Forbidden: - await ctx.respond( - "Private messages from server members seem to be disabled. Please enable this setting to receive " - "reminders. You will need to try setting your last reminder again.", - ephemeral=True, - ) - return False - new_user = ReminderInteractedUser({"user_id": user.id}) - self.backend.save(new_user) - return True + try: + interacted = self.backend.get(ReminderInteractedUser, {"user_id": user.id}) + except ReminderInteractedUser.DoesNotExist: + if not await _send_disclaimer(user, True, ctx): + return None + interacted = ReminderInteractedUser( + {"user_id": user.id, "tz": "UTC", "disclaimed": True} + ) + self.backend.save(interacted) + if not interacted.disclaimed: + if not await _send_disclaimer(user, False, ctx): + return None + interacted.disclaimed = True + self.backend.save(interacted) + return interacted + + def get_user_tz(self, uid: int): + user = self.backend.get(ReminderInteractedUser, {"user_id": uid}) + return pytz.timezone(user.tz) + + def reschedule_reminder(self, reminder: ReminderEntry, new_time=0): + """Reschedule the provided reminder. If any timestamp is provided, the reminder time will be set to that + timestamp, otherwise the reminder's instances list will be popped + """ + if not reminder.instances and not new_time: + raise ValueError("Invalid reschedule: no recurrence and no timestamp given") + if new_time: + reminder.time = new_time else: - return True + reminder.time = reminder.instances.pop(0) + self.backend.save(reminder) - async def fetch_reminders(self) -> List[ReminderEntry]: - """Retrieves a list of all reminders which are due to be delivered (has a UNIX timestamp in the past)""" - now_unix = int(datetime.utcnow().timestamp()) - return self.backend.filter(ReminderEntry, {"time": {"$lte": now_unix}}) + async def get_due_reminders(self) -> List[ReminderEntry]: + """Retrieves a list of all reminders due to be delivered (has a UNIX timestamp now or in the past)""" + now_timestamp = int(datetime.now(tz=self.local_tzinfo).timestamp()) + return self.backend.filter(ReminderEntry, {"time": {"$lte": now_timestamp}}) async def send_reminders(self, reminders: List[ReminderEntry]): for reminder in reminders: - channel = self.bot.get_channel(reminder["channel_id"]) - user = self.bot.get_user(reminder["user_id"]) - if reminder["location"] == "private": - await user.send( - f"On {reminder['created']} UTC you asked to be reminded: {reminder['text']}" + channel = await self.bot.fetch_channel(reminder.channel_id) + user = await self.bot.fetch_user(reminder.user_id) + tz = self.get_user_tz(user.id) + created = datetime.fromtimestamp(reminder.created, tz).strftime("%c %Z") + # TODO: Toss reminders when the creator is no longer in the public channel + try: + if reminder.location == "private": + await user.send( + f"On {created}, you asked to be reminded: {reminder.text}" + ) + else: + await channel.send( + f"On {created}, {user.mention} asked to be reminded: {reminder.text}" + ) + except discord.DiscordException as e: + reminder.fails += 1 + self.bot.logger.error( + f"Reminder delivery failed: {e}, Failure count {reminder.fails}" ) + if reminder.fails >= 3: + self.backend.delete(reminder) + return + else: + self.reschedule_reminder(reminder, reminder.time + 600) + return + + if reminder.nag: + self.reschedule_reminder(reminder, reminder.time + 60) + elif reminder.instances: + self.reschedule_reminder(reminder) else: - await channel.send( - f"On {reminder['created']} UTC, {user.mention} asked to be reminded: {reminder['text']}" - ) - self.backend.delete(reminder) + self.backend.delete(reminder) @tasks.loop(seconds=10) async def check_reminders(self): - reminders = await self.fetch_reminders() + reminders = await self.get_due_reminders() await self.send_reminders(reminders) - @reminder.command(name="add", guild_ids=util.guilds) + @reminder.command(guild_ids=util.guilds) async def add( self, ctx: discord.ApplicationContext, - time: Option( + when: Option( str, - "When to be reminded. Absolute with time zone (2020-01-01 15:00:00 UTC) or relative (in 5 minutes)", + "Time (2020-01-01 15:00:00 UTC) / relative (in 5 minutes) / recurring (Every Friday at 1pm)", required=True, ), - text: str, - location: Option( + what: str, + where: Option( str, "Deliver this reminder in public (this channel) or private DM", choices=["public", "private"], @@ -97,40 +200,46 @@ async def add( ), ): """Adds a reminder""" - reminder_time = dateparser.parse(time, settings={"TIMEZONE": "UTC"}) + await ctx.defer(ephemeral=True) + if not await self.init_user(ctx): + return + user_timezone = self.get_user_tz(ctx.author.id) + now: datetime = datetime.now() - if not reminder_time: + try: + now_ts, reminder_ts, instances = _parse_convert_dates( + when, now, user_timezone + ) + except ValueError: await ctx.respond( embed=mkembed("error", "Could not understand the time format."), ephemeral=True, ) return - reminder_time_unix = int(reminder_time.timestamp()) - - if not await self.do_disclaimer(ctx): - return - reminder = ReminderEntry( { - "time": reminder_time_unix, - "created": datetime.utcnow(), - "text": text, + "time": reminder_ts, + "created": now_ts, + "text": what, "user_id": ctx.author.id, "channel_id": ctx.channel.id, - "location": location, - "nag": False, + "location": where, + "nag": False, # TODO + "instances": instances, + "fails": 0, } ) + friendly = datetime.fromtimestamp(reminder_ts, user_timezone).strftime("%c %Z") self.backend.save(reminder) await ctx.respond( - embed=mkembed("done", f"Reminder set for {reminder_time} UTC"), + embed=mkembed("done", f"Reminder set for {friendly}"), ephemeral=True, ) - @reminder.command(name="clear", guild_ids=util.guilds) + @reminder.command(guild_ids=util.guilds) async def clear(self, ctx: discord.ApplicationContext): - """Removes all reminders""" + """Removes all your reminders""" user_id = ctx.author.id for reminder in self.backend.filter(ReminderEntry, {"user_id": user_id}): self.backend.delete(reminder) @@ -139,6 +248,57 @@ async def clear(self, ctx: discord.ApplicationContext): ephemeral=True, ) + @reminder.command(guild_ids=util.guilds) + async def timezone( + self, + ctx: discord.ApplicationContext, + zone: Option(str, description="A time zone name like 'America/Chicago'"), + ): + """Set your time zone for reminder messages""" + user = await self.init_user(ctx) + try: + pytz.timezone(zone) + except pytz.UnknownTimeZoneError: + await ctx.respond( + embed=mkembed( + "error", + "I could not recognize that time zone", + footer="Use an identifier from https://en.wikipedia.org/wiki/List_of_tz_database_time_zones", + ), + ephemeral=True, + ) + return + else: + user.tz = zone + self.backend.save(user) + await ctx.respond( + embed=mkembed("done", "Time zone updated successfully", zone=zone), + ephemeral=True, + ) + + @reminder.command(guild_ids=util.guilds) + async def list(self, ctx: discord.ApplicationContext): + """Lists all your active reminders""" + user_id = ctx.author.id + reminders = self.backend.filter(ReminderEntry, {"user_id": user_id}) + + if not reminders: + await ctx.respond("You have no reminders set.", ephemeral=True) + return + tz = self.get_user_tz(user_id) + embed = discord.Embed(title=f"Your reminders ({len(reminders)} total)") + + for reminder in reminders: + time = datetime.fromtimestamp(reminder.time, tz).strftime("%c %Z") + text = ( + reminder.text + if not reminder.instances + else reminder.text + f" (Future instances: {len(reminder.instances)})" + ) + embed.add_field(name=time, value=text, inline=False) + + await ctx.respond(embed=embed) + def setup(bot): bot.add_cog(Reminder(bot)) From e1c3d497fbce4e6413ab24b777ee87ae82fe3a99 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Sun, 10 Sep 2023 09:07:41 -0500 Subject: [PATCH 15/19] c.chatgpt: refactor command descriptions for conciseness and clarity - Refactored the command descriptions in the `ChatGPT` cog to be more concise and clear. - Updated the `reset`, `continue_conversation`, `show_conversation`, `save_conversation`, `summarize_chat`, `load_core`, `help`, `toggle_flags`, and `show_flags` commands with new descriptions. - Removed unnecessary details from the command descriptions to improve readability. --- cogs/chatgpt.py | 68 +++++++++++++++---------------------------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 010d044..1e9aba1 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -246,11 +246,7 @@ async def on_message(self, message: discord.Message): self.users[user_id] = gu await self.reply(message, response, telembed) - @gpt.command( - name="reset", - description="Reset your conversation history with the bot", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def reset( self, ctx, @@ -260,6 +256,7 @@ async def reset( default=None, ), ): + """Reset your conversation history with the bot""" gu = self.get_user_from_context( ctx, True, @@ -273,12 +270,9 @@ async def reset( response += f"\nSystem prompt set to: {system_prompt}" await ctx.respond(response, ephemeral=True) - @gpt.command( - name="continue", - description="Continue a stale conversation rather than resetting", - guild_ids=util.guilds, - ) + @gpt.command(name="continue", guild_ids=util.guilds) async def continue_conversation(self, ctx): + """Continue a stale conversation rather than resetting""" user_id = ctx.author.id if not self.users.get(user_id): await ctx.respond( @@ -288,12 +282,9 @@ async def continue_conversation(self, ctx): self.users[user_id].freshen() await ctx.respond("Your conversation has been resumed.", ephemeral=True) - @gpt.command( - name="show_conversation", - description="Show your current conversation with the bot", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def show_conversation(self, ctx): + """Show your current conversation with the bot""" user_id = ctx.author.id if user_id not in self.users: await ctx.respond( @@ -321,12 +312,9 @@ async def show_conversation(self, ctx): ephemeral=True, ) - @gpt.command( - name="save_conversation", - description="Save your current conversation with the bot to a text file", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def save_conversation(self, ctx): + """Save your current conversation with the bot to a text file""" user_id = ctx.author.id if user_id not in self.users: await ctx.respond( @@ -359,11 +347,7 @@ async def save_conversation(self, ctx): ephemeral=True, ) - @gpt.command( - name="summarize_chat", - description="Summarize the last n messages in the current channel", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def summarize_chat( self, ctx: discord.ApplicationContext, @@ -376,6 +360,7 @@ async def summarize_chat( default=None, ), ): + """Summarize the last n messages in the current channel""" gu = self.get_user_from_context(ctx) if ctx.channel.is_nsfw(): await ctx.respond( @@ -429,17 +414,14 @@ async def summarize_chat( content="Sorry, can't generate a summary right now." ) - @gpt.command( - name="load_core", - description="Load a soul core (warning: resets conversation)", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def load_core( self, ctx: discord.ApplicationContext, core: Option(str, autocomplete=util.souls.scan_cores, required=True), telepathy: Option(bool, default=True, description="Show thinking"), ): + """Load a soul core (warning: resets conversation)""" try: with open(f"cores/{core.split(' ')[0]}") as file: core_data = yaml.safe_load(file) @@ -453,8 +435,9 @@ async def load_core( return await ctx.respond(f"{core} has been loaded", ephemeral=True) - @gpt.command(name="help", description="Explain how this all works") - async def display_help(self, ctx: discord.ApplicationContext): + @gpt.command(guild_ids=util.guilds) + async def help(self, ctx: discord.ApplicationContext): + """Explain how this all works""" help_embed = discord.Embed(title="AI Chatbot Help", color=0x3498DB) help_embed.description = f"""I can use AI to hold a conversation. Just @mention me! I also accept DMs if you are in a server with me. @@ -481,11 +464,7 @@ async def display_help(self, ctx: discord.ApplicationContext): await ctx.respond(embed=help_embed, ephemeral=True) # noinspection PyTypeHints - @gpt.command( - name="toggle_flags", - description="Toggles user flags on/off", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def toggle_flags( self, ctx, @@ -496,6 +475,7 @@ async def toggle_flags( required=True, ), ): + """Toggles user flags on/off""" gu = self.get_user_from_context(ctx) flag_to_toggle = UserConfig[flag] @@ -512,12 +492,9 @@ async def toggle_flags( ephemeral=True, ) - @gpt.command( - name="show_flags", - description="Show your AI settings", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def show_flags(self, ctx): + """Show your AI settings""" gu = self.get_user_from_context(ctx) out = f"```md\n# AI settings for {gu.name}:\n" for k in UserConfig.__members__.keys(): @@ -526,11 +503,7 @@ async def show_flags(self, ctx): out += "```" await ctx.respond(out, ephemeral=True) - @gpt.command( - name="translate", - description="Translate across languages", - guild_ids=util.guilds, - ) + @gpt.command(guild_ids=util.guilds) async def translate( self, ctx, @@ -543,6 +516,7 @@ async def translate( default=False, ), ): + """Translate across languages""" await ctx.defer() prompt = ( f"You are an expert translator, fluent in both {from_language} and {to_language}. " From 7fbe95eec2b83bd6482f24a5cb20945ca2fb33f4 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Sun, 10 Sep 2023 09:08:51 -0500 Subject: [PATCH 16/19] main: add logging message for cog loading completion - Add logging message "Cog loading completed" after all cogs are loaded in the `PixlBot` class's `load_cogs` method. --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index 1f30b1b..7299b42 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ async def on_connect(self): self.logger.error(f"Skipping {ext}") continue self.loaded = True + self.logger.info("Cog loading completed") async def on_ready(self): self.logger.info("Ready!") From f8ca2159c8d38c6193eb5ef4d869a66f56015830 Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Sun, 10 Sep 2023 09:16:39 -0500 Subject: [PATCH 17/19] add reminder to default cog list in the example This commit adds the `cogs.reminder` module to the list of system cogs in the `config.yml.example` file. This change allows for the usage and functionality of the reminder cog within the application. --- config.yml.example | 1 + 1 file changed, 1 insertion(+) diff --git a/config.yml.example b/config.yml.example index e5d076f..8c8625b 100644 --- a/config.yml.example +++ b/config.yml.example @@ -8,6 +8,7 @@ system: - cogs.yoink - cogs.ftime - cogs.roller + - cogs.reminder #- cogs.chatgpt #- cogs.roleconcat #- cogs.bonk From 2bf514413af14fae814b31014bdf5ea1fa2de96d Mon Sep 17 00:00:00 2001 From: Mike Parks Date: Tue, 19 Mar 2024 19:37:45 -0500 Subject: [PATCH 18/19] c.chatgpt: support multiple AI vendors The code has been refactored to allow the chatbot to use different AI vendors. The Model class now includes a vendor parameter and separate methods for sending conversations to OpenAI and Anthropic. The send_to_chatgpt method in the ChatGPT class has been renamed to send_to_model, reflecting its more general functionality. API keys are now fetched from the configuration based on the vendor. Also, some unused imports have been removed for cleaner code. --- Pipfile | 1 + {cores => ai/cores}/asriel.yml | 0 {cores => ai/cores}/dustin.yml | 0 cogs/chatgpt.py | 28 ++++++-------- util/chatgpt.py | 69 ++++++++++++++++++++++++++++------ util/souls.py | 4 +- 6 files changed, 72 insertions(+), 30 deletions(-) rename {cores => ai/cores}/asriel.yml (100%) rename {cores => ai/cores}/dustin.yml (100%) diff --git a/Pipfile b/Pipfile index 4426328..f55ec5e 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ tiktoken = "~=0.4.0" dateparser = "*" pytz = "*" recurrent = "*" +anthropic = "~=0.21.1" [dev-packages] pytest = "*" diff --git a/cores/asriel.yml b/ai/cores/asriel.yml similarity index 100% rename from cores/asriel.yml rename to ai/cores/asriel.yml diff --git a/cores/dustin.yml b/ai/cores/dustin.yml similarity index 100% rename from cores/dustin.yml rename to ai/cores/dustin.yml diff --git a/cogs/chatgpt.py b/cogs/chatgpt.py index 1e9aba1..eff8c63 100644 --- a/cogs/chatgpt.py +++ b/cogs/chatgpt.py @@ -5,7 +5,6 @@ import discord import openai import yaml -from openai import ChatCompletion from discord.commands import SlashCommandGroup, Option from discord.ext import commands from blitzdb import Document, FileBackend @@ -29,7 +28,8 @@ def __init__(self, bot): self.users: Dict[int, GPTUser] = {} self.backend = FileBackend("db") self.backend.autocommit = True - openai.api_key = self.config["api_key"] + openai.api_key = self.config["openai_api_key"] + util.chatgpt.anthropic_api.api_key = self.config["anthropic_api_key"] bot.logger.info("ChatGPT integration initialized") @contextmanager @@ -42,7 +42,7 @@ def get_persistent_userdata(self, userid: int) -> PersistentUser: yield pu self.backend.save(pu) - async def send_to_chatgpt(self, user, conversation=None) -> Optional[str]: + async def send_to_model(self, user, conversation=None) -> Optional[str]: """Sends a conversation to OpenAI for chat completion and returns what the model said in reply. The model details will be read from the provided GPTUser. If a conversation is provided, it will be sent to the model. Otherwise, the conversation will be read from the user object. @@ -52,16 +52,8 @@ async def send_to_chatgpt(self, user, conversation=None) -> Optional[str]: :return: The response from the model, or none if there was a problem """ try: - response = await ChatCompletion.acreate( - model=user.model.model, - max_tokens=user.model.max_tokens, - temperature=user.model.temperature, - messages=conversation or user.conversation, - n=1, - stop=None, - user=user.idhash, - ) - return response.choices[0]["message"]["content"] + response = await user.model.send(conversation or user.conversation) + return response except Exception as e: self.bot.logger.error(e) return None @@ -96,7 +88,9 @@ def get_user_from_context(self, context, force_new=False, **kwargs) -> GPTUser: uname=context.author.display_name, sysprompt=sysprompt or server_config["system_prompt"], prompt_info=promptinfo, - model=Model(server_config["model_name"]), + model=Model( + server_config["model_name"], vendor=server_config["vendor"] + ), config=UserConfig(pu.config), ) return gu @@ -184,7 +178,7 @@ async def on_message(self, message: discord.Message): overflow.append(gu.pop_conversation(0)) async with message.channel.typing(): - response = await self.send_to_chatgpt(gu) + response = await self.send_to_model(gu) telembed = None warnings = "" stats = "" @@ -404,7 +398,7 @@ async def summarize_chat( ) # noinspection PyTypeChecker # This is a lint bug - summary = await self.send_to_chatgpt(gu, conversation) + summary = await self.send_to_model(gu, conversation) if summary: await loading_message.edit( content=f"Summary of the last {num_messages} messages:\n\n{summary}" @@ -531,7 +525,7 @@ async def translate( ) gu.push_conversation({"role": "user", "content": text}) async with ctx.channel.typing(): - response = await self.send_to_chatgpt(gu) + response = await self.send_to_model(gu) if not response: response = "Sorry, could not communicate with OpenAI. Please try again." gu.pop_conversation() diff --git a/util/chatgpt.py b/util/chatgpt.py index 7b927b7..9affa40 100644 --- a/util/chatgpt.py +++ b/util/chatgpt.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from datetime import datetime, timedelta from hashlib import sha256 from typing import List, Optional, TypedDict, Literal @@ -7,6 +6,10 @@ from util.souls import Soul, SOUL_PROMPT import tiktoken +import openai +import anthropic + +anthropic_api = anthropic.AsyncAnthropic() class ConversationLine(TypedDict): @@ -24,12 +27,52 @@ class UserConfig(Flag): DEFAULT_FLAGS = UserConfig.SHOWSTATS | UserConfig.NAMESUFFIX -@dataclass class Model: - model: str = "gpt-3.5-turbo" - max_tokens: int = 768 - temperature: float = 0.5 - max_context: int = 4097 + def __init__( + self, + model: str = "gpt-3.5-turbo", + max_tokens: int = 768, + temperature: float = 0.5, + max_context: int = 4097, + vendor: Literal["openai", "anthropic"] = "openai", + ): + self.model = model + self.max_tokens = max_tokens + self.temperature = temperature + self.max_context = max_context + self.vendor = vendor + + async def send(self, conversation: List[ConversationLine]) -> Optional[str]: + if self.vendor == "openai": + return await self._openai_send(conversation) + elif self.vendor == "anthropic": + return await self._anthropic_send(conversation) + + async def _openai_send(self, conversation: List[ConversationLine]) -> Optional[str]: + response = await openai.ChatCompletion.acreate( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + messages=conversation, + n=1, + stop=None, + ) + return response.choices[0]["message"]["content"] + + async def _anthropic_send( + self, conversation: List[ConversationLine] + ) -> Optional[str]: + sysprompt = [l for l in conversation if l["role"] == "system"][0] + conversation = [l for l in conversation if l["role"] != "system"] + # noinspection PyTypeChecker + response = await anthropic_api.messages.create( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + messages=conversation, + system=sysprompt["content"], + ) + return response.content[0].text class GPTUser: @@ -66,7 +109,7 @@ def __init__( uname: str, sysprompt: str, prompt_info: Optional[str], - model: Model = Model(), + model: Model, config: UserConfig = DEFAULT_FLAGS, ): """ @@ -87,10 +130,12 @@ def __init__( self._soul = None self.prompt_info = prompt_info self._model = model - self._encoding = tiktoken.encoding_for_model(model.model) + self._encoding = tiktoken.encoding_for_model("gpt-4") self._conversation_len = self._calculate_conversation_len() if UserConfig.NAMESUFFIX in config: self._add_namesuffix() + if not model: + self._model = Model() @property def conversation(self): @@ -107,9 +152,11 @@ def conversation(self, value): def format_conversation(self, bot_name: str) -> str: """Returns a pretty-printed version of user's conversation history with system prompts removed""" formatted_conversation = [ - f"{self.name}: {msg['content']}" - if msg["role"] == "user" - else f"{bot_name}: {msg['content']}" + ( + f"{self.name}: {msg['content']}" + if msg["role"] == "user" + else f"{bot_name}: {msg['content']}" + ) for msg in self.conversation if msg["role"] != "system" ] diff --git a/util/souls.py b/util/souls.py index d093025..83b16f9 100644 --- a/util/souls.py +++ b/util/souls.py @@ -22,8 +22,8 @@ def format_from_soul(txt: str) -> (Optional[str], list): def scan_cores(*args): - for f in os.listdir("cores"): - sc = yaml.safe_load(open(f"cores/{f}")) + for f in os.listdir("ai/cores"): + sc = yaml.safe_load(open(f"ai/cores/{f}")) cores[f] = sc["name"] return [f"{k} ({cores[k]})" for k in cores] From 7aa4a4d2ac879a2cd6b6644a1d6cfcd05a74c2fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 Mar 2024 16:21:55 -0500 Subject: [PATCH 19/19] Added CrumblWatch cog for cookie flavor tracking This update introduces a new feature, the CrumblWatch cog. This cog is designed to monitor changes in the flavors of cookies on a specific website and notify users about these changes. It uses BeautifulSoup for HTML parsing and aiohttp for asynchronous HTTP requests. Key features include: - Regularly checks the website content every hour - Notifies all channels with enabled notifications when flavors change - Allows enabling/disabling notifications per channel - Provides a command to force an immediate update and report of cookie flavors Also, added 'beautifulsoup4' to Pipfile dependencies. --- Pipfile | 1 + cogs/crumbl.py | 174 +++++++++++++++++++++++++++++++++++++++++++++ config.yml.example | 2 + 3 files changed, 177 insertions(+) create mode 100644 cogs/crumbl.py diff --git a/Pipfile b/Pipfile index f55ec5e..d13cfed 100644 --- a/Pipfile +++ b/Pipfile @@ -15,6 +15,7 @@ dateparser = "*" pytz = "*" recurrent = "*" anthropic = "~=0.21.1" +beautifulsoup4 = "*" [dev-packages] pytest = "*" diff --git a/cogs/crumbl.py b/cogs/crumbl.py new file mode 100644 index 0000000..ba8a2e5 --- /dev/null +++ b/cogs/crumbl.py @@ -0,0 +1,174 @@ +import asyncio +from datetime import datetime, timedelta +import pytz + +import aiohttp +import discord +from blitzdb import Document, FileBackend +from bs4 import BeautifulSoup +from discord import ApplicationContext +from discord.commands import SlashCommandGroup +from discord.ext import commands + +from util import guilds + + +class CrumblFlavor(Document): + pass + + +class CrumblNotificationChannel(Document): + pass + + +class CrumblWatch(commands.Cog): + crumbl = SlashCommandGroup( + name="crumbl", guild_ids=guilds, description="Crumbl Cookie Watcher" + ) + + def __init__(self, bot): + self.bot = bot + self.bot.logger.info("Starting CrumblWatch") + self.backend = FileBackend("db") + self.backend.autocommit = True + + # Specify the URL of the web page you want to scrape + url = "https://crumblcookies.com/nutrition/regular" + + # Start the background task to monitor the website content + self.bot.loop.create_task(self.check_website_content(url)) + + async def get_b_element_content(self, url): + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + html_content = await response.text() + + # Create a BeautifulSoup object to parse the HTML content + soup = BeautifulSoup(html_content, "html.parser") + + # Find all elements with the specified class + b_elements = soup.find_all("b", class_="text-lg sm:text-xl") + + # Extract the content of each element + content = [element.get_text(strip=True) for element in b_elements] + + return content + + async def check_website_content(self, url): + while True: + # Get the current time in Mountain Time + mountain_tz = pytz.timezone("US/Mountain") + now = datetime.now(mountain_tz) + + # Check if there is a last result saved + try: + last_result = self.backend.get(CrumblFlavor, {"id": "last_result"}) + except CrumblFlavor.DoesNotExist: + last_result = None + + # If there is no last result saved or it's Sunday and after 6 PM + if not last_result or (now.weekday() == 6 and now.hour >= 18): + # Call the function to get the content of elements + b_content = await self.get_b_element_content(url) + + # If there is no last result saved, create a new one + if not last_result: + last_result = CrumblFlavor( + {"id": "last_result", "flavors": b_content} + ) + self.backend.save(last_result) + + # If the content has changed or it's the first scrape + if b_content != last_result["flavors"]: + last_result["flavors"] = b_content + self.backend.save(last_result) + + # Get all channels with notifications enabled + notification_channels = self.backend.filter( + CrumblNotificationChannel, {} + ) + + # Send the embed to each channel with notifications enabled + for channel_doc in notification_channels: + channel = self.bot.get_channel(channel_doc["channel_id"]) + if channel: + embed = discord.Embed( + title="Crumbl Cookie Flavors", + description="The Crumbl Cookie flavors have changed!", + color=discord.Color.from_rgb( + 255, 192, 203 + ), # Pink color + ) + embed.add_field(name="Flavors", value="\n".join(b_content)) + await channel.send(embed=embed) + + # Wait for a certain interval before checking again + await asyncio.sleep(3600) # Check every hour + + @crumbl.command( + name="enable", + description="Enable Crumbl flavor change notifications for the current channel", + ) + async def enable_notifications(self, ctx: ApplicationContext): + channel_id = ctx.channel_id + try: + self.backend.get(CrumblNotificationChannel, {"channel_id": channel_id}) + await ctx.respond("Notifications are already enabled for this channel.") + except CrumblNotificationChannel.DoesNotExist: + notification_channel = CrumblNotificationChannel({"channel_id": channel_id}) + self.backend.save(notification_channel) + await ctx.respond("Notifications enabled for this channel.") + + @crumbl.command( + name="disable", + description="Disable Crumbl flavor change notifications for the current channel", + ) + async def disable_notifications(self, ctx: ApplicationContext): + channel_id = ctx.channel_id + try: + notification_channel = self.backend.get( + CrumblNotificationChannel, {"channel_id": channel_id} + ) + self.backend.delete(notification_channel) + await ctx.respond("Notifications disabled for this channel.") + except CrumblNotificationChannel.DoesNotExist: + await ctx.respond("Notifications are not enabled for this channel.") + + @crumbl.command( + name="forceupdate", + description="Force an immediate update and report of Crumbl Cookie flavors", + ) + async def force_update(self, ctx: ApplicationContext): + url = "https://crumblcookies.com/nutrition/regular" + b_content = await self.get_b_element_content(url) + + # Update the last result + try: + last_result = self.backend.get(CrumblFlavor, {"id": "last_result"}) + last_result["flavors"] = b_content + except CrumblFlavor.DoesNotExist: + last_result = CrumblFlavor({"id": "last_result", "flavors": b_content}) + self.backend.save(last_result) + + # Get all channels with notifications enabled + notification_channels = self.backend.filter(CrumblNotificationChannel, {}) + + # Send the embed to each channel with notifications enabled + for channel_doc in notification_channels: + channel = self.bot.get_channel(channel_doc["channel_id"]) + if channel: + embed = discord.Embed( + title="Crumbl Cookie Flavors (Forced Update)", + description="The Crumbl Cookie flavors have been forcefully updated!", + color=discord.Color.from_rgb(255, 192, 203), # Pink color + ) + embed.add_field(name="Flavors", value="\n".join(b_content)) + await channel.send(embed=embed) + + await ctx.respond( + "Crumbl Cookie flavors have been forcefully updated and reported." + ) + + +def setup(bot): + bot.add_cog(CrumblWatch(bot)) diff --git a/config.yml.example b/config.yml.example index 8c8625b..402cb51 100644 --- a/config.yml.example +++ b/config.yml.example @@ -9,6 +9,8 @@ system: - cogs.ftime - cogs.roller - cogs.reminder + - cogs.crumbl + #- cogs.chatgpt #- cogs.roleconcat #- cogs.bonk