From b3bfb60ef9cf981b16a3ad92d8078830d36a119a Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Wed, 29 Mar 2023 01:27:43 +0300 Subject: [PATCH] Add OpenAI conversational model Signed-off-by: Nikolaos Karaolidis --- Dockerfile | 1 + README.md | 24 +++++++- docker-compose.yml | 2 + requirements.txt | 3 + src/handlers.py | 107 ----------------------------------- src/handlers/conversation.py | 94 ++++++++++++++++++++++++++++++ src/handlers/thread.py | 21 +++++++ src/main.py | 98 +++++++++++++++++++++++++++++--- src/util/database.py | 10 ++-- src/util/session.py | 7 ++- 10 files changed, 244 insertions(+), 123 deletions(-) delete mode 100644 src/handlers.py create mode 100644 src/handlers/conversation.py create mode 100644 src/handlers/thread.py diff --git a/Dockerfile b/Dockerfile index b756268..0792a93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,5 +6,6 @@ COPY requirements.txt . RUN pip install -r requirements.txt COPY ./src . +RUN mkdir /app/data CMD ["python", "main.py"] diff --git a/README.md b/README.md index 6b4baea..3e73e46 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,26 @@ # admina -A Facebook messenger bot powered by OpenAI and Stable Diffusion. +A Facebook messenger bot powered by OpenAI and Stable Diffusion. Designed to provide intelligent and human-like conversations with users. +## Running + +You can easily run the bot by using [Docker](https://www.docker.com/). You can also run it without Docker, but you will need to install the dependencies manually. + +```bash +docker-compose build +docker-compose up +``` + +`docker-compose.yml` references an `.env` file that contains the environment variables: + +| Variable | Description | +| ---------------- | ---------------------------------------------------------------- | +| `FB_EMAIL` | Facebook email address | +| `FB_PASSWORD` | Facebook password | +| `FB_COOKIE_PATH` | Path to saved Facebook cookies (default: /app/data/session.json) | +| `MONGO_HOST` | MongoDB host (default: db) | +| `MONGO_PORT` | MongoDB port (default: 27017) | +| `MONGO_USERNAME` | MongoDB username (default: admina) | +| `MONGO_PASSWORD` | MongoDB password (default: admina) | +| `MONGO_DB` | MongoDB database name (default: admina) | +| `OPENAI_API_KEY` | OpenAI API key | diff --git a/docker-compose.yml b/docker-compose.yml index 62cd630..5c94aff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - db env_file: - .env + volumes: + - ./data:/app/data db: image: mongo:latest diff --git a/requirements.txt b/requirements.txt index 623861c..5ec760a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ blinker==1.5 certifi==2022.12.7 charset-normalizer==3.1.0 dnspython==2.3.0 +docutils==0.19 fbchat @ git+https://git.karaolidis.com/Nikas36/fbchat.git@2fa1b58336731a5de923018bcb9ec4488eeeb923 frozenlist==1.3.3 idna==3.4 @@ -16,8 +17,10 @@ openai==0.27.2 paho-mqtt==1.6.1 pycodestyle==2.10.0 pymongo==4.3.3 +regex==2023.3.23 requests==2.28.2 soupsieve==2.4 +tiktoken==0.3.2 tomli==2.0.1 tqdm==4.65.0 urllib3==1.26.15 diff --git a/src/handlers.py b/src/handlers.py deleted file mode 100644 index a8700e5..0000000 --- a/src/handlers.py +++ /dev/null @@ -1,107 +0,0 @@ -import fbchat -from util.logger import logger -from util.database import database -from util.session import session - - -def activate_thread(thread: fbchat.Thread): - thread_db = database.threads.update_one( - {"_id": thread.id}, {"$setOnInsert": { - "type": "group" if isinstance(thread, fbchat.Group) else "user" if isinstance(thread, fbchat.User) else "other", - "messages": [] - }}, upsert=True) - - database.threads.create_index( - "messages.created_at", expireAfterSeconds=900) - - thread.send_text("> Admina activated in thread") - return thread_db - - -def deactivate_thread(thread: fbchat.Thread): - thread.send_text("> Admina deactivated in thread") - return database.threads.delete_one({"_id": thread.id}) - - -COMMANDS = { - "ping": { - "description": "Pong!", - "usage": "!ping", - "admin_only": False, - "handler": lambda event: event.thread.send_text("Pong!"), - }, - "activate": { - "description": "Activate a thread", - "usage": "!activate", - "admin_only": False, - "handler": lambda event: activate_thread(event.thread), - }, - "deactivate": { - "description": "Deactivate a thread", - "usage": "!deactivate", - "admin_only": True, - "handler": lambda event: deactivate_thread(event.thread), - }, -} - - -def handle_message(_, event: fbchat.MessageEvent): - if not event.message.text: - return - - if not database.threads.find_one({"_id": event.thread.id}): - if not event.message.text.lower().startswith("!activate"): - return - - return COMMANDS["activate"]["handler"](event) - - database.threads.update_one( - {"_id": event.thread.id}, {"$push": { - "messages": { - "id": event.message.id, - "role": - "assistant" if event.message.author == session.user.id and event.message.text.startswith("#>") - else "user" if "@admina" in event.message.text.lower() - else None, - "author": event.message.author, - "created_at": event.message.created_at.timestamp(), - "text": event.message.text, - "attachments": [{ - "url": attachment.url, - "original_url": attachment.original_url, - "title": attachment.title, - "description": attachment.description, - "source": attachment.source, - "image": attachment.image.url if attachment.image else None, - "original_image_url": attachment.original_image_url, - } for attachment in event.message.attachments] - } - }}) - - logger.info( - f"Received message from {event.message.author} in {event.thread.id}" - ) - - if event.message.text.startswith("!"): - command = event.message.text[1:].split(" ") - - if command[0].lower() not in COMMANDS: - return - - if COMMANDS[command[0].lower()]["admin_only"]: - if not isinstance(event.thread, fbchat.Group): - return event.thread.send_text("> This command can only be used in groups") - - if session.user.id not in event.thread.admins: - return event.thread.send_text("> Account running the bot must be an admin to use this command") - - if event.message.author not in event.thread.admins: - return event.thread.send_text("> You must be an admin to use this command") - - return COMMANDS[command[0]]["handler"](event) - - if "@everyone" in event.message.text.lower(): - return event.thread.send_text(">TODO: @everyone") - - if "@admina" in event.message.text.lower(): - return event.thread.send_text(">TODO: @admina") diff --git a/src/handlers/conversation.py b/src/handlers/conversation.py new file mode 100644 index 0000000..014731a --- /dev/null +++ b/src/handlers/conversation.py @@ -0,0 +1,94 @@ +import fbchat +import openai +import tiktoken +from util.database import database +from util.logger import logger +from util.session import client + + +def count_tokens(message): + encoding = tiktoken.encoding_for_model("gpt-3.5-turbo") + + return len(encoding.encode(message)) + 20 + + +def check_safe(message): + response = openai.Moderation.create( + input=message, + ) + + return not response['results'][0]['flagged'], max(response['results'][0]['category_scores']) + + +def generate_prompt(event: fbchat.MessageEvent): + total_tokens = 0 + + system_prompt = [{ + "role": "system", + "content": "You are now Admina, a chill e-girl AI. Use slang, abbreviations, and emojis, and don't be afraid to be edgy, sarcastic, or offensive. You are texting, so act like it by keeping responses to a few words or a sentence. Stay in character at all times." + }] + + total_tokens += count_tokens(system_prompt[0]["content"]) + + messages = database.threads.aggregate([ + {"$match": {"_id": event.thread.id}}, + {"$project": {"messages": {"$filter": {"input": "$messages", + "as": "msg", "cond": {"$ne": ["$$msg.role", None]}}}}} + ]).next()["messages"] + + chat_prompt = [] + + while messages: + message = messages.pop() + + author = client.fetch_thread_info([message["author"]])[0].name + + total_tokens += count_tokens(message["text"]) + + if total_tokens > 2000: + break + + chat_prompt.append({ + "role": message["role"], + "content": f"[{author}]: {message['text']}", + }) + + if len(chat_prompt) == 0: + return None + + return system_prompt + chat_prompt[::-1] + + +def handle_conversation(event: fbchat.MessageEvent): + event.thread.start_typing() + + logger.info( + f"Received conversation message from {event.message.author} in {event.thread.id}" + ) + + prompt = generate_prompt(event) + + if not prompt: + return event.thread.send_text("#> No prompt was generated. Perhaps your message was too long?") + + logger.info( + f"Generated prompt for {event.message.author} in {event.thread.id}: {prompt}" + ) + + response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=prompt, + presence_penalty=0.25, + frequency_penalty=0.25, + )['choices'][0]['message']['content'] + + if not response.startswith("#> "): + response = "#> " + response + + logger.info( + f"Generated response for {event.message.author} in {event.thread.id}: {response}" + ) + + event.thread.stop_typing() + + return event.thread.send_text(response['choices'][0]['message']['content'], reply_to_id=event.message.id) diff --git a/src/handlers/thread.py b/src/handlers/thread.py new file mode 100644 index 0000000..763591b --- /dev/null +++ b/src/handlers/thread.py @@ -0,0 +1,21 @@ +import fbchat +from util.database import database + + +def activate_thread(event: fbchat.MessageEvent): + thread_db = database.threads.update_one( + {"_id": event.thread.id}, {"$setOnInsert": { + "type": "group" if isinstance(event.thread, fbchat.Group) else "user" if isinstance(event.thread, fbchat.User) else "other", + "messages": [] + }}, upsert=True) + + database.threads.create_index( + "messages.created_at", expireAfterSeconds=900) + + event.thread.send_text("> Admina activated in thread", reply_to_id=event.message.id) + return thread_db + + +def deactivate_thread(event: fbchat.MessageEvent): + event.thread.send_text("> Admina deactivated in thread") + return database.threads.delete_one({"_id": event.thread.id}) diff --git a/src/main.py b/src/main.py index 4ecf97b..e942d20 100644 --- a/src/main.py +++ b/src/main.py @@ -1,12 +1,97 @@ import fbchat -from handlers import handle_message from util.logger import logger -from util.session import session - -import atexit +from util.session import session, listener, client +from util.database import database +from handlers.conversation import handle_conversation +from handlers.thread import activate_thread, deactivate_thread from blinker import Signal from threading import Thread -from os import environ + + +COMMANDS = { + "ping": { + "description": "Pong!", + "usage": "!ping", + "admin_only": False, + "handler": lambda event: event.thread.send_text("Pong!", reply_to_id=event.message.id), + }, + "activate": { + "description": "Activate a thread", + "usage": "!activate", + "admin_only": False, + "handler": lambda event: activate_thread(event), + }, + "deactivate": { + "description": "Deactivate a thread", + "usage": "!deactivate", + "admin_only": True, + "handler": lambda event: deactivate_thread(event), + }, +} + + +def handle_message(_, event: fbchat.MessageEvent): + if not event.message.text: + return + + if not database.threads.find_one({"_id": event.thread.id}): + if not event.message.text.lower().startswith("!activate"): + return + + return COMMANDS["activate"]["handler"](event) + + database.threads.update_one( + {"_id": event.thread.id}, {"$push": { + "messages": { + "id": event.message.id, + "role": + "assistant" if event.message.author == session.user.id and event.message.text.startswith("#>") + else "user" if "@admina" in event.message.text.lower() + else None, + "author": event.message.author, + "created_at": event.message.created_at.timestamp(), + "text": event.message.text, + "attachments": [{ + "url": attachment.url, + "original_url": attachment.original_url, + "title": attachment.title, + "description": attachment.description, + "source": attachment.source, + "image": attachment.image.url if attachment.image else None, + "original_image_url": attachment.original_image_url, + } for attachment in event.message.attachments] + } + }}) + + logger.info( + f"Received message from {event.message.author} in {event.thread.id}" + ) + + if event.message.text.startswith("!"): + command = event.message.text[1:].split(" ") + + if command[0].lower() not in COMMANDS: + return + + if COMMANDS[command[0].lower()]["admin_only"]: + if not isinstance(event.thread, fbchat.Group): + return event.thread.send_text("> This command can only be used in groups", reply_to_id=event.message.id) + + thread_info = client.fetch_thread_info([event.thread.id])[0] + + if session.user.id not in thread_info.admins: + return event.thread.send_text("> Account running the bot must be an admin to use this command", reply_to_id=event.message.id) + + if event.message.author not in thread_info.admins: + return event.thread.send_text("> You must be an admin to use this command", reply_to_id=event.message.id) + + return COMMANDS[command[0]]["handler"](event) + + if "@everyone" in event.message.text.lower(): + pass + + if "@admina" in event.message.text.lower(): + return handle_conversation(event) def on_event(sender, event): @@ -19,9 +104,6 @@ def on_event(sender, event): def main(): - listener = fbchat.Listener( - session=session, chat_on=False, foreground=False) - events = Signal() events.connect(on_event) diff --git a/src/util/database.py b/src/util/database.py index f1a38c7..50dc77e 100644 --- a/src/util/database.py +++ b/src/util/database.py @@ -1,11 +1,11 @@ import pymongo from os import environ -MONGO_HOST = environ.get("MONGO_HOST") -MONGO_PORT = environ.get("MONGO_PORT") -MONGO_USERNAME = environ.get("MONGO_USERNAME") -MONGO_PASSWORD = environ.get("MONGO_PASSWORD") -MONGO_DATABASE = environ.get("MONGO_DATABASE") +MONGO_HOST = environ.get("MONGO_HOST") or "db" +MONGO_PORT = environ.get("MONGO_PORT") or "27017" +MONGO_USERNAME = environ.get("MONGO_USERNAME") or "admina" +MONGO_PASSWORD = environ.get("MONGO_PASSWORD") or "admina" +MONGO_DATABASE = environ.get("MONGO_DATABASE") or "admina" if not MONGO_HOST or not MONGO_PORT or not MONGO_USERNAME or not MONGO_PASSWORD or not MONGO_DATABASE: raise ValueError( diff --git a/src/util/session.py b/src/util/session.py index c899527..cd93a50 100644 --- a/src/util/session.py +++ b/src/util/session.py @@ -5,7 +5,7 @@ from os import environ FB_EMAIL = environ.get("FB_EMAIL") FB_PASSWORD = environ.get("FB_PASSWORD") -FB_COOKIE_PATH = environ.get("FB_COOKIE_PATH") or "session.json" +FB_COOKIE_PATH = environ.get("FB_COOKIE_PATH") or "/app/data/session.json" if not FB_EMAIL or not FB_PASSWORD: raise ValueError("FB_EMAIL and FB_PASSWORD must be set") @@ -21,11 +21,14 @@ except (FileNotFoundError, fbchat.FacebookError): FB_EMAIL, FB_PASSWORD, on_2fa_callback=lambda: input( "Input 2FA code: ") ) - session.session.to_file(FB_COOKIE_PATH) + session.to_file(FB_COOKIE_PATH) logger.info("Saved cookies to %s", FB_COOKIE_PATH) finally: if session.is_logged_in(): + client = fbchat.Client(session=session) + listener = fbchat.Listener( + session=session, chat_on=False, foreground=False) logger.info("Logged in as %s", session.user.id) else: raise ValueError("Failed to log in")