Bump version to 0.1.0

Improve logging
Black formatting
This commit is contained in:
Kumi 2023-05-25 12:41:32 +00:00
parent c188223cea
commit cce082ef67
Signed by: kumi
GPG key ID: ECBCC9082395383F
7 changed files with 349 additions and 165 deletions

View file

@ -3,6 +3,61 @@
# The values that are not commented have to be set, everything else comes with # The values that are not commented have to be set, everything else comes with
# sensible defaults. # sensible defaults.
###############################################################################
[GPTBot]
# Some way for the user to contact you.
# Ideally, either your personal user ID or a support room
# If this is your user ID and Debug is 1, any errors that occur when using the script will be reported to you in detail
#
Operator = Contact details not set
# Enable debug mode
# Will send error tracebacks to you (= Operator above) if an error occurs processing a message from you
# Defaults to 0 (= off)
#
# Debug = 1
# The default room name used by the !newroom command
# Defaults to GPTBot if not set
#
# DefaultRoomName = GPTBot
# Contents of a special message sent to the GPT API with every request.
# Can be used to give the bot some context about the environment it's running in
#
# SystemMessage = You are a helpful bot.
# Force inclusion of the SystemMessage defined above if one is defined on per-room level
# If no custom message is defined for the room, SystemMessage is always included
#
# ForceSystemMessage = 0
# Path to a custom logo
# Used as room/space image and profile picture
# Defaults to logo.png in assets directory
#
# Logo = assets/logo.png
# Display name for the bot
#
# DisplayName = GPTBot
# A list of allowed users
# If not defined, everyone is allowed to use the bot
# Use the "*:homeserver.matrix" syntax to allow everyone on a given homeserver
#
# AllowedUsers = ["*:matrix.local"]
# Minimum level of log messages that should be printed
# Available log levels in ascending order: trace, debug, info, warning, error, critical
# Defaults to info
#
LogLevel = info
###############################################################################
[OpenAI] [OpenAI]
# The Chat Completion model you want to use. # The Chat Completion model you want to use.
@ -38,6 +93,8 @@ APIKey = sk-yoursecretkey
# #
# MaxMessages = 20 # MaxMessages = 20
###############################################################################
[WolframAlpha] [WolframAlpha]
# An API key for Wolfram|Alpha # An API key for Wolfram|Alpha
@ -47,6 +104,8 @@ APIKey = sk-yoursecretkey
# #
#APIKey = YOUR-APIKEY #APIKey = YOUR-APIKEY
###############################################################################
[Matrix] [Matrix]
# The URL to your Matrix homeserver # The URL to your Matrix homeserver
@ -66,43 +125,7 @@ AccessToken = syt_yoursynapsetoken
# #
# UserID = @gptbot:matrix.local # UserID = @gptbot:matrix.local
[GPTBot] ###############################################################################
# Some way for the user to contact you.
# Ideally, either your personal user ID or a support room
#
Operator = Contact details not set
# The default room name used by the !newroom command
# Defaults to GPTBot if not set
#
# DefaultRoomName = GPTBot
# Contents of a special message sent to the GPT API with every request.
# Can be used to give the bot some context about the environment it's running in
#
# SystemMessage = You are a helpful bot.
# Force inclusion of the SystemMessage defined above if one is defined on per-room level
# If no custom message is defined for the room, SystemMessage is always included
#
# ForceSystemMessage = 0
# Path to a custom logo
# Used as room/space image and profile picture
# Defaults to logo.png in assets directory
#
# Logo = assets/logo.png
# Display name for the bot
#
# DisplayName = GPTBot
# A list of allowed users
# If not defined, everyone is allowed to use the bot
# Use the "*:homeserver.matrix" syntax to allow everyone on a given homeserver
#
# AllowedUsers = ["*:matrix.local"]
[Database] [Database]
@ -116,6 +139,8 @@ Path = database.db
# #
CryptoStore = store.db CryptoStore = store.db
###############################################################################
[TrackingMore] [TrackingMore]
# API key for TrackingMore # API key for TrackingMore
@ -123,6 +148,8 @@ CryptoStore = store.db
# #
# APIKey = abcde-fghij-klmnop # APIKey = abcde-fghij-klmnop
###############################################################################
[Replicate] [Replicate]
# API key for replicate.com # API key for replicate.com
@ -131,6 +158,8 @@ CryptoStore = store.db
# #
# APIKey = r8_alotoflettersandnumbershere # APIKey = r8_alotoflettersandnumbershere
###############################################################################
[HuggingFace] [HuggingFace]
# API key for Hugging Face # API key for Hugging Face
@ -138,3 +167,5 @@ CryptoStore = store.db
# If not defined, the features that depend on it are not available # If not defined, the features that depend on it are not available
# #
# APIKey = __________________________ # APIKey = __________________________
###############################################################################

View file

