2024-04-07 19:41:19 -06:00
|
|
|
import asyncio
|
2023-03-18 02:14:45 -06:00
|
|
|
import json
|
2023-04-19 17:01:07 -06:00
|
|
|
import logging
|
2023-03-18 02:14:45 -06:00
|
|
|
import os
|
|
|
|
from pathlib import Path
|
2024-04-07 19:41:19 -06:00
|
|
|
from typing import Union, Optional
|
2023-03-18 02:14:45 -06:00
|
|
|
|
2024-04-07 19:41:19 -06:00
|
|
|
from markdown import markdown
|
|
|
|
from nio import AsyncClient, AsyncClientConfig, LoginError, Response, ErrorResponse, RoomSendResponse, SendRetryError, SyncError
|
|
|
|
from nio.responses import LoginResponse, SyncResponse
|
2023-03-18 02:14:45 -06:00
|
|
|
|
|
|
|
|
2024-04-07 19:41:19 -06:00
|
|
|
class MatrixClientHelper:
|
2023-03-18 02:14:45 -06:00
|
|
|
"""
|
|
|
|
A simple wrapper class for common matrix-nio actions.
|
|
|
|
"""
|
|
|
|
|
2024-02-22 14:56:34 -07:00
|
|
|
# Encryption is disabled because it's handled by Pantalaimon.
|
|
|
|
client_config = AsyncClientConfig(max_limit_exceeded=0, max_timeouts=0, store_sync_tokens=True, encryption_enabled=False)
|
2023-03-18 02:14:45 -06:00
|
|
|
|
2024-04-07 22:27:00 -06:00
|
|
|
def __init__(self, user_id: str, passwd: str, homeserver: str, store_path: str, device_id: str):
|
2023-03-18 02:14:45 -06:00
|
|
|
self.user_id = user_id
|
|
|
|
self.passwd = passwd
|
|
|
|
|
|
|
|
self.homeserver = homeserver
|
|
|
|
if not (self.homeserver.startswith("https://") or self.homeserver.startswith("http://")):
|
|
|
|
self.homeserver = "https://" + self.homeserver
|
|
|
|
|
2024-04-07 19:41:19 -06:00
|
|
|
self.store_path = Path(store_path).absolute().expanduser().resolve()
|
|
|
|
self.store_path.mkdir(parents=True, exist_ok=True)
|
2024-04-07 22:27:00 -06:00
|
|
|
self.auth_file = self.store_path / (device_id.lower() + '.json')
|
|
|
|
self.device_name = device_id
|
|
|
|
self.client: AsyncClient = AsyncClient(homeserver=self.homeserver, user=self.user_id, config=self.client_config, device_id=device_id)
|
2024-04-07 19:41:19 -06:00
|
|
|
self.logger = logging.getLogger('MatrixGPT').getChild('MatrixClientHelper')
|
2023-03-18 02:14:45 -06:00
|
|
|
|
2024-04-07 19:41:19 -06:00
|
|
|
async def login(self) -> tuple[bool, LoginResponse | LoginError | None]:
|
2023-04-08 15:13:09 -06:00
|
|
|
try:
|
2024-02-22 14:56:34 -07:00
|
|
|
# If there are no previously-saved credentials, we'll use the password.
|
2023-04-08 15:13:09 -06:00
|
|
|
if not os.path.exists(self.auth_file):
|
2024-04-07 19:41:19 -06:00
|
|
|
self.logger.info('Using username/password')
|
2023-04-08 15:13:09 -06:00
|
|
|
resp = await self.client.login(self.passwd, device_name=self.device_name)
|
|
|
|
if isinstance(resp, LoginResponse):
|
2024-04-07 19:41:19 -06:00
|
|
|
self._write_details_to_disk(resp)
|
2023-04-08 15:13:09 -06:00
|
|
|
return True, resp
|
|
|
|
else:
|
|
|
|
return False, resp
|
2023-03-18 02:14:45 -06:00
|
|
|
else:
|
2024-02-22 14:56:34 -07:00
|
|
|
# Otherwise the config file exists, so we'll use the stored credentials.
|
2024-04-07 19:41:19 -06:00
|
|
|
self.logger.info('Using cached credentials')
|
|
|
|
|
|
|
|
auth_details = self._read_details_from_disk()['auth']
|
|
|
|
client = AsyncClient(auth_details["homeserver"])
|
|
|
|
client.access_token = auth_details["access_token"]
|
|
|
|
client.user_id = auth_details["user_id"]
|
|
|
|
client.device_id = auth_details["device_id"]
|
|
|
|
|
2023-04-08 15:13:09 -06:00
|
|
|
resp = await self.client.login(self.passwd, device_name=self.device_name)
|
|
|
|
if isinstance(resp, LoginResponse):
|
2024-04-07 19:41:19 -06:00
|
|
|
self._write_details_to_disk(resp)
|
2023-04-08 15:13:09 -06:00
|
|
|
return True, resp
|
|
|
|
else:
|
|
|
|
return False, resp
|
|
|
|
except Exception:
|
2024-04-07 19:41:19 -06:00
|
|
|
raise
|
|
|
|
|
|
|
|
async def sync(self) -> SyncResponse | SyncError:
|
|
|
|
last_sync = self._read_details_from_disk().get('extra', {}).get('last_sync')
|
|
|
|
response = await self.client.sync(timeout=10000, full_state=True, since=last_sync)
|
|
|
|
if isinstance(response, SyncError):
|
|
|
|
raise Exception(response)
|
|
|
|
self._write_details_to_disk(extra_data={'last_sync': response.next_batch})
|
|
|
|
return response
|
|
|
|
|
|
|
|
def run_sync_in_bg(self):
|
|
|
|
"""
|
|
|
|
Run a sync in the background to update the `last_sync` value every 3 minutes.
|
|
|
|
"""
|
|
|
|
asyncio.create_task(self._do_run_sync_in_bg())
|
|
|
|
|
|
|
|
async def _do_run_sync_in_bg(self):
|
|
|
|
while True:
|
|
|
|
await self.sync()
|
|
|
|
await asyncio.sleep(180) # 3 minutes
|
|
|
|
|
|
|
|
def _read_details_from_disk(self):
|
|
|
|
if not self.auth_file.exists():
|
2024-04-09 19:26:44 -06:00
|
|
|
return {'auth': {}, 'extra': {}}
|
|
|
|
with open(self.auth_file, 'r') as f:
|
2024-04-07 19:41:19 -06:00
|
|
|
return json.load(f)
|
2023-03-18 02:14:45 -06:00
|
|
|
|
2024-04-07 19:41:19 -06:00
|
|
|
def _write_details_to_disk(self, resp: LoginResponse = None, extra_data: dict = None) -> None:
|
|
|
|
data = self._read_details_from_disk()
|
|
|
|
if resp:
|
|
|
|
data['auth'] = {
|
|
|
|
'homeserver': self.homeserver,
|
|
|
|
'user_id': resp.user_id,
|
|
|
|
'device_id': resp.device_id,
|
|
|
|
'access_token': resp.access_token,
|
|
|
|
}
|
|
|
|
if extra_data:
|
|
|
|
data['extra'] = extra_data
|
|
|
|
with open(self.auth_file, 'w') as f:
|
|
|
|
json.dump(data, f, indent=4)
|
|
|
|
|
|
|
|
async def react_to_event(self, room_id: str, event_id: str, reaction_text: str, extra_error: str = False, extra_msg: str = False) -> Union[Response, ErrorResponse]:
|
|
|
|
content = {
|
|
|
|
"m.relates_to": {
|
|
|
|
"rel_type": "m.annotation",
|
|
|
|
"event_id": event_id,
|
|
|
|
"key": reaction_text
|
|
|
|
},
|
|
|
|
"m.matrixbot": {}
|
|
|
|
}
|
|
|
|
if extra_error:
|
|
|
|
content["m.matrixbot"]["error"] = str(extra_error)
|
|
|
|
if extra_msg:
|
|
|
|
content["m.matrixbot"]["msg"] = str(extra_msg)
|
|
|
|
return await self.client.room_send(room_id, "m.reaction", content, ignore_unverified_devices=True)
|
|
|
|
|
|
|
|
async def send_text_to_room(self, room_id: str, message: str, notice: bool = False,
|
2024-04-07 22:44:27 -06:00
|
|
|
markdown_convert: bool = False, reply_to_event_id: Optional[str] = None,
|
2024-04-07 19:41:19 -06:00
|
|
|
thread: bool = False, thread_root_id: Optional[str] = None, extra_error: Optional[str] = None,
|
2024-04-10 22:47:15 -06:00
|
|
|
extra_msg: Optional[str] = None, extra_data: Optional[dict] = None) -> Union[RoomSendResponse, ErrorResponse]:
|
2024-04-07 19:41:19 -06:00
|
|
|
"""Send text to a matrix room.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
room_id: The ID of the room to send the message to.
|
|
|
|
message: The message content.
|
|
|
|
notice: Whether the message should be sent with an "m.notice" message type
|
|
|
|
(will not ping users).
|
|
|
|
markdown_convert: Whether to convert the message content to markdown.
|
|
|
|
Defaults to true.
|
|
|
|
reply_to_event_id: Whether this message is a reply to another event. The event
|
|
|
|
ID this is message is a reply to.
|
|
|
|
thread:
|
|
|
|
thread_root_id:
|
|
|
|
extra_msg:
|
|
|
|
extra_error:
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A RoomSendResponse if the request was successful, else an ErrorResponse.
|
2023-03-18 02:14:45 -06:00
|
|
|
|
|
|
|
"""
|
2024-04-07 19:41:19 -06:00
|
|
|
msgtype = "m.notice" if notice else "m.text"
|
|
|
|
content = {"msgtype": msgtype, "format": "org.matrix.custom.html", "body": message}
|
|
|
|
|
|
|
|
if markdown_convert:
|
|
|
|
content["formatted_body"] = markdown(message, extensions=['fenced_code'])
|
|
|
|
|
|
|
|
if reply_to_event_id:
|
|
|
|
if thread:
|
|
|
|
content["m.relates_to"] = {
|
|
|
|
'event_id': thread_root_id,
|
|
|
|
'is_falling_back': True,
|
|
|
|
"m.in_reply_to": {
|
|
|
|
"event_id": reply_to_event_id
|
|
|
|
},
|
|
|
|
'rel_type': "m.thread"
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
content["m.relates_to"] = {
|
|
|
|
"m.in_reply_to": {
|
|
|
|
"event_id": reply_to_event_id
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
content["m.matrixgpt"] = {
|
|
|
|
"error": str(extra_error),
|
|
|
|
"msg": str(extra_msg),
|
|
|
|
}
|
2024-04-10 22:47:15 -06:00
|
|
|
if extra_data:
|
|
|
|
content["m.matrixgpt"].update(extra_data)
|
2024-04-07 19:41:19 -06:00
|
|
|
try:
|
|
|
|
return await self.client.room_send(room_id, "m.room.message", content, ignore_unverified_devices=True)
|
|
|
|
except SendRetryError:
|
|
|
|
self.logger.exception(f"Unable to send message response to {room_id}")
|