From 7e3f2cbffbaff1e7c3842f64f3adc14acfacc557 Mon Sep 17 00:00:00 2001 From: mudler Date: Sat, 16 Dec 2023 18:54:06 +0100 Subject: [PATCH] Add slack example --- .gitignore | 3 +- examples/slack/.dockerenv.example | 21 ++ examples/slack/Dockerfile | 17 ++ examples/slack/LICENSE | 21 ++ examples/slack/app/__init__.py | 0 examples/slack/app/agent.py | 396 ++++++++++++++++++++++++++ examples/slack/app/bolt_listeners.py | 403 +++++++++++++++++++++++++++ examples/slack/app/env.py | 43 +++ examples/slack/app/i18n.py | 75 +++++ examples/slack/app/markdown.py | 53 ++++ examples/slack/app/openai_ops.py | 234 ++++++++++++++++ examples/slack/app/slack_ops.py | 110 ++++++++ examples/slack/entrypoint.sh | 12 + examples/slack/main.py | 69 +++++ examples/slack/main_prod.py | 306 ++++++++++++++++++++ examples/slack/manifest-dev.yml | 32 +++ examples/slack/manifest-prod.yml | 43 +++ examples/slack/requirements.txt | 15 + examples/slack/run.sh | 2 + 19 files changed, 1854 insertions(+), 1 deletion(-) create mode 100644 examples/slack/.dockerenv.example create mode 100644 examples/slack/Dockerfile create mode 100644 examples/slack/LICENSE create mode 100644 examples/slack/app/__init__.py create mode 100644 examples/slack/app/agent.py create mode 100644 examples/slack/app/bolt_listeners.py create mode 100644 examples/slack/app/env.py create mode 100644 examples/slack/app/i18n.py create mode 100644 examples/slack/app/markdown.py create mode 100644 examples/slack/app/openai_ops.py create mode 100644 examples/slack/app/slack_ops.py create mode 100755 examples/slack/entrypoint.sh create mode 100644 examples/slack/main.py create mode 100644 examples/slack/main_prod.py create mode 100644 examples/slack/manifest-dev.yml create mode 100644 examples/slack/manifest-prod.yml create mode 100644 examples/slack/requirements.txt create mode 100644 examples/slack/run.sh diff --git a/.gitignore b/.gitignore index 89630bc..339b255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ db/ models/ -config.ini \ No newline at end of file +config.ini +.dockerenv \ No newline at end of file diff --git a/examples/slack/.dockerenv.example b/examples/slack/.dockerenv.example new file mode 100644 index 0000000..4f588c8 --- /dev/null +++ b/examples/slack/.dockerenv.example @@ -0,0 +1,21 @@ +SLACK_APP_TOKEN=xapp- +SLACK_BOT_TOKEN=xoxb- +OPENAI_API_KEY=fake + +OPENAI_SYSTEM_TEXT=Default System Text +OPENAI_TIMEOUT_SECONDS=30 +OPENAI_MODEL=gpt-3.5-turbo +USE_SLACK_LANGUAGE=true +SLACK_APP_LOG_LEVEL=DEBUG +TRANSLATE_MARKDOWN=false +OPENAI_API_BASE=http://localhost:8080/v1 +EMBEDDINGS_MODEL=all-MiniLM-L6-v2 +EMBEDDINGS_API_BASE=http://localhost:8080/v1 +LOCALAI_API_BASE=http://localhost:8080/v1 +TTS_API_BASE=http://localhost:8080/v1 +IMAGES_API_BASE=http://localhost:8080/v1 +STABLEDIFFUSION_MODEL=dreamshaper +FUNCTIONS_MODEL=gpt-3.5-turbo +LLM_MODEL=gpt-3.5-turbo +TTS_MODEL=en-us-kathleen-low.onnx +PERSISTENT_DIR=/data \ No newline at end of file diff --git a/examples/slack/Dockerfile b/examples/slack/Dockerfile new file mode 100644 index 0000000..d6da909 --- /dev/null +++ b/examples/slack/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11.3-slim-buster +WORKDIR /app/ +COPY requirements.txt /app/ + +RUN apt-get update && apt-get install build-essential git -y +RUN pip install -U pip && pip install -r requirements.txt +COPY *.py /app/ +COPY *.sh /app/ +RUN mkdir /app/app/ +COPY app/*.py /app/app/ +ENTRYPOINT /app/entrypoint.sh + +# docker build . -t your-repo/chat-gpt-in-slack +# export SLACK_APP_TOKEN=xapp-... +# export SLACK_BOT_TOKEN=xoxb-... +# export OPENAI_API_KEY=sk-... +# docker run -e SLACK_APP_TOKEN=$SLACK_APP_TOKEN -e SLACK_BOT_TOKEN=$SLACK_BOT_TOKEN -e OPENAI_API_KEY=$OPENAI_API_KEY -it your-repo/chat-gpt-in-slack diff --git a/examples/slack/LICENSE b/examples/slack/LICENSE new file mode 100644 index 0000000..a9f1d6d --- /dev/null +++ b/examples/slack/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Slack Technologies, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/slack/app/__init__.py b/examples/slack/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/slack/app/agent.py b/examples/slack/app/agent.py new file mode 100644 index 0000000..a40a155 --- /dev/null +++ b/examples/slack/app/agent.py @@ -0,0 +1,396 @@ +import openai +#from langchain.embeddings import HuggingFaceEmbeddings +from langchain.embeddings import LocalAIEmbeddings + +from langchain.document_loaders import ( + SitemapLoader, + # GitHubIssuesLoader, + # GitLoader, +) + +import uuid +import sys + +from app.env import * +from queue import Queue +import asyncio +import threading +from localagi import LocalAGI + +from ascii_magic import AsciiArt +from duckduckgo_search import DDGS +from typing import Dict, List +import os +from langchain.text_splitter import RecursiveCharacterTextSplitter +import openai +import urllib.request +from datetime import datetime +import json +import os +from io import StringIO +FILE_NAME_FORMAT = '%Y_%m_%d_%H_%M_%S' + + + +if not os.environ.get("PYSQL_HACK", "false") == "false": + # these three lines swap the stdlib sqlite3 lib with the pysqlite3 package for chroma + __import__('pysqlite3') + import sys + sys.modules['sqlite3'] = sys.modules.pop('pysqlite3') +if MILVUS_HOST == "": + from langchain.vectorstores import Chroma +else: + from langchain.vectorstores import Milvus + +embeddings = LocalAIEmbeddings(model=EMBEDDINGS_MODEL,openai_api_base=EMBEDDINGS_API_BASE) + +loop = None +channel = None +def call(thing): + return asyncio.run_coroutine_threadsafe(thing,loop).result() + +def ingest(a, agent_actions={}, localagi=None): + q = json.loads(a) + chunk_size = MEMORY_CHUNK_SIZE + chunk_overlap = MEMORY_CHUNK_OVERLAP + print(">>> ingesting: ") + print(q) + documents = [] + sitemap_loader = SitemapLoader(web_path=q["url"]) + text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap) + documents.extend(sitemap_loader.load()) + texts = text_splitter.split_documents(documents) + if MILVUS_HOST == "": + db = Chroma.from_documents(texts,embeddings,collection_name=MEMORY_COLLECTION, persist_directory=PERSISTENT_DIR) + db.persist() + db = None + else: + Milvus.from_documents(texts,embeddings,collection_name=MEMORY_COLLECTION, connection_args={"host": MILVUS_HOST, "port": MILVUS_PORT}) + return f"Documents ingested" +# def create_image(a, agent_actions={}, localagi=None): +# """ +# Create an image based on a description using OpenAI's API. + +# Args: +# a (str): A JSON string containing the description, width, and height for the image to be created. +# agent_actions (dict, optional): A dictionary of agent actions. Defaults to {}. +# localagi (LocalAGI, optional): An instance of the LocalAGI class. Defaults to None. + +# Returns: +# str: A string containing the URL of the created image. +# """ +# q = json.loads(a) +# print(">>> creating image: ") +# print(q["description"]) +# size=f"{q['width']}x{q['height']}" +# response = openai.Image.create(prompt=q["description"], n=1, size=size) +# image_url = response["data"][0]["url"] +# image_name = download_image(image_url) +# image_path = f"{PERSISTENT_DIR}{image_name}" + +# file = discord.File(image_path, filename=image_name) +# embed = discord.Embed(title="Generated image") +# embed.set_image(url=f"attachment://{image_name}") + +# call(channel.send(file=file, content=f"Here is what I have generated", embed=embed)) + +# return f"Image created: {response['data'][0]['url']}" +def download_image(url: str): + file_name = f"{datetime.now().strftime(FILE_NAME_FORMAT)}.jpg" + full_path = f"{PERSISTENT_DIR}{file_name}" + urllib.request.urlretrieve(url, full_path) + return file_name + + +### Agent capabilities +### These functions are called by the agent to perform actions +### +def save(memory, agent_actions={}, localagi=None): + q = json.loads(memory) + print(">>> saving to memories: ") + print(q["content"]) + if MILVUS_HOST == "": + chroma_client = Chroma(collection_name=MEMORY_COLLECTION,embedding_function=embeddings, persist_directory=PERSISTENT_DIR) + else: + chroma_client = Milvus(collection_name=MEMORY_COLLECTION,embedding_function=embeddings, connection_args={"host": MILVUS_HOST, "port": MILVUS_PORT}) + chroma_client.add_texts([q["content"]],[{"id": str(uuid.uuid4())}]) + if MILVUS_HOST == "": + chroma_client.persist() + chroma_client = None + return f"The object was saved permanently to memory." + +def search_memory(query, agent_actions={}, localagi=None): + q = json.loads(query) + if MILVUS_HOST == "": + chroma_client = Chroma(collection_name=MEMORY_COLLECTION,embedding_function=embeddings, persist_directory=PERSISTENT_DIR) + else: + chroma_client = Milvus(collection_name=MEMORY_COLLECTION,embedding_function=embeddings, connection_args={"host": MILVUS_HOST, "port": MILVUS_PORT}) + #docs = chroma_client.search(q["keywords"], "mmr") + retriever = chroma_client.as_retriever(search_type=MEMORY_SEARCH_TYPE, search_kwargs={"k": MEMORY_RESULTS}) + + docs = retriever.get_relevant_documents(q["keywords"]) + text_res="Memories found in the database:\n" + + sources = set() # To store unique sources + + # Collect unique sources + for document in docs: + if "source" in document.metadata: + sources.add(document.metadata["source"]) + + for doc in docs: + # drop newlines from page_content + content = doc.page_content.replace("\n", " ") + content = " ".join(content.split()) + text_res+="- "+content+"\n" + + # Print the relevant sources used for the answer + for source in sources: + if source.startswith("http"): + text_res += "" + source + "\n" + + chroma_client = None + #if args.postprocess: + # return post_process(text_res) + return text_res + #return localagi.post_process(text_res) + +# write file to disk with content +def save_file(arg, agent_actions={}, localagi=None): + arg = json.loads(arg) + file = filename = arg["filename"] + content = arg["content"] + # create persistent dir if does not exist + if not os.path.exists(PERSISTENT_DIR): + os.makedirs(PERSISTENT_DIR) + # write the file in the directory specified + file = os.path.join(PERSISTENT_DIR, filename) + + # Check if the file already exists + if os.path.exists(file): + mode = 'a' # Append mode + else: + mode = 'w' # Write mode + + with open(file, mode) as f: + f.write(content) + + file = discord.File(file, filename=filename) + call(channel.send(file=file, content=f"Here is what I have generated")) + return f"File {file} saved successfully." + +def ddg(query: str, num_results: int, backend: str = "api") -> List[Dict[str, str]]: + """Run query through DuckDuckGo and return metadata. + + Args: + query: The query to search for. + num_results: The number of results to return. + + Returns: + A list of dictionaries with the following keys: + snippet - The description of the result. + title - The title of the result. + link - The link to the result. + """ + ddgs = DDGS() + try: + results = ddgs.text( + query, + backend=backend, + ) + if results is None: + return [{"Result": "No good DuckDuckGo Search Result was found"}] + + def to_metadata(result: Dict) -> Dict[str, str]: + if backend == "news": + return { + "date": result["date"], + "title": result["title"], + "snippet": result["body"], + "source": result["source"], + "link": result["url"], + } + return { + "snippet": result["body"], + "title": result["title"], + "link": result["href"], + } + + formatted_results = [] + for i, res in enumerate(results, 1): + if res is not None: + formatted_results.append(to_metadata(res)) + if len(formatted_results) == num_results: + break + except Exception as e: + print(e) + return [] + return formatted_results + +## Search on duckduckgo +def search_duckduckgo(a, agent_actions={}, localagi=None): + a = json.loads(a) + list=ddg(a["query"], 2) + + text_res="" + for doc in list: + text_res+=f"""{doc["link"]}: {doc["title"]} {doc["snippet"]}\n""" + print("Found") + print(text_res) + #if args.postprocess: + # return post_process(text_res) + return text_res + #l = json.dumps(list) + #return l + +### End Agent capabilities +### + +### Agent action definitions +agent_actions = { + # "generate_picture": { + # "function": create_image, + # "plannable": True, + # "description": 'For creating a picture, the assistant replies with "generate_picture" and a detailed description, enhancing it with as much detail as possible.', + # "signature": { + # "name": "generate_picture", + # "parameters": { + # "type": "object", + # "properties": { + # "description": { + # "type": "string", + # }, + # "width": { + # "type": "number", + # }, + # "height": { + # "type": "number", + # }, + # }, + # } + # }, + # }, + "search_internet": { + "function": search_duckduckgo, + "plannable": True, + "description": 'For searching the internet with a query, the assistant replies with the action "search_internet" and the query to search.', + "signature": { + "name": "search_internet", + "description": """For searching internet.""", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "information to save" + }, + }, + } + }, + }, + "save_file": { + "function": save_file, + "plannable": True, + "description": 'The assistant replies with the action "save_file", the filename and content to save for writing a file to disk permanently. This can be used to store the result of complex actions locally.', + "signature": { + "name": "save_file", + "description": """For saving a file to disk with content.""", + "parameters": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "information to save" + }, + "content": { + "type": "string", + "description": "information to save" + }, + }, + } + }, + }, + "ingest": { + "function": ingest, + "plannable": True, + "description": 'The assistant replies with the action "ingest" when there is an url to a sitemap to ingest memories from.', + "signature": { + "name": "ingest", + "description": """Save or store informations into memory.""", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "information to save" + }, + }, + "required": ["url"] + } + }, + }, + "save_memory": { + "function": save, + "plannable": True, + "description": 'The assistant replies with the action "save_memory" and the string to remember or store an information that thinks it is relevant permanently.', + "signature": { + "name": "save_memory", + "description": """Save or store informations into memory.""", + "parameters": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "information to save" + }, + }, + "required": ["content"] + } + }, + }, + "search_memory": { + "function": search_memory, + "plannable": True, + "description": 'The assistant replies with the action "search_memory" for searching between its memories with a query term.', + "signature": { + "name": "search_memory", + "description": """Search in memory""", + "parameters": { + "type": "object", + "properties": { + "keywords": { + "type": "string", + "description": "reasoning behind the intent" + }, + }, + "required": ["keywords"] + } + }, + }, +} + + + +def localagi(q): + localagi = LocalAGI( + agent_actions=agent_actions, + llm_model=LLM_MODEL, + tts_model=VOICE_MODEL, + tts_api_base=TTS_API_BASE, + functions_model=FUNCTIONS_MODEL, + api_base=LOCALAI_API_BASE, + stablediffusion_api_base=IMAGE_API_BASE, + stablediffusion_model=STABLEDIFFUSION_MODEL, + ) + conversation_history = [] + + conversation_history=localagi.evaluate( + q, + conversation_history, + critic=False, + re_evaluate=False, + # Enable to lower context usage but increases LLM calls + postprocess=False, + subtaskContext=True, + ) + return conversation_history[-1]["content"] \ No newline at end of file diff --git a/examples/slack/app/bolt_listeners.py b/examples/slack/app/bolt_listeners.py new file mode 100644 index 0000000..643b2fe --- /dev/null +++ b/examples/slack/app/bolt_listeners.py @@ -0,0 +1,403 @@ +import logging +import re +import time + +from openai.error import Timeout +from slack_bolt import App, Ack, BoltContext, BoltResponse +from slack_bolt.request.payload_utils import is_event +from slack_sdk.web import WebClient + +from app.env import ( + OPENAI_TIMEOUT_SECONDS, + SYSTEM_TEXT, + TRANSLATE_MARKDOWN, +) + + +from app.i18n import translate +from app.openai_ops import ( + ask_llm, + format_openai_message_content, + build_system_text, +) +from app.slack_ops import find_parent_message, is_no_mention_thread, post_wip_message, update_wip_message + + +# +# Listener functions +# + + +def just_ack(ack: Ack): + ack() + + +TIMEOUT_ERROR_MESSAGE = ( + f":warning: Sorry! It looks like OpenAI didn't respond within {OPENAI_TIMEOUT_SECONDS} seconds. " + "Please try again later. :bow:" +) +DEFAULT_LOADING_TEXT = ":hourglass_flowing_sand: Wait a second, please ..." + + +def respond_to_app_mention( + context: BoltContext, + payload: dict, + client: WebClient, + logger: logging.Logger, +): + if payload.get("thread_ts") is not None: + parent_message = find_parent_message( + client, context.channel_id, payload.get("thread_ts") + ) + if parent_message is not None: + if is_no_mention_thread(context, parent_message): + # The message event handler will reply to this + return + + wip_reply = None + # Replace placeholder for Slack user ID in the system prompt + system_text = build_system_text(SYSTEM_TEXT, TRANSLATE_MARKDOWN, context) + messages = [{"role": "system", "content": system_text}] + + print("system text:"+system_text, flush=True) + + openai_api_key = context.get("OPENAI_API_KEY") + try: + if openai_api_key is None: + client.chat_postMessage( + channel=context.channel_id, + text="To use this app, please configure your OpenAI API key first", + ) + return + + user_id = context.actor_user_id or context.user_id + content = "" + if payload.get("thread_ts") is not None: + # Mentioning the bot user in a thread + replies_in_thread = client.conversations_replies( + channel=context.channel_id, + ts=payload.get("thread_ts"), + include_all_metadata=True, + limit=1000, + ).get("messages", []) + reply = replies_in_thread[-1] + #for reply in replies_in_thread: + c = reply["text"]+"\n\n" + content += c + role = "assistant" if reply["user"] == context.bot_user_id else "user" + messages.append( + { + "role": role, + "content": ( + format_openai_message_content( + reply["text"], TRANSLATE_MARKDOWN + ) + ), + } + ) + else: + # Strip bot Slack user ID from initial message + msg_text = re.sub(f"<@{context.bot_user_id}>\\s*", "", payload["text"]) + messages.append( + { + "role": "user", + "content": format_openai_message_content(msg_text, TRANSLATE_MARKDOWN), + } + ) + + loading_text = translate( + openai_api_key=openai_api_key, context=context, text=DEFAULT_LOADING_TEXT + ) + wip_reply = post_wip_message( + client=client, + channel=context.channel_id, + thread_ts=payload["ts"], + loading_text=loading_text, + messages=messages, + user=context.user_id, + ) + + resp = ask_llm(messages=messages) + print("Reply "+resp) + + update_wip_message( + client=client, + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=resp, + messages=messages, + user=user_id, + ) + + except Timeout: + if wip_reply is not None: + text = ( + ( + wip_reply.get("message", {}).get("text", "") + if wip_reply is not None + else "" + ) + + "\n\n" + + translate( + openai_api_key=openai_api_key, + context=context, + text=TIMEOUT_ERROR_MESSAGE, + ) + ) + client.chat_update( + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=text, + ) + except Exception as e: + text = ( + ( + wip_reply.get("message", {}).get("text", "") + if wip_reply is not None + else "" + ) + + "\n\n" + + translate( + openai_api_key=openai_api_key, + context=context, + text=f":warning: Failed to start a conversation with ChatGPT: {e}", + ) + ) + logger.exception(text, e) + if wip_reply is not None: + client.chat_update( + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=text, + ) + + +def respond_to_new_message( + context: BoltContext, + payload: dict, + client: WebClient, + logger: logging.Logger, +): + if payload.get("bot_id") is not None and payload.get("bot_id") != context.bot_id: + # Skip a new message by a different app + return + + wip_reply = None + try: + is_in_dm_with_bot = payload.get("channel_type") == "im" + is_no_mention_required = False + thread_ts = payload.get("thread_ts") + if is_in_dm_with_bot is False and thread_ts is None: + return + + openai_api_key = context.get("OPENAI_API_KEY") + if openai_api_key is None: + return + + messages_in_context = [] + if is_in_dm_with_bot is True and thread_ts is None: + # In the DM with the bot + past_messages = client.conversations_history( + channel=context.channel_id, + include_all_metadata=True, + limit=100, + ).get("messages", []) + past_messages.reverse() + # Remove old messages + for message in past_messages: + seconds = time.time() - float(message.get("ts")) + if seconds < 86400: # less than 1 day + messages_in_context.append(message) + is_no_mention_required = True + else: + # In a thread with the bot in a channel + messages_in_context = client.conversations_replies( + channel=context.channel_id, + ts=thread_ts, + include_all_metadata=True, + limit=1000, + ).get("messages", []) + if is_in_dm_with_bot is True: + is_no_mention_required = True + else: + the_parent_message_found = False + for message in messages_in_context: + if message.get("ts") == thread_ts: + the_parent_message_found = True + is_no_mention_required = is_no_mention_thread(context, message) + break + if the_parent_message_found is False: + parent_message = find_parent_message( + client, context.channel_id, thread_ts + ) + if parent_message is not None: + is_no_mention_required = is_no_mention_thread( + context, parent_message + ) + + messages = [] + user_id = context.actor_user_id or context.user_id + last_assistant_idx = -1 + indices_to_remove = [] + for idx, reply in enumerate(messages_in_context): + maybe_event_type = reply.get("metadata", {}).get("event_type") + if maybe_event_type == "chat-gpt-convo": + if context.bot_id != reply.get("bot_id"): + # Remove messages by a different app + indices_to_remove.append(idx) + continue + maybe_new_messages = ( + reply.get("metadata", {}).get("event_payload", {}).get("messages") + ) + if maybe_new_messages is not None: + if len(messages) == 0 or user_id is None: + new_user_id = ( + reply.get("metadata", {}) + .get("event_payload", {}) + .get("user") + ) + if new_user_id is not None: + user_id = new_user_id + messages = maybe_new_messages + last_assistant_idx = idx + + if is_no_mention_required is False: + return + if is_in_dm_with_bot is False and last_assistant_idx == -1: + return + + if is_in_dm_with_bot is True: + # To know whether this app needs to start a new convo + if not next(filter(lambda msg: msg["role"] == "system", messages), None): + # Replace placeholder for Slack user ID in the system prompt + system_text = build_system_text( + SYSTEM_TEXT, TRANSLATE_MARKDOWN, context + ) + messages.insert(0, {"role": "system", "content": system_text}) + + filtered_messages_in_context = [] + for idx, reply in enumerate(messages_in_context): + # Strip bot Slack user ID from initial message + if idx == 0: + reply["text"] = re.sub( + f"<@{context.bot_user_id}>\\s*", "", reply["text"] + ) + if idx not in indices_to_remove: + filtered_messages_in_context.append(reply) + if len(filtered_messages_in_context) == 0: + return + + for reply in filtered_messages_in_context: + msg_user_id = reply.get("user") + messages.append( + { + "content": format_openai_message_content( + reply.get("text"), TRANSLATE_MARKDOWN + ), + "role": "user", + } + ) + + loading_text = translate( + openai_api_key=openai_api_key, context=context, text=DEFAULT_LOADING_TEXT + ) + wip_reply = post_wip_message( + client=client, + channel=context.channel_id, + thread_ts=payload.get("thread_ts") if is_in_dm_with_bot else payload["ts"], + loading_text=loading_text, + messages=messages, + user=user_id, + ) + + latest_replies = client.conversations_replies( + channel=context.channel_id, + ts=wip_reply.get("ts"), + include_all_metadata=True, + limit=1000, + ) + if latest_replies.get("messages", [])[-1]["ts"] != wip_reply["message"]["ts"]: + # Since a new reply will come soon, this app abandons this reply + client.chat_delete( + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + ) + return + + resp = ask_llm(messages=messages) + print("Reply "+resp) + update_wip_message( + client=client, + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=resp, + messages=messages, + user=user_id, + ) + except Timeout: + if wip_reply is not None: + text = ( + ( + wip_reply.get("message", {}).get("text", "") + if wip_reply is not None + else "" + ) + + "\n\n" + + translate( + openai_api_key=openai_api_key, + context=context, + text=TIMEOUT_ERROR_MESSAGE, + ) + ) + client.chat_update( + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=text, + ) + except Exception as e: + text = ( + ( + wip_reply.get("message", {}).get("text", "") + if wip_reply is not None + else "" + ) + + "\n\n" + + f":warning: Failed to reply: {e}" + ) + logger.exception(text, e) + if wip_reply is not None: + client.chat_update( + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=text, + ) + + +def register_listeners(app: App): + app.event("app_mention")(ack=just_ack, lazy=[respond_to_app_mention]) + # app.event("message")(ack=just_ack, lazy=[respond_to_new_message]) + + +MESSAGE_SUBTYPES_TO_SKIP = ["message_changed", "message_deleted"] + + +# To reduce unnecessary workload in this app, +# this before_authorize function skips message changed/deleted events. +# Especially, "message_changed" events can be triggered many times when the app rapidly updates its reply. +def before_authorize( + body: dict, + payload: dict, + logger: logging.Logger, + next_, +): + if ( + is_event(body) + and payload.get("type") == "message" + and payload.get("subtype") in MESSAGE_SUBTYPES_TO_SKIP + ): + logger.debug( + "Skipped the following middleware and listeners " + f"for this message event (subtype: {payload.get('subtype')})" + ) + return BoltResponse(status=200, body="") + next_() diff --git a/examples/slack/app/env.py b/examples/slack/app/env.py new file mode 100644 index 0000000..00099d7 --- /dev/null +++ b/examples/slack/app/env.py @@ -0,0 +1,43 @@ +import os + +DEFAULT_SYSTEM_TEXT = """ +""" + +SYSTEM_TEXT = os.environ.get("OPENAI_SYSTEM_TEXT", DEFAULT_SYSTEM_TEXT) + +DEFAULT_OPENAI_TIMEOUT_SECONDS = 30 +OPENAI_TIMEOUT_SECONDS = int( + os.environ.get("OPENAI_TIMEOUT_SECONDS", DEFAULT_OPENAI_TIMEOUT_SECONDS) +) + +DEFAULT_OPENAI_MODEL = "gpt-3.5-turbo" +OPENAI_MODEL = os.environ.get("OPENAI_MODEL", DEFAULT_OPENAI_MODEL) + +USE_SLACK_LANGUAGE = os.environ.get("USE_SLACK_LANGUAGE", "true") == "true" + +SLACK_APP_LOG_LEVEL = os.environ.get("SLACK_APP_LOG_LEVEL", "DEBUG") + +TRANSLATE_MARKDOWN = os.environ.get("TRANSLATE_MARKDOWN", "false") == "true" + +BASE_PATH = os.environ.get('OPENAI_API_BASE', 'http://localhost:8080/v1') + +EMBEDDINGS_MODEL = os.environ.get('EMBEDDINGS_MODEL', "all-MiniLM-L6-v2") + + +EMBEDDINGS_API_BASE = os.environ.get("EMBEDDINGS_API_BASE", BASE_PATH) +LOCALAI_API_BASE = os.environ.get("LOCALAI_API_BASE", BASE_PATH) +TTS_API_BASE = os.environ.get("TTS_API_BASE", BASE_PATH) +IMAGE_API_BASE = os.environ.get("IMAGES_API_BASE", BASE_PATH) + +STABLEDIFFUSION_MODEL = os.environ.get("STABLEDIFFUSION_MODEL", "dreamshaper") +FUNCTIONS_MODEL = os.environ.get("FUNCTIONS_MODEL", OPENAI_MODEL) +LLM_MODEL = os.environ.get("LLM_MODEL", OPENAI_MODEL) +VOICE_MODEL= os.environ.get("TTS_MODEL", "en-us-kathleen-low.onnx" ) +PERSISTENT_DIR = os.environ.get("PERSISTENT_DIR", "/data") +MILVUS_HOST = os.environ.get("MILVUS_HOST", "") +MILVUS_PORT = os.environ.get("MILVUS_PORT", 0) +MEMORY_COLLECTION = os.environ.get("MEMORY_COLLECTION", "local") +MEMORY_CHUNK_SIZE = os.environ.get("MEMORY_CHUNK_SIZE", 600) +MEMORY_CHUNK_OVERLAP = os.environ.get("MEMORY_RESULTS", 110) +MEMORY_RESULTS = os.environ.get("MEMORY_RESULTS", 3) +MEMORY_SEARCH_TYPE = os.environ.get("MEMORY_SEARCH_TYPE", "mmr") \ No newline at end of file diff --git a/examples/slack/app/i18n.py b/examples/slack/app/i18n.py new file mode 100644 index 0000000..255e70d --- /dev/null +++ b/examples/slack/app/i18n.py @@ -0,0 +1,75 @@ +from typing import Optional + +import openai +from slack_bolt import BoltContext + +from .openai_ops import GPT_3_5_TURBO_0301_MODEL + +# All the supported languages for Slack app as of March 2023 +_locale_to_lang = { + "en-US": "English", + "en-GB": "English", + "de-DE": "German", + "es-ES": "Spanish", + "es-LA": "Spanish", + "fr-FR": "French", + "it-IT": "Italian", + "pt-BR": "Portuguese", + "ru-RU": "Russian", + "ja-JP": "Japanese", + "zh-CN": "Chinese", + "zh-TW": "Chinese", + "ko-KR": "Korean", +} + + +def from_locale_to_lang(locale: Optional[str]) -> Optional[str]: + if locale is None: + return None + return _locale_to_lang.get(locale) + + +_translation_result_cache = {} + + +def translate(*, openai_api_key: str, context: BoltContext, text: str) -> str: + lang = from_locale_to_lang(context.get("locale")) + if lang is None or lang == "English": + return text + + cached_result = _translation_result_cache.get(f"{lang}:{text}") + if cached_result is not None: + return cached_result + response = openai.ChatCompletion.create( + api_key=openai_api_key, + model=GPT_3_5_TURBO_0301_MODEL, + messages=[ + { + "role": "system", + "content": "You're the AI model that primarily focuses on the quality of language translation. " + "You must not change the meaning of sentences when translating them into a different language. " + "You must provide direct translation result as much as possible. " + "When the given text is a single verb/noun, its translated text must be a norm/verb form too. " + "Slack's emoji (e.g., :hourglass_flowing_sand:) and mention parts must be kept as-is. " + "Your response must not include any additional notes in English. " + "Your response must omit English version / pronunciation guide for the result. ", + }, + { + "role": "user", + "content": f"Can you translate {text} into {lang} in a professional tone? " + "Please respond with the only the translated text in a format suitable for Slack user interface. " + "No need to append any English notes and guides.", + }, + ], + top_p=1, + n=1, + max_tokens=1024, + temperature=1, + presence_penalty=0, + frequency_penalty=0, + logit_bias={}, + user="system", + ) + translated_text = response["choices"][0]["message"].get("content") + _translation_result_cache[f"{lang}:{text}"] = translated_text + return translated_text diff --git a/examples/slack/app/markdown.py b/examples/slack/app/markdown.py new file mode 100644 index 0000000..f38619b --- /dev/null +++ b/examples/slack/app/markdown.py @@ -0,0 +1,53 @@ +import re + + +# Conversion from Slack mrkdwn to OpenAI markdown +# See also: https://api.slack.com/reference/surfaces/formatting#basics +def slack_to_markdown(content: str) -> str: + # Split the input string into parts based on code blocks and inline code + parts = re.split(r"(```.+?```|`[^`\n]+?`)", content) + + # Apply the bold, italic, and strikethrough formatting to text not within code + result = "" + for part in parts: + if part.startswith("```") or part.startswith("`"): + result += part + else: + for o, n in [ + (r"\*(?!\s)([^\*\n]+?)(? str: + # Split the input string into parts based on code blocks and inline code + parts = re.split(r"(```.+?```|`[^`\n]+?`)", content) + + # Apply the bold, italic, and strikethrough formatting to text not within code + result = "" + for part in parts: + if part.startswith("```") or part.startswith("`"): + result += part + else: + for o, n in [ + ( + r"\*\*\*(?!\s)([^\*\n]+?)(? str: + if content is None: + return None + + # Unescape &, < and >, since Slack replaces these with their HTML equivalents + # See also: https://api.slack.com/reference/surfaces/formatting#escaping + content = content.replace("<", "<").replace(">", ">").replace("&", "&") + + # Convert from Slack mrkdwn to markdown format + if translate_markdown: + content = slack_to_markdown(content) + + return content + + +def ask_llm( + *, + messages: List[Dict[str, str]], +) -> str: + # Remove old messages to make sure we have room for max_tokens + # See also: https://platform.openai.com/docs/guides/chat/introduction + # > total tokens must be below the model’s maximum limit (4096 tokens for gpt-3.5-turbo-0301) + # TODO: currently we don't pass gpt-4 to this calculation method + while calculate_num_tokens(messages) >= 4096 - MAX_TOKENS: + removed = False + for i, message in enumerate(messages): + if message["role"] in ("user", "assistant"): + del messages[i] + removed = True + break + if not removed: + # Fall through and let the OpenAI error handler deal with it + break + + prompt="" + + for i, message in enumerate(messages): + prompt += message["content"] + "\n" + + return localagi(prompt) + +def consume_openai_stream_to_write_reply( + *, + client: WebClient, + wip_reply: dict, + context: BoltContext, + user_id: str, + messages: List[Dict[str, str]], + steam: Generator[OpenAIObject, Any, None], + timeout_seconds: int, + translate_markdown: bool, +): + start_time = time.time() + assistant_reply: Dict[str, str] = {"role": "assistant", "content": ""} + messages.append(assistant_reply) + word_count = 0 + threads = [] + try: + loading_character = " ... :writing_hand:" + for chunk in steam: + spent_seconds = time.time() - start_time + if timeout_seconds < spent_seconds: + raise Timeout() + item = chunk.choices[0] + if item.get("finish_reason") is not None: + break + delta = item.get("delta") + if delta.get("content") is not None: + word_count += 1 + assistant_reply["content"] += delta.get("content") + if word_count >= 20: + + def update_message(): + assistant_reply_text = format_assistant_reply( + assistant_reply["content"], translate_markdown + ) + wip_reply["message"]["text"] = assistant_reply_text + update_wip_message( + client=client, + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=assistant_reply_text + loading_character, + messages=messages, + user=user_id, + ) + + thread = threading.Thread(target=update_message) + thread.daemon = True + thread.start() + threads.append(thread) + word_count = 0 + + for t in threads: + try: + if t.is_alive(): + t.join() + except Exception: + pass + + assistant_reply_text = format_assistant_reply( + assistant_reply["content"], translate_markdown + ) + wip_reply["message"]["text"] = assistant_reply_text + update_wip_message( + client=client, + channel=context.channel_id, + ts=wip_reply["message"]["ts"], + text=assistant_reply_text, + messages=messages, + user=user_id, + ) + finally: + for t in threads: + try: + if t.is_alive(): + t.join() + except Exception: + pass + try: + steam.close() + except Exception: + pass + + +def calculate_num_tokens( + messages: List[Dict[str, str]], + # TODO: adjustment for gpt-4 + model: str = GPT_3_5_TURBO_0301_MODEL, +) -> int: + """Returns the number of tokens used by a list of messages.""" + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + encoding = tiktoken.get_encoding("cl100k_base") + if model == GPT_3_5_TURBO_0301_MODEL: + # note: future models may deviate from this + num_tokens = 0 + for message in messages: + # every message follows {role/name}\n{content}\n + num_tokens += 4 + for key, value in message.items(): + num_tokens += len(encoding.encode(value)) + if key == "name": # if there's a name, the role is omitted + num_tokens += -1 # role is always required and always 1 token + num_tokens += 2 # every reply is primed with assistant + return num_tokens + else: + error = ( + f"Calculating the number of tokens for for model {model} is not yet supported. " + "See https://github.com/openai/openai-python/blob/main/chatml.md " + "for information on how messages are converted to tokens." + ) + raise NotImplementedError(error) + + +# Format message from OpenAI to display in Slack +def format_assistant_reply(content: str, translate_markdown: bool) -> str: + for o, n in [ + # Remove leading newlines + ("^\n+", ""), + # Remove prepended Slack user ID + ("^<@U.*?>\\s?:\\s?", ""), + # Remove OpenAI syntax tags since Slack doesn't render them in a message + ("```\\s*[Rr]ust\n", "```\n"), + ("```\\s*[Rr]uby\n", "```\n"), + ("```\\s*[Ss]cala\n", "```\n"), + ("```\\s*[Kk]otlin\n", "```\n"), + ("```\\s*[Jj]ava\n", "```\n"), + ("```\\s*[Gg]o\n", "```\n"), + ("```\\s*[Ss]wift\n", "```\n"), + ("```\\s*[Oo]objective[Cc]\n", "```\n"), + ("```\\s*[Cc]\n", "```\n"), + ("```\\s*[Cc][+][+]\n", "```\n"), + ("```\\s*[Cc][Pp][Pp]\n", "```\n"), + ("```\\s*[Cc]sharp\n", "```\n"), + ("```\\s*[Mm]atlab\n", "```\n"), + ("```\\s*[Jj][Ss][Oo][Nn]\n", "```\n"), + ("```\\s*[Ll]a[Tt]e[Xx]\n", "```\n"), + ("```\\s*bash\n", "```\n"), + ("```\\s*zsh\n", "```\n"), + ("```\\s*sh\n", "```\n"), + ("```\\s*[Ss][Qq][Ll]\n", "```\n"), + ("```\\s*[Pp][Hh][Pp]\n", "```\n"), + ("```\\s*[Pp][Ee][Rr][Ll]\n", "```\n"), + ("```\\s*[Jj]ava[Ss]cript", "```\n"), + ("```\\s*[Ty]ype[Ss]cript", "```\n"), + ("```\\s*[Pp]ython\n", "```\n"), + ]: + content = re.sub(o, n, content) + + # Convert from OpenAI markdown to Slack mrkdwn format + if translate_markdown: + content = markdown_to_slack(content) + + return content + + +def build_system_text( + system_text_template: str, translate_markdown: bool, context: BoltContext +): + system_text = system_text_template.format(bot_user_id=context.bot_user_id) + # Translate format hint in system prompt + if translate_markdown is True: + system_text = slack_to_markdown(system_text) + return system_text diff --git a/examples/slack/app/slack_ops.py b/examples/slack/app/slack_ops.py new file mode 100644 index 0000000..8a33837 --- /dev/null +++ b/examples/slack/app/slack_ops.py @@ -0,0 +1,110 @@ +from typing import Optional +from typing import List, Dict + +from slack_sdk.web import WebClient, SlackResponse +from slack_bolt import BoltContext + +# ---------------------------- +# General operations in a channel +# ---------------------------- + + +def find_parent_message( + client: WebClient, channel_id: Optional[str], thread_ts: Optional[str] +) -> Optional[dict]: + if channel_id is None or thread_ts is None: + return None + + messages = client.conversations_history( + channel=channel_id, + latest=thread_ts, + limit=1, + inclusive=1, + ).get("messages", []) + + return messages[0] if len(messages) > 0 else None + + +def is_no_mention_thread(context: BoltContext, parent_message: dict) -> bool: + parent_message_text = parent_message.get("text", "") + return f"<@{context.bot_user_id}>" in parent_message_text + + +# ---------------------------- +# WIP reply message stuff +# ---------------------------- + + +def post_wip_message( + *, + client: WebClient, + channel: str, + thread_ts: str, + loading_text: str, + messages: List[Dict[str, str]], + user: str, +) -> SlackResponse: + system_messages = [msg for msg in messages if msg["role"] == "system"] + return client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=loading_text, + metadata={ + "event_type": "chat-gpt-convo", + "event_payload": {"messages": system_messages, "user": user}, + }, + ) + + +def update_wip_message( + client: WebClient, + channel: str, + ts: str, + text: str, + messages: List[Dict[str, str]], + user: str, +) -> SlackResponse: + system_messages = [msg for msg in messages if msg["role"] == "system"] + return client.chat_update( + channel=channel, + ts=ts, + text=text, + metadata={ + "event_type": "chat-gpt-convo", + "event_payload": {"messages": system_messages, "user": user}, + }, + ) + + +# ---------------------------- +# Home tab +# ---------------------------- + +DEFAULT_HOME_TAB_MESSAGE = ( + "To enable this app in this Slack workspace, you need to save your OpenAI API key. " + "Visit to grap your key!" +) + +DEFAULT_HOME_TAB_CONFIGURE_LABEL = "Configure" + + +def build_home_tab(message: str, configure_label: str) -> dict: + return { + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": message, + }, + "accessory": { + "action_id": "configure", + "type": "button", + "text": {"type": "plain_text", "text": configure_label}, + "style": "primary", + "value": "api_key", + }, + } + ], + } diff --git a/examples/slack/entrypoint.sh b/examples/slack/entrypoint.sh new file mode 100755 index 0000000..fb8ca20 --- /dev/null +++ b/examples/slack/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cd /app + +pip uninstall hnswlib -y + +git clone https://github.com/nmslib/hnswlib.git +cd hnswlib +pip install . +cd .. + +python main.py \ No newline at end of file diff --git a/examples/slack/main.py b/examples/slack/main.py new file mode 100644 index 0000000..3c0d71b --- /dev/null +++ b/examples/slack/main.py @@ -0,0 +1,69 @@ +import logging +import os + +from slack_bolt import App, BoltContext +from slack_sdk.web import WebClient +from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler + +from app.bolt_listeners import before_authorize, register_listeners +from app.env import * +from app.slack_ops import ( + build_home_tab, + DEFAULT_HOME_TAB_MESSAGE, + DEFAULT_HOME_TAB_CONFIGURE_LABEL, +) +from app.i18n import translate + +if __name__ == "__main__": + from slack_bolt.adapter.socket_mode import SocketModeHandler + + logging.basicConfig(level=SLACK_APP_LOG_LEVEL) + + app = App( + token=os.environ["SLACK_BOT_TOKEN"], + before_authorize=before_authorize, + process_before_response=True, + ) + app.client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=2)) + + register_listeners(app) + + @app.event("app_home_opened") + def render_home_tab(client: WebClient, context: BoltContext): + already_set_api_key = os.environ["OPENAI_API_KEY"] + text = translate( + openai_api_key=already_set_api_key, + context=context, + text=DEFAULT_HOME_TAB_MESSAGE, + ) + configure_label = translate( + openai_api_key=already_set_api_key, + context=context, + text=DEFAULT_HOME_TAB_CONFIGURE_LABEL, + ) + client.views_publish( + user_id=context.user_id, + view=build_home_tab(text, configure_label), + ) + + if USE_SLACK_LANGUAGE is True: + + @app.middleware + def set_locale( + context: BoltContext, + client: WebClient, + next_, + ): + user_id = context.actor_user_id or context.user_id + user_info = client.users_info(user=user_id, include_locale=True) + context["locale"] = user_info.get("user", {}).get("locale") + next_() + + @app.middleware + def set_openai_api_key(context: BoltContext, next_): + context["OPENAI_API_KEY"] = os.environ["OPENAI_API_KEY"] + context["OPENAI_MODEL"] = OPENAI_MODEL + next_() + + handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + handler.start() diff --git a/examples/slack/main_prod.py b/examples/slack/main_prod.py new file mode 100644 index 0000000..45a9631 --- /dev/null +++ b/examples/slack/main_prod.py @@ -0,0 +1,306 @@ +# Unzip the dependencies managed by serverless-python-requirements +try: + import unzip_requirements # type:ignore +except ImportError: + pass + +# +# Imports +# + +import json +import logging +import os +import openai + +from slack_sdk.web import WebClient +from slack_sdk.errors import SlackApiError +from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler +from slack_bolt import App, Ack, BoltContext + +from app.bolt_listeners import register_listeners, before_authorize +from app.env import USE_SLACK_LANGUAGE, SLACK_APP_LOG_LEVEL, DEFAULT_OPENAI_MODEL +from app.slack_ops import ( + build_home_tab, + DEFAULT_HOME_TAB_MESSAGE, + DEFAULT_HOME_TAB_CONFIGURE_LABEL, +) +from app.i18n import translate + +# +# Product deployment (AWS Lambda) +# +# export SLACK_CLIENT_ID= +# export SLACK_CLIENT_SECRET= +# export SLACK_SIGNING_SECRET= +# export SLACK_SCOPES=app_mentions:read,channels:history,groups:history,im:history,mpim:history,chat:write.public,chat:write,users:read +# export SLACK_INSTALLATION_S3_BUCKET_NAME= +# export SLACK_STATE_S3_BUCKET_NAME= +# export OPENAI_S3_BUCKET_NAME= +# npm install -g serverless +# serverless plugin install -n serverless-python-requirements +# serverless deploy +# + +import boto3 +from slack_bolt.adapter.aws_lambda import SlackRequestHandler +from slack_bolt.adapter.aws_lambda.lambda_s3_oauth_flow import LambdaS3OAuthFlow + +SlackRequestHandler.clear_all_log_handlers() +logging.basicConfig(format="%(asctime)s %(message)s", level=SLACK_APP_LOG_LEVEL) + +s3_client = boto3.client("s3") +openai_bucket_name = os.environ["OPENAI_S3_BUCKET_NAME"] + +client_template = WebClient() +client_template.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=2)) + + +def register_revocation_handlers(app: App): + # Handle uninstall events and token revocations + @app.event("tokens_revoked") + def handle_tokens_revoked_events( + event: dict, + context: BoltContext, + logger: logging.Logger, + ): + user_ids = event.get("tokens", {}).get("oauth", []) + if len(user_ids) > 0: + for user_id in user_ids: + app.installation_store.delete_installation( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + user_id=user_id, + ) + bots = event.get("tokens", {}).get("bot", []) + if len(bots) > 0: + app.installation_store.delete_bot( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + try: + s3_client.delete_object(Bucket=openai_bucket_name, Key=context.team_id) + except Exception as e: + logger.error( + f"Failed to delete an OpenAI auth key: (team_id: {context.team_id}, error: {e})" + ) + + @app.event("app_uninstalled") + def handle_app_uninstalled_events( + context: BoltContext, + logger: logging.Logger, + ): + app.installation_store.delete_all( + enterprise_id=context.enterprise_id, + team_id=context.team_id, + ) + try: + s3_client.delete_object(Bucket=openai_bucket_name, Key=context.team_id) + except Exception as e: + logger.error( + f"Failed to delete an OpenAI auth key: (team_id: {context.team_id}, error: {e})" + ) + + +def handler(event, context_): + app = App( + process_before_response=True, + before_authorize=before_authorize, + oauth_flow=LambdaS3OAuthFlow(), + client=client_template, + ) + app.oauth_flow.settings.install_page_rendering_enabled = False + register_listeners(app) + register_revocation_handlers(app) + + if USE_SLACK_LANGUAGE is True: + + @app.middleware + def set_locale( + context: BoltContext, + client: WebClient, + logger: logging.Logger, + next_, + ): + bot_scopes = context.authorize_result.bot_scopes + if bot_scopes is not None and "users:read" in bot_scopes: + user_id = context.actor_user_id or context.user_id + try: + user_info = client.users_info(user=user_id, include_locale=True) + context["locale"] = user_info.get("user", {}).get("locale") + except SlackApiError as e: + logger.debug(f"Failed to fetch user info due to {e}") + pass + next_() + + @app.middleware + def set_s3_openai_api_key(context: BoltContext, next_): + try: + s3_response = s3_client.get_object( + Bucket=openai_bucket_name, Key=context.team_id + ) + config_str: str = s3_response["Body"].read().decode("utf-8") + if config_str.startswith("{"): + config = json.loads(config_str) + context["OPENAI_API_KEY"] = config.get("api_key") + context["OPENAI_MODEL"] = config.get("model") + else: + # The legacy data format + context["OPENAI_API_KEY"] = config_str + context["OPENAI_MODEL"] = DEFAULT_OPENAI_MODEL + except: # noqa: E722 + context["OPENAI_API_KEY"] = None + next_() + + @app.event("app_home_opened") + def render_home_tab(client: WebClient, context: BoltContext): + message = DEFAULT_HOME_TAB_MESSAGE + configure_label = DEFAULT_HOME_TAB_CONFIGURE_LABEL + try: + s3_client.get_object(Bucket=openai_bucket_name, Key=context.team_id) + message = "This app is ready to use in this workspace :raised_hands:" + except: # noqa: E722 + pass + + openai_api_key = context.get("OPENAI_API_KEY") + if openai_api_key is not None: + message = translate( + openai_api_key=openai_api_key, context=context, text=message + ) + configure_label = translate( + openai_api_key=openai_api_key, + context=context, + text=DEFAULT_HOME_TAB_CONFIGURE_LABEL, + ) + + client.views_publish( + user_id=context.user_id, + view=build_home_tab(message, configure_label), + ) + + @app.action("configure") + def handle_some_action(ack, body: dict, client: WebClient, context: BoltContext): + ack() + already_set_api_key = context.get("OPENAI_API_KEY") + api_key_text = "Save your OpenAI API key:" + submit = "Submit" + cancel = "Cancel" + if already_set_api_key is not None: + api_key_text = translate( + openai_api_key=already_set_api_key, context=context, text=api_key_text + ) + submit = translate( + openai_api_key=already_set_api_key, context=context, text=submit + ) + cancel = translate( + openai_api_key=already_set_api_key, context=context, text=cancel + ) + + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "configure", + "title": {"type": "plain_text", "text": "OpenAI API Key"}, + "submit": {"type": "plain_text", "text": submit}, + "close": {"type": "plain_text", "text": cancel}, + "blocks": [ + { + "type": "input", + "block_id": "api_key", + "label": {"type": "plain_text", "text": api_key_text}, + "element": {"type": "plain_text_input", "action_id": "input"}, + }, + { + "type": "input", + "block_id": "model", + "label": {"type": "plain_text", "text": "OpenAI Model"}, + "element": { + "type": "static_select", + "action_id": "input", + "options": [ + { + "text": { + "type": "plain_text", + "text": "GPT-3.5 Turbo", + }, + "value": "gpt-3.5-turbo", + }, + { + "text": {"type": "plain_text", "text": "GPT-4"}, + "value": "gpt-4", + }, + ], + "initial_option": { + "text": { + "type": "plain_text", + "text": "GPT-3.5 Turbo", + }, + "value": "gpt-3.5-turbo", + }, + }, + }, + ], + }, + ) + + def validate_api_key_registration(ack: Ack, view: dict, context: BoltContext): + already_set_api_key = context.get("OPENAI_API_KEY") + + inputs = view["state"]["values"] + api_key = inputs["api_key"]["input"]["value"] + model = inputs["model"]["input"]["selected_option"]["value"] + try: + # Verify if the API key is valid + openai.Model.retrieve(api_key=api_key, id="gpt-3.5-turbo") + try: + # Verify if the given model works with the API key + openai.Model.retrieve(api_key=api_key, id=model) + except Exception: + text = "This model is not yet available for this API key" + if already_set_api_key is not None: + text = translate( + openai_api_key=already_set_api_key, context=context, text=text + ) + ack( + response_action="errors", + errors={"model": text}, + ) + return + ack() + except Exception: + text = "This API key seems to be invalid" + if already_set_api_key is not None: + text = translate( + openai_api_key=already_set_api_key, context=context, text=text + ) + ack( + response_action="errors", + errors={"api_key": text}, + ) + + def save_api_key_registration( + view: dict, + logger: logging.Logger, + context: BoltContext, + ): + inputs = view["state"]["values"] + api_key = inputs["api_key"]["input"]["value"] + model = inputs["model"]["input"]["selected_option"]["value"] + try: + openai.Model.retrieve(api_key=api_key, id=model) + s3_client.put_object( + Bucket=openai_bucket_name, + Key=context.team_id, + Body=json.dumps({"api_key": api_key, "model": model}), + ) + except Exception as e: + logger.exception(e) + + app.view("configure")( + ack=validate_api_key_registration, + lazy=[save_api_key_registration], + ) + + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(event, context_) diff --git a/examples/slack/manifest-dev.yml b/examples/slack/manifest-dev.yml new file mode 100644 index 0000000..24fc849 --- /dev/null +++ b/examples/slack/manifest-dev.yml @@ -0,0 +1,32 @@ +display_information: + name: ChatGPT (dev) +features: + app_home: + home_tab_enabled: false + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: ChatGPT Bot (dev) + always_online: true +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - groups:history + - im:history + - mpim:history + - chat:write.public + - chat:write + - users:read +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + interactivity: + is_enabled: true + socket_mode_enabled: true diff --git a/examples/slack/manifest-prod.yml b/examples/slack/manifest-prod.yml new file mode 100644 index 0000000..1734601 --- /dev/null +++ b/examples/slack/manifest-prod.yml @@ -0,0 +1,43 @@ +display_information: + name: ChatGPT + description: Interact with ChatGPT in Slack! + background_color: "#195208" +features: + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: ChatGPT Bot + always_online: true +oauth_config: + redirect_urls: + - https://TODO.amazonaws.com/slack/oauth_redirect + scopes: + bot: + - app_mentions:read + - channels:history + - groups:history + - im:history + - mpim:history + - chat:write.public + - chat:write + - users:read +settings: + event_subscriptions: + request_url: https://TODO.amazonaws.com/slack/events + bot_events: + - app_home_opened + - app_mention + - app_uninstalled + - message.channels + - message.groups + - message.im + - message.mpim + - tokens_revoked + interactivity: + is_enabled: true + request_url: https://TODO.amazonaws.com/slack/events + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false diff --git a/examples/slack/requirements.txt b/examples/slack/requirements.txt new file mode 100644 index 0000000..dc7a0c2 --- /dev/null +++ b/examples/slack/requirements.txt @@ -0,0 +1,15 @@ +slack-bolt>=1.18.0,<2 +lxml==4.9.2 +bs4==0.0.1 +openai>=0.27.4,<0.28 +tiktoken>=0.3.3,<0.4 +chromadb==0.3.23 +langchain==0.0.242 +GitPython==3.1.31 +InstructorEmbedding +loguru +git+https://github.com/mudler/LocalAGI +pysqlite3-binary +requests +ascii-magic +duckduckgo_search \ No newline at end of file diff --git a/examples/slack/run.sh b/examples/slack/run.sh new file mode 100644 index 0000000..6fa435c --- /dev/null +++ b/examples/slack/run.sh @@ -0,0 +1,2 @@ +docker build -t slack-bot . +docker run -v $PWD/data:/data --rm -ti --env-file .dockerenv slack-bot \ No newline at end of file