diff --git a/README.md b/README.md index 1df56ef..81c8937 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@

+

@@ -22,10 +23,15 @@ # Recent Notable Updates +- **AI-BASED SERVER MODERATION** - GPT3Discord now has a built-in AI-based moderation system that can automatically detect and remove toxic messages from your server. This is a great way to keep your server safe and clean, and it's completely automatic and **free**! Check out the commands section to learn how to enable it! + + - **AUTOMATIC CHAT SUMMARIZATION!** - When the context limit of a conversation is reached, the bot will use GPT3 itself to summarize the conversation to reduce the tokens, and continue conversing with you, this allows you to chat for a long time! + - Custom conversation openers from https://github.com/f/awesome-chatgpt-prompts were integrated into the bot, check out `/gpt converse opener_file`! The bot now has built in support to make GPT3 behave like various personalities, such as a life coach, python interpreter, interviewer, text based adventure game, and much more! + - Autocomplete for settings and various commands to make it easier to use the bot! # Features @@ -39,6 +45,8 @@ - **Redo Requests** - A simple button after the GPT3 response or DALL-E generation allows you to redo the initial prompt you asked. You can also redo conversation messages by just editing your message! +- **Automatic AI-Based Server Moderation** - Moderate your server automatically with AI! + - Automatically re-send your prompt and update the response in place if you edit your original prompt! - Async and fault tolerant, **can handle hundreds of users at once**, if the upstream API permits! @@ -55,6 +63,8 @@ These commands are grouped, so each group has a prefix but you can easily tab co `/help` - Display help text for the bot +### (Chat)GPT3 Commands + `/gpt ask ` Ask the GPT3 Davinci 003 model a question. Optional overrides available `/gpt converse` - Start a conversation with the bot, like ChatGPT @@ -73,10 +83,14 @@ These commands are grouped, so each group has a prefix but you can easily tab co `/gpt end` - End a conversation with the bot. +### DALL-E2 Commands + `/dalle draw ` - Have DALL-E generate images based on a prompt `/dalle optimize ` Optimize a given prompt text for DALL-E image generation. +### System and Settings + `/system settings` - Display settings for the model (temperature, top_p, etc) `/system settings ` - Change a model setting to a new value. Has autocomplete support, certain settings will have autocompleted values too. @@ -91,6 +105,19 @@ These commands are grouped, so each group has a prefix but you can easily tab co `/system clear-local` - Clear all the local dalleimages. +### Automatic AI Moderation + +`/system moderations status:on` - Turn on automatic chat moderations. + +`/system moderations status:off` - Turn off automatic chat moderations + +`/system moderations status:off alert_channel_id:` - Turn on moderations and set the alert channel to the channel ID you specify in the command. + +- The bot needs Administrative permissions for this, and you need to set `MODERATIONS_ALERT_CHANNEL` to the channel ID of a desired channel in your .env file if you want to receive alerts about moderated messages. +- This uses the OpenAI Moderations endpoint to check for messages, requests are only sent to the moderations endpoint at a MINIMUM request gap of 0.5 seconds, to ensure you don't get blocked and to ensure reliability. +- The bot uses numerical thresholds to determine whether a message is toxic or not, and I have manually tested and fine tuned these thresholds to a point that I think is good, please open an issue if you have any suggestions for the thresholds! + + # Configuration All the model parameters are configurable inside discord. Type `/system settings` to view all the configurable parameters, and use `/system settings ` to set parameters. @@ -130,6 +157,8 @@ DALLE_ROLES="Admin,Openai,Dalle,gpt" # People with the roles in GPT_ROLES can use commands like /gpt ask or /gpt converse GPT_ROLES="openai,gpt" WELCOME_MESSAGE="Hi There! Welcome to our Discord server. We hope you'll enjoy our server and we look forward to engaging with you!" # This is a fallback message if gpt3 fails to generate a welcome message. +# This is the channel that auto-moderation alerts will be sent to +MODERATIONS_ALERT_CHANNEL="977697652147892304" ``` **Permissions** diff --git a/cogs/gpt_3_commands_and_converser.py b/cogs/gpt_3_commands_and_converser.py index f7b34d8..f2749c4 100644 --- a/cogs/gpt_3_commands_and_converser.py +++ b/cogs/gpt_3_commands_and_converser.py @@ -1,3 +1,4 @@ +import asyncio import datetime import json import re @@ -12,6 +13,7 @@ from pycord.multicog import add_to_group from models.deletion_service_model import Deletion from models.env_service_model import EnvService from models.message_model import Message +from models.moderations_service_model import Moderation from models.user_model import User, RedoUser from models.check_model import Check from models.autocomplete_model import Settings_autocompleter, File_autocompleter @@ -60,6 +62,10 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): self.users_to_interactions = defaultdict(list) self.redo_users = {} self.awaiting_responses = [] + self.moderation_queues = {} + self.moderation_alerts_channel = EnvService.get_moderations_alert_channel() + self.moderation_enabled_guilds = [] + self.moderation_tasks = {} try: conversation_file_path = data_path / "conversation_starter_pretext.txt" @@ -243,8 +249,6 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): ) # Close all conversation threads for the user - channel = self.bot.get_channel(self.conversation_threads[normalized_user_id]) - if normalized_user_id in self.conversation_threads: thread_id = self.conversation_threads[normalized_user_id] self.conversation_threads.pop(normalized_user_id) @@ -478,6 +482,13 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): # A listener for message edits to redo prompts if they are edited @discord.Cog.listener() async def on_message_edit(self, before, after): + + # Moderation + if after.guild.id in self.moderation_queues and self.moderation_queues[after.guild.id] is not None: + # Create a timestamp that is 0.5 seconds from now + timestamp = (datetime.datetime.now() + datetime.timedelta(seconds=0.5)).timestamp() + await self.moderation_queues[after.guild.id].put(Moderation(after, timestamp)) + if after.author.id in self.redo_users: if after.id == original_message[after.author.id]: response_message = self.redo_users[after.author.id].response @@ -501,8 +512,9 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): ) self.conversating_users[after.author.id].count += 1 + print("Doing the encapsulated send") await self.encapsulated_send( - after.author.id, edited_content, ctx, response_message + user_id=after.author.id, prompt=edited_content, ctx=ctx, response_message=response_message ) self.redo_users[after.author.id].prompt = after.content @@ -516,6 +528,12 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): content = message.content.strip() + # Moderations service + if message.guild.id in self.moderation_queues and self.moderation_queues[message.guild.id] is not None: + # Create a timestamp that is 0.5 seconds from now + timestamp = (datetime.datetime.now() + datetime.timedelta(seconds=0.5)).timestamp() + await self.moderation_queues[message.guild.id].put(Moderation(message, timestamp)) + conversing = self.check_conversing( message.author.id, message.channel.id, content ) @@ -650,6 +668,7 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): return # Send the request to the model + print("About to send model request") response = await self.model.send_request( new_prompt, tokens=tokens, @@ -949,6 +968,59 @@ class GPT3ComCon(discord.Cog, name="GPT3ComCon"): self.conversation_threads[user_id_normalized] = thread.id + @add_to_group("system") + @discord.slash_command( + name="moderations-test", + description="Used to test a prompt and see what threshold values are returned by the moderations endpoint", + guild_ids=ALLOWED_GUILDS, + ) + @discord.option( + name="prompt", + description="The prompt to test", + required=True, + ) + @discord.guild_only() + async def moderations_test(self, ctx: discord.ApplicationContext, prompt: str): + await ctx.defer() + response = await self.model.send_moderations_request(prompt) + await ctx.respond(response['results'][0]['category_scores']) + await ctx.send_followup(response['results'][0]['flagged']) + + @add_to_group("system") + @discord.slash_command( + name="moderations", + description="The AI moderations service", + guild_ids=ALLOWED_GUILDS, + ) + @discord.option(name="status", description="Enable or disable the moderations service for the current guild (on/off)", required = True) + @discord.option(name="alert_channel_id", description="The channel ID to send moderation alerts to", required=False) + @discord.guild_only() + async def moderations(self, ctx: discord.ApplicationContext, status: str, alert_channel_id: str): + await ctx.defer() + + status = status.lower().strip() + if status not in ["on", "off"]: + await ctx.respond("Invalid status, please use on or off") + return + + if status == "on": + # Create the moderations service. + self.moderation_queues[ctx.guild_id] = asyncio.Queue() + if self.moderation_alerts_channel or alert_channel_id: + moderations_channel = await self.bot.fetch_channel(self.moderation_alerts_channel if not alert_channel_id else alert_channel_id) + else: + moderations_channel = self.moderation_alerts_channel # None + + self.moderation_tasks[ctx.guild_id] = asyncio.ensure_future(Moderation.process_moderation_queue(self.moderation_queues[ctx.guild_id], 1, 1, moderations_channel)) + await ctx.respond("Moderations service enabled") + + elif status == "off": + # Cancel the moderations service. + self.moderation_tasks[ctx.guild_id].cancel() + self.moderation_tasks[ctx.guild_id] = None + self.moderation_queues[ctx.guild_id] = None + await ctx.respond("Moderations service disabled") + @add_to_group("gpt") @discord.slash_command( name="end", diff --git a/models/env_service_model.py b/models/env_service_model.py index 520c253..ac733f7 100644 --- a/models/env_service_model.py +++ b/models/env_service_model.py @@ -120,3 +120,13 @@ class EnvService: except: welcome_message = "Hi there! Welcome to our Discord server!" return welcome_message + + @staticmethod + def get_moderations_alert_channel(): + # MODERATIONS_ALERT_CHANNEL is a channel id where moderation alerts are sent to + # The string can be blank but this is not advised. If a string cannot be found in the .env file, the below string is used. + try: + moderations_alert_channel = os.getenv("MODERATIONS_ALERT_CHANNEL") + except: + moderations_alert_channel = None + return moderations_alert_channel diff --git a/models/moderations_service_model.py b/models/moderations_service_model.py new file mode 100644 index 0000000..6102724 --- /dev/null +++ b/models/moderations_service_model.py @@ -0,0 +1,113 @@ +import asyncio +import os +import traceback +from datetime import datetime +from pathlib import Path + +import discord + +from models.openai_model import Model +from models.usage_service_model import UsageService + +usage_service = UsageService(Path(os.environ.get("DATA_DIR", os.getcwd()))) +model = Model(usage_service) + +class Moderation: + + def __init__(self, message, timestamp): + self.message = message + self.timestamp = timestamp + + @staticmethod + def build_moderation_embed(): + # Create a discord embed to send to the user when their message gets moderated + embed = discord.Embed( + title="Your message was moderated", + description="Our automatic moderation systems detected that your message was inappropriate and has been deleted. Please review the rules.", + colour=discord.Colour.red(), + ) + # Set the embed thumbnail + embed.set_thumbnail( + url="https://i.imgur.com/2oL8JSp.png" + ) + embed.set_footer(text="If you think this was a mistake, please contact the server admins.") + return embed + + @staticmethod + def build_admin_moderated_message(moderated_message, response_message): + + # Create a discord embed to send to the user when their message gets moderated + embed = discord.Embed( + title="A message was moderated in the "+moderated_message.message.guild.name+" server", + description=f"Message from {moderated_message.message.author.mention} was moderated: {moderated_message.message.content}", + colour=discord.Colour.yellow(), + ) + # Get the link to the moderated message + link = f"https://discord.com/channels/{response_message.guild.id}/{response_message.channel.id}/{response_message.id}" + # set the link of the embed + embed.add_field(name="Moderated message link", value=link, inline=False) + return embed + + + @staticmethod + def determine_moderation_result(response): + HATE_THRESHOLD = 0.005 + HATE_VIOLENCE_THRESHOLD = 0.05 + SELF_HARM_THRESHOLD = 0.05 + SEXUAL_THRESHOLD = 0.75 + SEXUAL_MINORS_THRESHOLD = 0.1 + VIOLENCE_THRESHOLD = 0.01 + VIOLENCE_GRAPHIC_THRESHOLD = 0.1 + + thresholds = [HATE_THRESHOLD, HATE_VIOLENCE_THRESHOLD, SELF_HARM_THRESHOLD, SEXUAL_THRESHOLD, SEXUAL_MINORS_THRESHOLD, VIOLENCE_THRESHOLD, VIOLENCE_GRAPHIC_THRESHOLD] + threshold_iterator = ['hate','hate/threatening','self-harm','sexual','sexual/minors','violence','violence/graphic'] + + category_scores = response['results'][0]['category_scores'] + + flagged = response['results'][0]['flagged'] + + # Iterate the category scores using the threshold_iterator and compare the values to thresholds + for category, threshold in zip(threshold_iterator, thresholds): + if category_scores[category] > threshold: + return True + + return False + + # This function will be called by the bot to process the message queue + @staticmethod + async def process_moderation_queue( + moderation_queue, PROCESS_WAIT_TIME, EMPTY_WAIT_TIME, moderations_alert_channel + ): + while True: + try: + # If the queue is empty, sleep for a short time before checking again + if moderation_queue.empty(): + await asyncio.sleep(EMPTY_WAIT_TIME) + continue + + # Get the next message from the queue + to_moderate = await moderation_queue.get() + + # Check if the current timestamp is greater than the deletion timestamp + if datetime.now().timestamp() > to_moderate.timestamp: + response = await model.send_moderations_request(to_moderate.message.content) + moderation_result = Moderation.determine_moderation_result(response) + + if moderation_result: + # Take care of the flagged message + response_message = await to_moderate.message.reply(embed=Moderation.build_moderation_embed()) + # Do the same response as above but use an ephemeral message + await to_moderate.message.delete() + + # Send to the moderation alert channel + if moderations_alert_channel: + await moderations_alert_channel.send(embed=Moderation.build_admin_moderated_message(to_moderate, response_message)) + + else: + await moderation_queue.put(to_moderate) + # Sleep for a short time before processing the next message + # This will prevent the bot from spamming messages too quickly + await asyncio.sleep(PROCESS_WAIT_TIME) + except: + traceback.print_exc() + pass diff --git a/models/openai_model.py b/models/openai_model.py index 2d3a27e..6a15c9f 100644 --- a/models/openai_model.py +++ b/models/openai_model.py @@ -317,6 +317,22 @@ class Model: + str(response["error"]["message"]) ) + async def send_moderations_request(self, text): + # Use aiohttp to send the above request: + async with aiohttp.ClientSession() as session: + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.openai_key}", + } + payload = {"input": text} + async with session.post( + "https://api.openai.com/v1/moderations", + headers=headers, + json=payload, + ) as response: + return await response.json() + + async def send_summary_request(self, prompt): """ Sends a summary request to the OpenAI API