commit 67779b8335fda85472247f577e587d74360dddaf Author: Kumi Date: Sun Apr 16 14:08:57 2023 +0000 Initial working version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28912b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.db +config.ini +venv/ +*.pyc +__pycache__/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18d99e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Kumi Mitterer + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..285f82c --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# GPTbot + +GPTbot is a simple bot that uses the [OpenAI ChatCompletion API](https://platform.openai.com/docs/guides/chat) +to generate responses to messages in a Matrix room. + +It will also save a log of the spent tokens to a sqlite database +(token_usage.db in the working directory). + +## Installation + +Simply clone this repository and install the requirements. + +### Requirements + +* Python 3.8 or later +* Requirements from `requirements.txt` (install with `pip install -r requirements.txt` in a venv) + +### Configuration + +The bot requires a configuration file to be present in the working directory. +Copy the provided `config.dist.ini` to `config.ini` and edit it to your needs. + +## Running + +The bot can be run with `python -m gptbot`. If required, activate a venv first. + +You may want to run the bot in a screen or tmux session, or use a process +manager like systemd. + +## License + +This project is licensed under the terms of the MIT license. \ No newline at end of file diff --git a/config.dist.ini b/config.dist.ini new file mode 100644 index 0000000..7cf33ff --- /dev/null +++ b/config.dist.ini @@ -0,0 +1,8 @@ +[OpenAI] +Model = gpt-3.5-turbo +APIKey = sk-yoursecretkey + +[Matrix] +Homeserver = https://matrix.local +AccessToken = syt_yoursynapsetoken +UserID = @gptbot:matrix.local \ No newline at end of file diff --git a/gptbot.py b/gptbot.py new file mode 100644 index 0000000..1fa0422 --- /dev/null +++ b/gptbot.py @@ -0,0 +1,256 @@ +import sqlite3 +import os +import inspect + +import openai +import asyncio +import markdown2 +import tiktoken + +from nio import AsyncClient, RoomMessageText, MatrixRoom, Event, InviteEvent +from nio.api import MessageDirection +from nio.responses import RoomMessagesError, SyncResponse + +from configparser import ConfigParser +from datetime import datetime + +config = ConfigParser() +config.read("config.ini") + +# Set up GPT API +openai.api_key = config["OpenAI"]["APIKey"] +MODEL = config["OpenAI"].get("Model", "gpt-3.5-turbo") + +# Set up Matrix client +MATRIX_HOMESERVER = config["Matrix"]["Homeserver"] +MATRIX_ACCESS_TOKEN = config["Matrix"]["AccessToken"] +BOT_USER_ID = config["Matrix"]["UserID"] + +client = AsyncClient(MATRIX_HOMESERVER, BOT_USER_ID) + +SYNC_TOKEN = None + +# Set up SQLite3 database +conn = sqlite3.connect("token_usage.db") +cursor = conn.cursor() +cursor.execute( + """ + CREATE TABLE IF NOT EXISTS token_usage ( + room_id TEXT NOT NULL, + tokens INTEGER NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """ +) +conn.commit() + +# Define the system message and max token limit +SYSTEM_MESSAGE = "You are a helpful assistant." +MAX_TOKENS = 3000 + + +def logging(message, log_level="info"): + caller = inspect.currentframe().f_back.f_code.co_name + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S:%f") + print(f"[{timestamp} - {caller}] [{log_level.upper()}] {message}") + + +async def gpt_query(messages): + logging(f"Querying GPT with {len(messages)} messages") + try: + response = openai.ChatCompletion.create( + model=MODEL, + messages=messages + ) + result_text = response.choices[0].message['content'] + tokens_used = response.usage["total_tokens"] + logging(f"Used {tokens_used} tokens") + return result_text, tokens_used + + except Exception as e: + logging(f"Error during GPT API call: {e}", "error") + return None, 0 + + +async def fetch_last_n_messages(room_id, n=20): + # Fetch the last n messages from the room + room = await client.join(room_id) + messages = [] + + logging(f"Fetching last {n} messages from room {room_id}...") + + response = await client.room_messages( + room_id=room_id, + start=SYNC_TOKEN, + limit=n, + ) + + if isinstance(response, RoomMessagesError): + logging( + f"Error fetching messages: {response.message} (status code {response.status_code})", "error") + return [] + + for event in response.chunk: + if isinstance(event, RoomMessageText): + messages.append(event) + + logging(f"Found {len(messages)} messages") + + # Reverse the list so that messages are in chronological order + return messages[::-1] + + +def truncate_messages_to_fit_tokens(messages, max_tokens=MAX_TOKENS): + encoding = tiktoken.encoding_for_model(MODEL) + total_tokens = 0 + + system_message_tokens = len(encoding.encode(SYSTEM_MESSAGE)) + 1 + + if system_message_tokens > max_tokens: + logging( + f"System message is too long to fit within token limit ({system_message_tokens} tokens) - cannot proceed", "error") + return [] + + total_tokens += system_message_tokens + + total_tokens = len(SYSTEM_MESSAGE) + 1 + truncated_messages = [] + + for message in messages[0] + reversed(messages[1:]): + content = message["content"] + tokens = len(encoding.encode(content)) + 1 + if total_tokens + tokens > max_tokens: + break + total_tokens += tokens + truncated_messages.append(message) + + return list(truncated_messages[0] + reversed(truncated_messages[1:])) + + +async def message_callback(room: MatrixRoom, event: RoomMessageText): + logging(f"Received message from {event.sender} in room {room.room_id}") + + if event.sender == BOT_USER_ID: + logging("Message is from bot - ignoring") + return + + await client.room_typing(room.room_id, True) + + await client.room_read_markers(room.room_id, event.event_id) + + last_messages = await fetch_last_n_messages(room.room_id, 20) + + if not last_messages or all(message.sender == BOT_USER_ID for message in last_messages): + logging("No messages to respond to") + await client.room_typing(room.room_id, False) + return + + chat_messages = [{"role": "system", "content": SYSTEM_MESSAGE}] + + for message in last_messages: + role = "assistant" if message.sender == BOT_USER_ID else "user" + if not message.event_id == event.event_id: + chat_messages.append({"role": role, "content": message.body}) + + chat_messages.append({"role": "user", "content": event.body}) + + # Truncate messages to fit within the token limit + truncated_messages = truncate_messages_to_fit_tokens( + chat_messages, MAX_TOKENS - 1) + + response, tokens_used = await gpt_query(truncated_messages) + + if response: + # Send the response to the room + + logging(f"Sending response to room {room.room_id}...") + + markdowner = markdown2.Markdown(extras=["fenced-code-blocks"]) + formatted_body = markdowner.convert(response) + + await client.room_send( + room.room_id, "m.room.message", {"msgtype": "m.text", "body": response, + "format": "org.matrix.custom.html", "formatted_body": formatted_body} + ) + + logging("Logging tokens used...") + + cursor.execute( + "INSERT INTO token_usage (room_id, tokens) VALUES (?, ?)", (room.room_id, tokens_used)) + conn.commit() + else: + # Send a notice to the room if there was an error + + logging("Error during GPT API call - sending notice to room") + + await client.room_send( + room.room_id, "m.room.message", { + "msgtype": "m.notice", "body": "Sorry, I'm having trouble connecting to the GPT API right now. Please try again later."} + ) + print("No response from GPT API") + + await client.room_typing(room.room_id, False) + + +async def room_invite_callback(room: MatrixRoom, event): + logging(f"Received invite to room {room.room_id} - joining...") + + await client.join(room.room_id) + await client.room_send( + room.room_id, + "m.room.message", + {"msgtype": "m.text", + "body": "Hello! I'm a helpful assistant. How can I help you today?"} + ) + + +async def accept_pending_invites(): + logging("Accepting pending invites...") + + for room_id in list(client.invited_rooms.keys()): + logging(f"Joining room {room_id}...") + + await client.join(room_id) + await client.room_send( + room_id, + "m.room.message", + {"msgtype": "m.text", + "body": "Hello! I'm a helpful assistant. How can I help you today?"} + ) + + +async def sync_cb(response): + logging(f"Sync response received (next batch: {response.next_batch})") + SYNC_TOKEN = response.next_batch + + +async def main(): + logging("Starting bot...") + + client.access_token = MATRIX_ACCESS_TOKEN # Set the access token directly + client.user_id = BOT_USER_ID # Set the user_id directly + + client.add_response_callback(sync_cb, SyncResponse) + + logging("Syncing...") + + await client.sync(timeout=30000) + + client.add_event_callback(message_callback, RoomMessageText) + client.add_event_callback(room_invite_callback, InviteEvent) + + await accept_pending_invites() # Accept pending invites + + logging("Bot started") + + try: + await client.sync_forever(timeout=30000) # Continue syncing events + finally: + await client.close() # Properly close the aiohttp client session + logging("Bot stopped") + +if __name__ == "__main__": + try: + asyncio.run(main()) + finally: + conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ba5181 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +openai +matrix-nio[e2e] +markdown2[all] +tiktoken \ No newline at end of file