Document matrix_client.py

This commit is contained in:
miruka 2019-12-18 13:16:15 -04:00
parent 05a331382e
commit c6938903b8
2 changed files with 161 additions and 14 deletions

View File

@ -47,6 +47,8 @@
## Issues ## Issues
- last event obj
- load_past raise
- Room pane slightly overlaps chat at small width - Room pane slightly overlaps chat at small width
- invisible uploaded mxc images? - invisible uploaded mxc images?
- first undecryptable message - first undecryptable message

View File

@ -1,3 +1,5 @@
"""Matrix client and related classes."""
import asyncio import asyncio
import html import html
import io import io
@ -41,23 +43,40 @@ CryptDict = Dict[str, Any]
class UploadReturn(NamedTuple): class UploadReturn(NamedTuple):
"""Details for an uploaded file."""
mxc: str mxc: str
mime: str mime: str
decryption_dict: Dict[str, Any] decryption_dict: Dict[str, Any]
class MatrixImageInfo(NamedTuple): class MatrixImageInfo(NamedTuple):
w: int """Image informations to be passed for Matrix file events."""
h: int
mimetype: str width: int
size: int height: int
mime: str
size: int
def as_dict(self) -> Dict[str, Union[int, str]]:
"""Return a dict ready to be included in a Matrix file events."""
return {
"w": self.width,
"h": self.height,
"mimetype": self.mime,
"size": self.size,
}
class MatrixClient(nio.AsyncClient): class MatrixClient(nio.AsyncClient):
"""A client for an account to interact with a matrix homeserver."""
user_id_regex = re.compile(r"^@.+:.+") user_id_regex = re.compile(r"^@.+:.+")
room_id_or_alias_regex = re.compile(r"^[#!].+:.+") room_id_or_alias_regex = re.compile(r"^[#!].+:.+")
http_s_url = re.compile(r"^https?://") http_s_url = re.compile(r"^https?://")
def __init__(self, def __init__(self,
backend, backend,
user: str, user: str,
@ -112,12 +131,16 @@ class MatrixClient(nio.AsyncClient):
@property @property
def default_device_name(self) -> str: def default_device_name(self) -> str:
"""Device name to set at login if the user hasn't set a custom one."""
os_ = f" on {platform.system()}".rstrip() os_ = f" on {platform.system()}".rstrip()
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else "" os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
return f"{__display_name__}{os_}" return f"{__display_name__}{os_}"
async def login(self, password: str, device_name: str = "") -> None: async def login(self, password: str, device_name: str = "") -> None:
"""Login to the server using the account's password."""
response = await super().login( response = await super().login(
password, device_name or self.default_device_name, password, device_name or self.default_device_name,
) )
@ -125,17 +148,21 @@ class MatrixClient(nio.AsyncClient):
if isinstance(response, nio.LoginError): if isinstance(response, nio.LoginError):
raise MatrixError.from_nio(response) raise MatrixError.from_nio(response)
asyncio.ensure_future(self.start()) asyncio.ensure_future(self._start())
async def resume(self, user_id: str, token: str, device_id: str) -> None: async def resume(self, user_id: str, token: str, device_id: str) -> None:
"""Login to the server using an existing access token."""
response = nio.LoginResponse(user_id, device_id, token) response = nio.LoginResponse(user_id, device_id, token)
await self.receive_response(response) await self.receive_response(response)
asyncio.ensure_future(self.start()) asyncio.ensure_future(self._start())
async def logout(self) -> None: async def logout(self) -> None:
"""Logout from the server. This will delete the device."""
for task in (self.profile_task, self.load_rooms_task, self.sync_task): for task in (self.profile_task, self.load_rooms_task, self.sync_task):
if task: if task:
task.cancel() task.cancel()
@ -148,14 +175,20 @@ class MatrixClient(nio.AsyncClient):
@property @property
def syncing(self) -> bool: def syncing(self) -> bool:
"""Return whether this client is currently syncing with the server."""
if not self.sync_task: if not self.sync_task:
return False return False
return not self.sync_task.done() return not self.sync_task.done()
async def start(self) -> None: async def _start(self) -> None:
"""Fetch our user profile and enter the server sync loop."""
def on_profile_response(future) -> None: def on_profile_response(future) -> None:
"""Update our model `Account` with the received profile details."""
exception = future.exception() exception = future.exception()
if exception: if exception:
@ -192,10 +225,14 @@ class MatrixClient(nio.AsyncClient):
@property @property
def all_rooms(self) -> Dict[str, nio.MatrixRoom]: def all_rooms(self) -> Dict[str, nio.MatrixRoom]:
"""Return dict containing both our joined and invited rooms."""
return {**self.invited_rooms, **self.rooms} return {**self.invited_rooms, **self.rooms}
async def send_text(self, room_id: str, text: str) -> None: async def send_text(self, room_id: str, text: str) -> None:
"""Send a markdown `m.text` or `m.notice` (with `/me`) message ."""
escape = False escape = False
if text.startswith("//") or text.startswith(r"\/"): if text.startswith("//") or text.startswith(r"\/"):
escape = True escape = True
@ -228,6 +265,8 @@ class MatrixClient(nio.AsyncClient):
async def send_file(self, room_id: str, path: Union[Path, str]) -> None: async def send_file(self, room_id: str, path: Union[Path, str]) -> None:
"""Send a `m.file`, `m.image`, `m.audio` or `m.video` message."""
item_uuid = uuid4() item_uuid = uuid4()
try: try:
@ -240,6 +279,11 @@ class MatrixClient(nio.AsyncClient):
async def _send_file( async def _send_file(
self, item_uuid: UUID, room_id: str, path: Union[Path, str], self, item_uuid: UUID, room_id: str, path: Union[Path, str],
) -> None: ) -> None:
"""Monitorably upload a file + thumbnail and send the built event."""
# TODO: this function is WAY TOO COMPLEX, and most of it should be
# refactored into nio.
from .media_cache import Media, Thumbnail from .media_cache import Media, Thumbnail
transaction_id = uuid4() transaction_id = uuid4()
@ -361,7 +405,7 @@ class MatrixClient(nio.AsyncClient):
else: else:
content["info"]["thumbnail_url"] = thumb_url content["info"]["thumbnail_url"] = thumb_url
content["info"]["thumbnail_info"] = thumb_info._asdict() content["info"]["thumbnail_info"] = thumb_info.as_dict()
elif kind == "audio": elif kind == "audio":
event_type = \ event_type = \
@ -422,9 +466,27 @@ class MatrixClient(nio.AsyncClient):
async def _local_echo( async def _local_echo(
self, room_id: str, transaction_id: UUID, self,
event_type: Type[nio.Event], **event_fields, room_id: str,
transaction_id: UUID,
event_type: Type[nio.Event],
**event_fields,
) -> None: ) -> None:
"""Register a local model `Event` while waiting for the server.
When the user sends a message, we want to show instant feedback in
the UI timeline without waiting for the servers to receive our message
and retransmit it to us.
The event will be locally echoed for all our accounts that are members
of the `room_id` room.
This allows sending messages from other accounts within the same
composer without having to go to another page in the UI,
and getting direct feedback for these accounts in the timeline.
When we do get the real event retransmited by the server, it will
replace the local one we registered.
"""
our_info = self.models[Member, self.user_id, room_id][self.user_id] our_info = self.models[Member, self.user_id, room_id][self.user_id]
@ -453,6 +515,7 @@ class MatrixClient(nio.AsyncClient):
async def _send_message(self, room_id: str, content: dict) -> None: async def _send_message(self, room_id: str, content: dict) -> None:
"""Send a message event with `content` dict to a room."""
async with self.backend.send_locks[room_id]: async with self.backend.send_locks[room_id]:
response = await self.room_send( response = await self.room_send(
@ -467,6 +530,14 @@ class MatrixClient(nio.AsyncClient):
async def load_past_events(self, room_id: str) -> bool: async def load_past_events(self, room_id: str) -> bool:
"""Ask the server for 100 previous events of the room.
Events from before the client was started will be requested and
registered into our models.
Returns whether there are any messages left to load.
"""
if room_id in self.fully_loaded_rooms or \ if room_id in self.fully_loaded_rooms or \
room_id in self.invited_rooms or \ room_id in self.invited_rooms or \
room_id in self.cleared_events_rooms: room_id in self.cleared_events_rooms:
@ -507,6 +578,8 @@ class MatrixClient(nio.AsyncClient):
async def load_rooms_without_visible_events(self) -> None: async def load_rooms_without_visible_events(self) -> None:
"""Call `_load_room_without_visible_events` for all joined rooms."""
for room_id in self.models[Room, self.user_id]: for room_id in self.models[Room, self.user_id]:
asyncio.ensure_future( asyncio.ensure_future(
self._load_room_without_visible_events(room_id), self._load_room_without_visible_events(room_id),
@ -514,6 +587,20 @@ class MatrixClient(nio.AsyncClient):
async def _load_room_without_visible_events(self, room_id: str) -> None: async def _load_room_without_visible_events(self, room_id: str) -> None:
"""Request past events for rooms without any suitable event to show.
Some events are currently not supported, or processed but not
shown in the UI timeline/room "last event" subtitle, e.g.
the "x changed their name/avatar" events.
It could happen that all the initial events received in the initial
sync for a room are such events,
and thus we'd have nothing to show in the room.
This method tries to load past events until we have at least one
to show or there is nothing left to load.
"""
events = self.models[Event, self.user_id, room_id] events = self.models[Event, self.user_id, room_id]
more = True more = True
@ -522,6 +609,8 @@ class MatrixClient(nio.AsyncClient):
async def new_direct_chat(self, invite: str, encrypt: bool = False) -> str: async def new_direct_chat(self, invite: str, encrypt: bool = False) -> str:
"""Create a room and invite a single user in it for a direct chat."""
if invite == self.user_id: if invite == self.user_id:
raise InvalidUserInContext(invite) raise InvalidUserInContext(invite)
@ -553,6 +642,7 @@ class MatrixClient(nio.AsyncClient):
encrypt: bool = False, encrypt: bool = False,
federate: bool = True, federate: bool = True,
) -> str: ) -> str:
"""Create a new matrix room with the purpose of being a group chat."""
response = await super().room_create( response = await super().room_create(
name = name or None, name = name or None,
@ -571,6 +661,8 @@ class MatrixClient(nio.AsyncClient):
return response.room_id return response.room_id
async def room_join(self, alias_or_id_or_url: str) -> str: async def room_join(self, alias_or_id_or_url: str) -> str:
"""Join an existing matrix room."""
string = alias_or_id_or_url.strip() string = alias_or_id_or_url.strip()
if self.http_s_url.match(string): if self.http_s_url.match(string):
@ -593,6 +685,12 @@ class MatrixClient(nio.AsyncClient):
async def room_forget(self, room_id: str) -> None: async def room_forget(self, room_id: str) -> None:
"""Leave a joined room (or decline an invite) and forget its history.
If all the members of a room leave and forget it, that room
will be marked as suitable for destruction by the server.
"""
await super().room_leave(room_id) await super().room_leave(room_id)
await super().room_forget(room_id) await super().room_forget(room_id)
self.models[Room, self.user_id].pop(room_id, None) self.models[Room, self.user_id].pop(room_id, None)
@ -603,6 +701,13 @@ class MatrixClient(nio.AsyncClient):
async def room_mass_invite( async def room_mass_invite(
self, room_id: str, *user_ids: str, self, room_id: str, *user_ids: str,
) -> Tuple[List[str], List[Tuple[str, Exception]]]: ) -> Tuple[List[str], List[Tuple[str, Exception]]]:
"""Invite users to a room in parallel.
Returns a tuple with:
- A list of users we successfully invited
- A list of `(user_id, Exception)` tuples for those failed to invite.
"""
user_ids = tuple( user_ids = tuple(
uid for uid in user_ids uid for uid in user_ids
@ -640,6 +745,7 @@ class MatrixClient(nio.AsyncClient):
async def generate_thumbnail( async def generate_thumbnail(
self, data: UploadData, is_svg: bool = False, self, data: UploadData, is_svg: bool = False,
) -> Tuple[bytes, MatrixImageInfo]: ) -> Tuple[bytes, MatrixImageInfo]:
"""Create a thumbnail from an image, return the bytes and info."""
png_modes = ("1", "L", "P", "RGBA") png_modes = ("1", "L", "P", "RGBA")
@ -689,6 +795,7 @@ class MatrixClient(nio.AsyncClient):
encrypt: bool = False, encrypt: bool = False,
monitor: Optional[nio.TransferMonitor] = None, monitor: Optional[nio.TransferMonitor] = None,
) -> UploadReturn: ) -> UploadReturn:
"""Upload a file to the matrix homeserver."""
mime = mime or await utils.guess_mime(data_provider(0, 0)) mime = mime or await utils.guess_mime(data_provider(0, 0))
@ -707,6 +814,8 @@ class MatrixClient(nio.AsyncClient):
async def set_avatar_from_file(self, path: Union[Path, str]) -> None: async def set_avatar_from_file(self, path: Union[Path, str]) -> None:
"""Upload an image to the homeserver and set it as our avatar."""
mime = await utils.guess_mime(path) mime = await utils.guess_mime(path)
if mime.split("/")[0] != "image": if mime.split("/")[0] != "image":
@ -717,11 +826,15 @@ class MatrixClient(nio.AsyncClient):
async def import_keys(self, infile: str, passphrase: str) -> None: async def import_keys(self, infile: str, passphrase: str) -> None:
"""Import decryption keys from a file, then retry decrypting events."""
await super().import_keys(infile, passphrase) await super().import_keys(infile, passphrase)
await self.retry_decrypting_events() await self.retry_decrypting_events()
async def export_keys(self, outfile: str, passphrase: str) -> None: async def export_keys(self, outfile: str, passphrase: str) -> None:
"""Export our decryption keys to a file."""
path = Path(outfile) path = Path(outfile)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@ -733,6 +846,8 @@ class MatrixClient(nio.AsyncClient):
async def retry_decrypting_events(self) -> None: async def retry_decrypting_events(self) -> None:
"""Retry decrypting room `Event`s in our model we failed to decrypt."""
for sync_id, model in self.models.items(): for sync_id, model in self.models.items():
if not (isinstance(sync_id, tuple) and if not (isinstance(sync_id, tuple) and
sync_id[0:2] == (Event, self.user_id)): sync_id[0:2] == (Event, self.user_id)):
@ -759,6 +874,11 @@ class MatrixClient(nio.AsyncClient):
async def clear_events(self, room_id: str) -> None: async def clear_events(self, room_id: str) -> None:
"""Remove every `Event` of a room we registred in our model.
The events will be gone from the UI, until the client is restarted.
"""
self.cleared_events_rooms.add(room_id) self.cleared_events_rooms.add(room_id)
model = self.models[Event, self.user_id, room_id] model = self.models[Event, self.user_id, room_id]
if model: if model:
@ -769,6 +889,8 @@ class MatrixClient(nio.AsyncClient):
# Functions to register data into models # Functions to register data into models
async def event_is_past(self, ev: Union[nio.Event, Event]) -> bool: async def event_is_past(self, ev: Union[nio.Event, Event]) -> bool:
"""Return whether an event was created before this client started."""
if not self.first_sync_date: if not self.first_sync_date:
return True return True
@ -780,6 +902,11 @@ class MatrixClient(nio.AsyncClient):
async def set_room_last_event(self, room_id: str, item: Event) -> None: async def set_room_last_event(self, room_id: str, item: Event) -> None:
"""Set the `last_event` for a `Room` using data in our `Event` model.
The `last_event` is notably displayed in the UI room subtitles.
"""
model = self.models[Room, self.user_id] model = self.models[Room, self.user_id]
room = model[room_id] room = model[room_id]
@ -813,8 +940,11 @@ class MatrixClient(nio.AsyncClient):
model.sync_now() model.sync_now()
async def register_nio_room(self, room: nio.MatrixRoom, left: bool = False, async def register_nio_room(
) -> None: self, room: nio.MatrixRoom, left: bool = False,
) -> None:
"""Register a `nio.MatrixRoom` as a `Room` object in our model."""
# Add room # Add room
try: try:
last_ev = self.models[Room, self.user_id][room.room_id].last_event last_ev = self.models[Room, self.user_id][room.room_id].last_event
@ -880,16 +1010,26 @@ class MatrixClient(nio.AsyncClient):
self.models[Member, self.user_id, room.room_id].update(new_dict) self.models[Member, self.user_id, room.room_id].update(new_dict)
async def get_member_name_avatar(self, room_id: str, user_id: str, async def get_member_name_avatar(
) -> Tuple[str, str]: self, room_id: str, user_id: str,
) -> Tuple[str, str]:
"""Return a room member's display name and avatar.
If the member isn't found in the room (e.g. they left), their
profile is retrieved using `MatrixClient.backend.get_profile()`.
"""
try: try:
item = self.models[Member, self.user_id, room_id][user_id] item = self.models[Member, self.user_id, room_id][user_id]
except KeyError: # e.g. user is not anymore in the room except KeyError: # e.g. user is not anymore in the room
try: try:
info = await self.backend.get_profile(user_id) info = await self.backend.get_profile(user_id)
return (info.displayname or "", info.avatar_url or "") return (info.displayname or "", info.avatar_url or "")
except MatrixError: except MatrixError:
return ("", "") return ("", "")
else: else:
return (item.display_name, item.avatar_url) return (item.display_name, item.avatar_url)
@ -897,6 +1037,11 @@ class MatrixClient(nio.AsyncClient):
async def register_nio_event( async def register_nio_event(
self, room: nio.MatrixRoom, ev: nio.Event, **fields, self, room: nio.MatrixRoom, ev: nio.Event, **fields,
) -> None: ) -> None:
"""Register a `nio.Event` as a `Event` object in our model.
`MatrixClient.register_nio_room` is called for the passed `room`
if neccessary before.
"""
await self.register_nio_room(room) await self.register_nio_room(room)