@ -7,7 +7,7 @@ allow-direct-references = true
[project] [project]
name = "matrix-gptbot" name = "matrix-gptbot"
version = "0.1.0-alpha1" version = "0.1.0"
authors = [ authors = [
{ name="Kumi Mitterer", email="gptbot@kumi.email" }, { name="Kumi Mitterer", email="gptbot@kumi.email" },

View file

@ -6,4 +6,5 @@ duckdb
python-magic python-magic
pillow pillow
wolframalpha wolframalpha
git+https://kumig.it/kumitterer/trackingmore-api-tool.git git+https://kumig.it/kumitterer/trackingmore-api-tool.git

View file

@ -16,9 +16,17 @@ if __name__ == "__main__":
parser = ArgumentParser() parser = ArgumentParser()
parser.add_argument( parser.add_argument(
"--config", "--config",
"-c",
help="Path to config file (default: config.ini in working directory)", help="Path to config file (default: config.ini in working directory)",
default="config.ini", default="config.ini",
) )
parser.add_argument(
"--version",
"-v",
help="Print version and exit",
action="version",
version="GPTBot v0.1.0",
)
args = parser.parse_args() args = parser.parse_args()
# Read config file # Read config file

View file

@ -1,4 +1,11 @@
async def test_response_callback(response, bot): from nio import ErrorResponse
bot.logger.log(
f"{response.__class__} response received", "debug")
async def test_response_callback(response, bot):
if isinstance(response, ErrorResponse):
bot.logger.log(
f"Error response received ({response.__class__.__name__}): {response.message}",
"warning",
)
else:
bot.logger.log(f"{response.__class__} response received", "debug")

View file

@ -53,10 +53,11 @@ from .openai import OpenAI
from .wolframalpha import WolframAlpha from .wolframalpha import WolframAlpha
from .trackingmore import TrackingMore from .trackingmore import TrackingMore
class GPTBot: class GPTBot:
# Default values # Default values
database: Optional[sqlite3.Connection] = None database: Optional[sqlite3.Connection] = None
crypto_store_path: Optional[str|Path] = None crypto_store_path: Optional[str | Path] = None
# Default name of rooms created by the bot # Default name of rooms created by the bot
display_name = default_room_name = "GPTBot" display_name = default_room_name = "GPTBot"
default_system_message: str = "You are a helpful assistant." default_system_message: str = "You are a helpful assistant."
@ -93,51 +94,64 @@ class GPTBot:
bot = cls() bot = cls()
# Set the database connection # Set the database connection
bot.database = sqlite3.connect( bot.database = (
config["Database"]["Path"]) if "Database" in config and "Path" in config["Database"] else None sqlite3.connect(config["Database"]["Path"])
if "Database" in config and "Path" in config["Database"]
else None
)
bot.crypto_store_path = config["Database"]["CryptoStore"] if "Database" in config and "CryptoStore" in config["Database"] else None bot.crypto_store_path = (
config["Database"]["CryptoStore"]
if "Database" in config and "CryptoStore" in config["Database"]
else None
)
# Override default values # Override default values
if "GPTBot" in config: if "GPTBot" in config:
bot.operator = config["GPTBot"].get("Operator", bot.operator) bot.operator = config["GPTBot"].get("Operator", bot.operator)
bot.default_room_name = config["GPTBot"].get( bot.default_room_name = config["GPTBot"].get(
"DefaultRoomName", bot.default_room_name) "DefaultRoomName", bot.default_room_name
)
bot.default_system_message = config["GPTBot"].get( bot.default_system_message = config["GPTBot"].get(
"SystemMessage", bot.default_system_message) "SystemMessage", bot.default_system_message
)
bot.force_system_message = config["GPTBot"].getboolean( bot.force_system_message = config["GPTBot"].getboolean(
"ForceSystemMessage", bot.force_system_message) "ForceSystemMessage", bot.force_system_message
)
bot.debug = config["GPTBot"].getboolean("Debug", bot.debug) bot.debug = config["GPTBot"].getboolean("Debug", bot.debug)
logo_path = config["GPTBot"].get("Logo", str( if "LogLevel" in config["GPTBot"]:
Path(__file__).parent.parent / "assets/logo.png")) bot.logger = Logger(config["GPTBot"]["LogLevel"])
bot.logger.log(f"Loading logo from {logo_path}") logo_path = config["GPTBot"].get(
"Logo", str(Path(__file__).parent.parent / "assets/logo.png")
)
bot.logger.log(f"Loading logo from {logo_path}", "debug")
if Path(logo_path).exists() and Path(logo_path).is_file(): if Path(logo_path).exists() and Path(logo_path).is_file():
bot.logo = Image.open(logo_path) bot.logo = Image.open(logo_path)
bot.display_name = config["GPTBot"].get( bot.display_name = config["GPTBot"].get("DisplayName", bot.display_name)
"DisplayName", bot.display_name)
if "AllowedUsers" in config["GPTBot"]: if "AllowedUsers" in config["GPTBot"]:
bot.allowed_users = json.loads(config["GPTBot"]["AllowedUsers"]) bot.allowed_users = json.loads(config["GPTBot"]["AllowedUsers"])
bot.chat_api = bot.image_api = bot.classification_api = OpenAI( bot.chat_api = bot.image_api = bot.classification_api = OpenAI(
config["OpenAI"]["APIKey"], config["OpenAI"].get("Model"), bot.logger) config["OpenAI"]["APIKey"], config["OpenAI"].get("Model"), bot.logger
)
bot.max_tokens = config["OpenAI"].getint("MaxTokens", bot.max_tokens) bot.max_tokens = config["OpenAI"].getint("MaxTokens", bot.max_tokens)
bot.max_messages = config["OpenAI"].getint( bot.max_messages = config["OpenAI"].getint("MaxMessages", bot.max_messages)
"MaxMessages", bot.max_messages)
# Set up WolframAlpha # Set up WolframAlpha
if "WolframAlpha" in config: if "WolframAlpha" in config:
bot.calculation_api = WolframAlpha( bot.calculation_api = WolframAlpha(
config["WolframAlpha"]["APIKey"], bot.logger) config["WolframAlpha"]["APIKey"], bot.logger
)
# Set up TrackingMore # Set up TrackingMore
if "TrackingMore" in config: if "TrackingMore" in config:
bot.parcel_api = TrackingMore( bot.parcel_api = TrackingMore(config["TrackingMore"]["APIKey"], bot.logger)
config["TrackingMore"]["APIKey"], bot.logger)
# Set up the Matrix client # Set up the Matrix client
@ -182,17 +196,21 @@ class GPTBot:
room_id = room.room_id if isinstance(room, MatrixRoom) else room room_id = room.room_id if isinstance(room, MatrixRoom) else room
self.logger.log( self.logger.log(
f"Fetching last {2*n} messages from room {room_id} (starting at {self.sync_token})...") f"Fetching last {2*n} messages from room {room_id} (starting at {self.sync_token})...",
"debug",
)
response = await self.matrix_client.room_messages( response = await self.matrix_client.room_messages(
room_id=room_id, room_id=room_id,
start=self.sync_token, start=self.sync_token,
limit=2*n, limit=2 * n,
) )
if isinstance(response, RoomMessagesError): if isinstance(response, RoomMessagesError):
raise Exception( raise Exception(
f"Error fetching messages: {response.message} (status code {response.status_code})", "error") f"Error fetching messages: {response.message} (status code {response.status_code})",
"error",
)
for event in response.chunk: for event in response.chunk:
if len(messages) >= n: if len(messages) >= n:
@ -202,34 +220,48 @@ class GPTBot:
event = await self.matrix_client.decrypt_event(event) event = await self.matrix_client.decrypt_event(event)
except (GroupEncryptionError, EncryptionError): except (GroupEncryptionError, EncryptionError):
self.logger.log( self.logger.log(
f"Could not decrypt message {event.event_id} in room {room_id}", "error") f"Could not decrypt message {event.event_id} in room {room_id}",
"error",
)
continue continue
if isinstance(event, (RoomMessageText, RoomMessageNotice)): if isinstance(event, (RoomMessageText, RoomMessageNotice)):
if event.body.startswith("!gptbot ignoreolder"): if event.body.startswith("!gptbot ignoreolder"):
break break
if (not event.body.startswith("!")) or (event.body.startswith("!gptbot")): if (not event.body.startswith("!")) or (
event.body.startswith("!gptbot")
):
messages.append(event) messages.append(event)
self.logger.log(f"Found {len(messages)} messages (limit: {n})") self.logger.log(f"Found {len(messages)} messages (limit: {n})", "debug")
# Reverse the list so that messages are in chronological order # Reverse the list so that messages are in chronological order
return messages[::-1] return messages[::-1]
def _truncate(self, messages: list, max_tokens: Optional[int] = None, def _truncate(
model: Optional[str] = None, system_message: Optional[str] = None): self,
messages: list,
max_tokens: Optional[int] = None,
model: Optional[str] = None,
system_message: Optional[str] = None,
):
max_tokens = max_tokens or self.max_tokens max_tokens = max_tokens or self.max_tokens
model = model or self.chat_api.chat_model model = model or self.chat_api.chat_model
system_message = self.default_system_message if system_message is None else system_message system_message = (
self.default_system_message if system_message is None else system_message
)
encoding = tiktoken.encoding_for_model(model) encoding = tiktoken.encoding_for_model(model)
total_tokens = 0 total_tokens = 0
system_message_tokens = 0 if not system_message else ( system_message_tokens = (
len(encoding.encode(system_message)) + 1) 0 if not system_message else (len(encoding.encode(system_message)) + 1)
)
if system_message_tokens > max_tokens: if system_message_tokens > max_tokens:
self.logger.log( self.logger.log(
f"System message is too long to fit within token limit ({system_message_tokens} tokens) - cannot proceed", "error") f"System message is too long to fit within token limit ({system_message_tokens} tokens) - cannot proceed",
"error",
)
return [] return []
total_tokens += system_message_tokens total_tokens += system_message_tokens
@ -279,7 +311,9 @@ class GPTBot:
""" """
self.logger.log( self.logger.log(
f"Received command {event.body} from {event.sender} in room {room.room_id}") f"Received command {event.body} from {event.sender} in room {room.room_id}",
"debug",
)
command = event.body.split()[1] if event.body.split()[1:] else None command = event.body.split()[1] if event.body.split()[1:] else None
await COMMANDS.get(command, COMMANDS[None])(room, event, self) await COMMANDS.get(command, COMMANDS[None])(room, event, self)
@ -297,7 +331,9 @@ class GPTBot:
with closing(self.database.cursor()) as cursor: with closing(self.database.cursor()) as cursor:
cursor.execute( cursor.execute(
"SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room_id, "use_classification")) "SELECT value FROM room_settings WHERE room_id = ? AND setting = ?",
(room_id, "use_classification"),
)
result = cursor.fetchone() result = cursor.fetchone()
return False if not result else bool(int(result[0])) return False if not result else bool(int(result[0]))
@ -310,10 +346,13 @@ class GPTBot:
await callback(room, event, self) await callback(room, event, self)
except Exception as e: except Exception as e:
self.logger.log( self.logger.log(
f"Error in event callback for {event.__class__}: {e}", "error") f"Error in event callback for {event.__class__}: {e}", "error"
)
if self.debug: if self.debug:
await self.send_message(room, f"Error: {e}\n\n```\n{traceback.format_exc()}\n```", True) await self.send_message(
room, f"Error: {e}\n\n```\n{traceback.format_exc()}\n```", True
)
def user_is_allowed(self, user_id: str) -> bool: def user_is_allowed(self, user_id: str) -> bool:
"""Check if a user is allowed to use the bot. """Check if a user is allowed to use the bot.
@ -326,10 +365,14 @@ class GPTBot:
""" """
return ( return (
user_id in self.allowed_users or (
f"*:{user_id.split(':')[1]}" in self.allowed_users or user_id in self.allowed_users
f"@*:{user_id.split(':')[1]}" in self.allowed_users or f"*:{user_id.split(':')[1]}" in self.allowed_users
) if self.allowed_users else True or f"@*:{user_id.split(':')[1]}" in self.allowed_users
)
if self.allowed_users
else True
)
async def event_callback(self, room: MatrixRoom, event: Event): async def event_callback(self, room: MatrixRoom, event: Event):
"""Callback for events. """Callback for events.
@ -349,8 +392,8 @@ class GPTBot:
"m.room.message", "m.room.message",
{ {
"msgtype": "m.notice", "msgtype": "m.notice",
"body": f"You are not allowed to use this bot. Please contact {self.operator} for more information." "body": f"You are not allowed to use this bot. Please contact {self.operator} for more information.",
} },
) )
return return
@ -369,7 +412,9 @@ class GPTBot:
with closing(self.database.cursor()) as cursor: with closing(self.database.cursor()) as cursor:
cursor.execute( cursor.execute(
"SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room_id, "use_timing")) "SELECT value FROM room_settings WHERE room_id = ? AND setting = ?",
(room_id, "use_timing"),
)
result = cursor.fetchone() result = cursor.fetchone()
return False if not result else bool(int(result[0])) return False if not result else bool(int(result[0]))
@ -392,7 +437,9 @@ class GPTBot:
for invite in invites.keys(): for invite in invites.keys():
if invite in self.room_ignore_list: if invite in self.room_ignore_list:
self.logger.log( self.logger.log(
f"Ignoring invite to room {invite} (room is in ignore list)") f"Ignoring invite to room {invite} (room is in ignore list)",
"debug",
)
continue continue
self.logger.log(f"Accepting invite to room {invite}") self.logger.log(f"Accepting invite to room {invite}")
@ -401,16 +448,25 @@ class GPTBot:
if isinstance(response, JoinError): if isinstance(response, JoinError):
self.logger.log( self.logger.log(
f"Error joining room {invite}: {response.message}. Not trying again.", "error") f"Error joining room {invite}: {response.message}. Not trying again.",
"error",
)
leave_response = await self.matrix_client.room_leave(invite) leave_response = await self.matrix_client.room_leave(invite)
if isinstance(leave_response, RoomLeaveError): if isinstance(leave_response, RoomLeaveError):
self.logger.log( self.logger.log(
f"Error leaving room {invite}: {leave_response.message}", "error") f"Error leaving room {invite}: {leave_response.message}",
"error",
)
self.room_ignore_list.append(invite) self.room_ignore_list.append(invite)
async def upload_file(self, file: bytes, filename: str = "file", mime: str = "application/octet-stream") -> str: async def upload_file(
self,
file: bytes,
filename: str = "file",
mime: str = "application/octet-stream",
) -> str:
"""Upload a file to the homeserver. """Upload a file to the homeserver.
Args: Args:
@ -426,15 +482,14 @@ class GPTBot:
bio.seek(0) bio.seek(0)
response, _ = await self.matrix_client.upload( response, _ = await self.matrix_client.upload(
bio, bio, content_type=mime, filename=filename, filesize=len(file)
content_type=mime,
filename=filename,
filesize=len(file)
) )
return response.content_uri return response.content_uri
async def send_image(self, room: MatrixRoom, image: bytes, message: Optional[str] = None): async def send_image(
self, room: MatrixRoom, image: bytes, message: Optional[str] = None
):
"""Send an image to a room. """Send an image to a room.
Args: Args:
@ -444,7 +499,8 @@ class GPTBot:
""" """
self.logger.log( self.logger.log(
f"Sending image of size {len(image)} bytes to room {room.room_id}") f"Sending image of size {len(image)} bytes to room {room.room_id}", "debug"
)
bio = BytesIO(image) bio = BytesIO(image)
img = Image.open(bio) img = Image.open(bio)
@ -453,11 +509,13 @@ class GPTBot:
(width, height) = img.size (width, height) = img.size
self.logger.log( self.logger.log(
f"Uploading - Image size: {width}x{height} pixels, MIME type: {mime}") f"Uploading - Image size: {width}x{height} pixels, MIME type: {mime}",
"debug",
)
content_uri = await self.upload_file(image, "image", mime) content_uri = await self.upload_file(image, "image", mime)
self.logger.log("Uploaded image - sending message...") self.logger.log("Uploaded image - sending message...", "debug")
content = { content = {
"body": message or "", "body": message or "",
@ -468,20 +526,18 @@ class GPTBot:
"h": height, "h": height,
}, },
"msgtype": "m.image", "msgtype": "m.image",
"url": content_uri "url": content_uri,
} }
status = await self.matrix_client.room_send( status = await self.matrix_client.room_send(
room.room_id, room.room_id, "m.room.message", content
"m.room.message",
content
) )
self.logger.log(str(status), "debug") self.logger.log("Sent image", "debug")
self.logger.log("Sent image") async def send_message(
self, room: MatrixRoom | str, message: str, notice: bool = False
async def send_message(self, room: MatrixRoom | str, message: str, notice: bool = False): ):
"""Send a message to a room. """Send a message to a room.
Args: Args:
@ -498,8 +554,12 @@ class GPTBot:
msgtype = "m.notice" if notice else "m.text" msgtype = "m.notice" if notice else "m.text"
msgcontent = {"msgtype": msgtype, "body": message, msgcontent = {
"format": "org.matrix.custom.html", "formatted_body": formatted_body} "msgtype": msgtype,
"body": message,
"format": "org.matrix.custom.html",
"formatted_body": formatted_body,
}
content = None content = None
@ -507,7 +567,9 @@ class GPTBot:
try: try:
if not room.members_synced: if not room.members_synced:
responses = [] responses = []
responses.append(await self.matrix_client.joined_members(room.room_id)) responses.append(
await self.matrix_client.joined_members(room.room_id)
)
if self.matrix_client.olm.should_share_group_session(room.room_id): if self.matrix_client.olm.should_share_group_session(room.room_id):
try: try:
@ -521,12 +583,14 @@ class GPTBot:
if msgtype != "m.reaction": if msgtype != "m.reaction":
response = self.matrix_client.encrypt( response = self.matrix_client.encrypt(
room.room_id, "m.room.message", msgcontent) room.room_id, "m.room.message", msgcontent
)
msgtype, content = response msgtype, content = response
except Exception as e: except Exception as e:
self.logger.log( self.logger.log(
f"Error encrypting message: {e} - sending unencrypted", "error") f"Error encrypting message: {e} - sending unencrypted", "warning"
)
raise raise
if not content: if not content:
@ -534,17 +598,24 @@ class GPTBot:
content = msgcontent content = msgcontent
method, path, data = Api.room_send( method, path, data = Api.room_send(
self.matrix_client.access_token, room.room_id, msgtype, content, uuid.uuid4() self.matrix_client.access_token,
room.room_id,
msgtype,
content,
uuid.uuid4(),
) )
response = await self.matrix_client._send(RoomSendResponse, method, path, data, (room.room_id,)) response = await self.matrix_client._send(
RoomSendResponse, method, path, data, (room.room_id,)
)
if isinstance(response, RoomSendError): if isinstance(response, RoomSendError):
self.logger.log( self.logger.log(f"Error sending message: {response.message}", "error")
f"Error sending message: {response.message}", "error")
return return
def log_api_usage(self, message: Event | str, room: MatrixRoom | str, api: str, tokens: int): def log_api_usage(
self, message: Event | str, room: MatrixRoom | str, api: str, tokens: int
):
"""Log API usage to the database. """Log API usage to the database.
Args: Args:
@ -565,7 +636,7 @@ class GPTBot:
self.database.execute( self.database.execute(
"INSERT INTO token_usage (message_id, room_id, tokens, api, timestamp) VALUES (?, ?, ?, ?, ?)", "INSERT INTO token_usage (message_id, room_id, tokens, api, timestamp) VALUES (?, ?, ?, ?, ?)",
(message, room, tokens, api, datetime.now()) (message, room, tokens, api, datetime.now()),
) )
async def run(self): async def run(self):
@ -587,7 +658,9 @@ class GPTBot:
IN_MEMORY = False IN_MEMORY = False
if not self.database: if not self.database:
self.logger.log( self.logger.log(
"No database connection set up, using in-memory database. Data will be lost on bot shutdown.") "No database connection set up, using in-memory database. Data will be lost on bot shutdown.",
"warning",
)
IN_MEMORY = True IN_MEMORY = True
self.database = sqlite3.connect(":memory:") self.database = sqlite3.connect(":memory:")
@ -596,8 +669,12 @@ class GPTBot:
try: try:
before, after = migrate(self.database) before, after = migrate(self.database)
except sqlite3.DatabaseError as e: except sqlite3.DatabaseError as e:
self.logger.log(f"Error migrating database: {e}", "fatal") self.logger.log(f"Error migrating database: {e}", "critical")
self.logger.log("If you have just updated the bot, the previous version of the database may be incompatible with this version. Please delete the database file and try again.", "fatal")
self.logger.log(
"If you have just updated the bot, the previous version of the database may be incompatible with this version. Please delete the database file and try again.",
"critical",
)
exit(1) exit(1)
if before != after: if before != after:
@ -607,66 +684,73 @@ class GPTBot:
if IN_MEMORY: if IN_MEMORY:
client_config = AsyncClientConfig( client_config = AsyncClientConfig(
store_sync_tokens=True, encryption_enabled=False) store_sync_tokens=True, encryption_enabled=False
)
else: else:
matrix_store = SqliteStore matrix_store = SqliteStore
client_config = AsyncClientConfig( client_config = AsyncClientConfig(
store_sync_tokens=True, encryption_enabled=True, store=matrix_store) store_sync_tokens=True, encryption_enabled=True, store=matrix_store
)
self.matrix_client.config = client_config self.matrix_client.config = client_config
self.matrix_client.store = matrix_store( self.matrix_client.store = matrix_store(
self.matrix_client.user_id, self.matrix_client.user_id,
self.matrix_client.device_id, self.matrix_client.device_id,
self.crypto_store_path or "" self.crypto_store_path or "",
) )
self.matrix_client.olm = Olm( self.matrix_client.olm = Olm(
self.matrix_client.user_id, self.matrix_client.user_id,
self.matrix_client.device_id, self.matrix_client.device_id,
self.matrix_client.store self.matrix_client.store,
) )
self.matrix_client.encrypted_rooms = self.matrix_client.store.load_encrypted_rooms() self.matrix_client.encrypted_rooms = (
self.matrix_client.store.load_encrypted_rooms()
)
# Run initial sync (now includes joining rooms) # Run initial sync (now includes joining rooms)
sync = await self.matrix_client.sync(timeout=30000) sync = await self.matrix_client.sync(timeout=30000)
if isinstance(sync, SyncResponse): if isinstance(sync, SyncResponse):
await self.response_callback(sync) await self.response_callback(sync)
else: else:
self.logger.log(f"Initial sync failed, aborting: {sync}", "error") self.logger.log(f"Initial sync failed, aborting: {sync}", "critical")
return exit(1)
# Set up callbacks # Set up callbacks
self.matrix_client.add_event_callback(self.event_callback, Event) self.matrix_client.add_event_callback(self.event_callback, Event)
self.matrix_client.add_response_callback( self.matrix_client.add_response_callback(self.response_callback, Response)
self.response_callback, Response)
# Set custom name / logo # Set custom name / logo
if self.display_name: if self.display_name:
self.logger.log(f"Setting display name to {self.display_name}") self.logger.log(f"Setting display name to {self.display_name}", "debug")
await self.matrix_client.set_displayname(self.display_name) asyncio.create_task(self.matrix_client.set_displayname(self.display_name))
if self.logo: if self.logo:
self.logger.log("Setting avatar...") self.logger.log("Setting avatar...")
logo_bio = BytesIO() logo_bio = BytesIO()
self.logo.save(logo_bio, format=self.logo.format) self.logo.save(logo_bio, format=self.logo.format)
uri = await self.upload_file(logo_bio.getvalue(), "logo", Image.MIME[self.logo.format]) uri = await self.upload_file(
logo_bio.getvalue(), "logo", Image.MIME[self.logo.format]
)
self.logo_uri = uri self.logo_uri = uri
asyncio.create_task(self.matrix_client.set_avatar(uri)) asyncio.create_task(self.matrix_client.set_avatar(uri))
for room in self.matrix_client.rooms.keys(): for room in self.matrix_client.rooms.keys():
self.logger.log(f"Setting avatar for {room}...", "debug") self.logger.log(f"Setting avatar for {room}...", "debug")
asyncio.create_task(self.matrix_client.room_put_state(room, "m.room.avatar", { asyncio.create_task(
"url": uri self.matrix_client.room_put_state(
}, "")) room, "m.room.avatar", {"url": uri}, ""
)
)
# Start syncing events # Start syncing events
self.logger.log("Starting sync loop...") self.logger.log("Starting sync loop...", "warning")
try: try:
await self.matrix_client.sync_forever(timeout=30000) await self.matrix_client.sync_forever(timeout=30000)
finally: finally:
self.logger.log("Syncing one last time...") self.logger.log("Syncing one last time...", "warning")
await self.matrix_client.sync(timeout=30000) await self.matrix_client.sync(timeout=30000)
async def create_space(self, name, visibility=RoomVisibility.private) -> str: async def create_space(self, name, visibility=RoomVisibility.private) -> str:
@ -681,16 +765,18 @@ class GPTBot:
""" """
response = await self.matrix_client.room_create( response = await self.matrix_client.room_create(
name=name, visibility=visibility, space=True) name=name, visibility=visibility, space=True
)
if isinstance(response, RoomCreateError): if isinstance(response, RoomCreateError):
self.logger.log( self.logger.log(f"Error creating space: {response.message}", "error")
f"Error creating space: {response.message}", "error")
return return
return response.room_id return response.room_id
async def add_rooms_to_space(self, space: MatrixRoom | str, rooms: List[MatrixRoom | str]): async def add_rooms_to_space(
self, space: MatrixRoom | str, rooms: List[MatrixRoom | str]
):
"""Add rooms to a space. """Add rooms to a space.
Args: Args:
@ -706,20 +792,26 @@ class GPTBot:
room = room.room_id room = room.room_id
if space == room: if space == room:
self.logger.log( self.logger.log(f"Refusing to add {room} to itself", "warning")
f"Refusing to add {room} to itself", "warning")
continue continue
self.logger.log(f"Adding {room} to {space}...") self.logger.log(f"Adding {room} to {space}...", "debug")
await self.matrix_client.room_put_state(space, "m.space.child", { await self.matrix_client.room_put_state(
space,
"m.space.child",
{
"via": [room.split(":")[1], space.split(":")[1]], "via": [room.split(":")[1], space.split(":")[1]],
}, room) },
room,
)
await self.matrix_client.room_put_state(room, "m.room.parent", { await self.matrix_client.room_put_state(
"via": [space.split(":")[1], room.split(":")[1]], room,
"canonical": True "m.room.parent",
}, space) {"via": [space.split(":")[1], room.split(":")[1]], "canonical": True},
space,
)
def respond_to_room_messages(self, room: MatrixRoom | str) -> bool: def respond_to_room_messages(self, room: MatrixRoom | str) -> bool:
"""Check whether the bot should respond to all messages sent in a room. """Check whether the bot should respond to all messages sent in a room.
@ -736,12 +828,16 @@ class GPTBot:
with closing(self.database.cursor()) as cursor: with closing(self.database.cursor()) as cursor:
cursor.execute( cursor.execute(
"SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", (room, "always_reply")) "SELECT value FROM room_settings WHERE room_id = ? AND setting = ?",
(room, "always_reply"),
)
result = cursor.fetchone() result = cursor.fetchone()
return True if not result else bool(int(result[0])) return True if not result else bool(int(result[0]))
async def process_query(self, room: MatrixRoom, event: RoomMessageText, from_chat_command: bool = False): async def process_query(
self, room: MatrixRoom, event: RoomMessageText, from_chat_command: bool = False
):
"""Process a query message. Generates a response and sends it to the room. """Process a query message. Generates a response and sends it to the room.
Args: Args:
@ -750,7 +846,11 @@ class GPTBot:
from_chat_command (bool, optional): Whether the query was sent via the `!gptbot chat` command. Defaults to False. from_chat_command (bool, optional): Whether the query was sent via the `!gptbot chat` command. Defaults to False.
""" """
if not (from_chat_command or self.respond_to_room_messages(room) or self.matrix_client.user_id in event.body): if not (
from_chat_command
or self.respond_to_room_messages(room)
or self.matrix_client.user_id in event.body
):
return return
await self.matrix_client.room_typing(room.room_id, True) await self.matrix_client.room_typing(room.room_id, True)
@ -760,18 +860,26 @@ class GPTBot:
if (not from_chat_command) and self.room_uses_classification(room): if (not from_chat_command) and self.room_uses_classification(room):
try: try:
classification, tokens = await self.classification_api.classify_message( classification, tokens = await self.classification_api.classify_message(
event.body, room.room_id) event.body, room.room_id
)
except Exception as e: except Exception as e:
self.logger.log(f"Error classifying message: {e}", "error") self.logger.log(f"Error classifying message: {e}", "error")
await self.send_message( await self.send_message(
room, "Something went wrong. Please try again.", True) room, "Something went wrong. Please try again.", True
)
return return
self.log_api_usage( self.log_api_usage(
event, room, f"{self.classification_api.api_code}-{self.classification_api.classification_api}", tokens) event,
room,
f"{self.classification_api.api_code}-{self.classification_api.classification_api}",
tokens,
)
if not classification["type"] == "chat": if not classification["type"] == "chat":
event.body = f"!gptbot {classification['type']} {classification['prompt']}" event.body = (
f"!gptbot {classification['type']} {classification['prompt']}"
)
await self.process_command(room, event) await self.process_command(room, event)
return return
@ -780,7 +888,8 @@ class GPTBot:
except Exception as e: except Exception as e:
self.logger.log(f"Error getting last messages: {e}", "error") self.logger.log(f"Error getting last messages: {e}", "error")
await self.send_message( await self.send_message(
room, "Something went wrong. Please try again.", True) room, "Something went wrong. Please try again.", True
)
return return
system_message = self.get_system_message(room) system_message = self.get_system_message(room)
@ -788,7 +897,9 @@ class GPTBot:
chat_messages = [{"role": "system", "content": system_message}] chat_messages = [{"role": "system", "content": system_message}]
for message in last_messages: for message in last_messages:
role = "assistant" if message.sender == self.matrix_client.user_id else "user" role = (
"assistant" if message.sender == self.matrix_client.user_id else "user"
)
if not message.event_id == event.event_id: if not message.event_id == event.event_id:
chat_messages.append({"role": role, "content": message.body}) chat_messages.append({"role": role, "content": message.body})
@ -796,20 +907,27 @@ class GPTBot:
# Truncate messages to fit within the token limit # Truncate messages to fit within the token limit
truncated_messages = self._truncate( truncated_messages = self._truncate(
chat_messages, self.max_tokens - 1, system_message=system_message) chat_messages, self.max_tokens - 1, system_message=system_message
)
try: try:
response, tokens_used = await self.chat_api.generate_chat_response( response, tokens_used = await self.chat_api.generate_chat_response(
chat_messages, user=room.room_id) chat_messages, user=room.room_id
)
except Exception as e: except Exception as e:
self.logger.log(f"Error generating response: {e}", "error") self.logger.log(f"Error generating response: {e}", "error")
await self.send_message( await self.send_message(
room, "Something went wrong. Please try again.", True) room, "Something went wrong. Please try again.", True
)
return return
if response: if response:
self.log_api_usage( self.log_api_usage(
event, room, f"{self.chat_api.api_code}-{self.chat_api.chat_api}", tokens_used) event,
room,
f"{self.chat_api.api_code}-{self.chat_api.chat_api}",
tokens_used,
)
self.logger.log(f"Sending response to room {room.room_id}...") self.logger.log(f"Sending response to room {room.room_id}...")
@ -821,7 +939,8 @@ class GPTBot:
# Send a notice to the room if there was an error # Send a notice to the room if there was an error
self.logger.log("Didn't get a response from GPT API", "error") self.logger.log("Didn't get a response from GPT API", "error")
await self.send_message( await self.send_message(
room, "Something went wrong. Please try again.", True) room, "Something went wrong. Please try again.", True
)
await self.matrix_client.room_typing(room.room_id, False) await self.matrix_client.room_typing(room.room_id, False)
@ -845,12 +964,14 @@ class GPTBot:
with closing(self.database.cursor()) as cur: with closing(self.database.cursor()) as cur:
cur.execute( cur.execute(
"SELECT value FROM room_settings WHERE room_id = ? AND setting = ?", "SELECT value FROM room_settings WHERE room_id = ? AND setting = ?",
(room_id, "system_message") (room_id, "system_message"),
) )
system_message = cur.fetchone() system_message = cur.fetchone()
complete = ((default if ((not system_message) or self.force_system_message) else "") + ( complete = (
"\n\n" + system_message[0] if system_message else "")).strip() (default if ((not system_message) or self.force_system_message) else "")
+ ("\n\n" + system_message[0] if system_message else "")
).strip()
return complete return complete

View file

@ -4,7 +4,23 @@ from datetime import datetime
class Logger: class Logger:
LOG_LEVELS = ["trace", "debug", "info", "warning", "error", "critical"]
def __init__(self, log_level: str = "warning"):
if log_level not in self.LOG_LEVELS:
raise ValueError(
f"Invalid log level {log_level}. Valid levels are {', '.join(self.LOG_LEVELS)}")
self.log_level = log_level
def log(self, message: str, log_level: str = "info"): def log(self, message: str, log_level: str = "info"):
if log_level not in self.LOG_LEVELS:
raise ValueError(
f"Invalid log level {log_level}. Valid levels are {', '.join(self.LOG_LEVELS)}")
if self.LOG_LEVELS.index(log_level) < self.LOG_LEVELS.index(self.log_level):
return
caller = inspect.currentframe().f_back.f_code.co_name caller = inspect.currentframe().f_back.f_code.co_name
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S:%f") timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S:%f")
print(f"[{timestamp}] - {caller} - [{log_level.upper()}] {message}") print(f"[{timestamp}] - {caller} - [{log_level.upper()}] {message}")