Document matrix_client.py
This commit is contained in:
parent
05a331382e
commit
c6938903b8
2
TODO.md
2
TODO.md
|
@ -47,6 +47,8 @@
|
|||
|
||||
## Issues
|
||||
|
||||
- last event obj
|
||||
- load_past raise
|
||||
- Room pane slightly overlaps chat at small width
|
||||
- invisible uploaded mxc images?
|
||||
- first undecryptable message
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
"""Matrix client and related classes."""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import io
|
||||
|
@ -41,23 +43,40 @@ CryptDict = Dict[str, Any]
|
|||
|
||||
|
||||
class UploadReturn(NamedTuple):
|
||||
"""Details for an uploaded file."""
|
||||
|
||||
mxc: str
|
||||
mime: str
|
||||
decryption_dict: Dict[str, Any]
|
||||
|
||||
|
||||
class MatrixImageInfo(NamedTuple):
|
||||
w: int
|
||||
h: int
|
||||
mimetype: str
|
||||
size: int
|
||||
"""Image informations to be passed for Matrix file events."""
|
||||
|
||||
width: 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):
|
||||
"""A client for an account to interact with a matrix homeserver."""
|
||||
|
||||
user_id_regex = re.compile(r"^@.+:.+")
|
||||
room_id_or_alias_regex = re.compile(r"^[#!].+:.+")
|
||||
http_s_url = re.compile(r"^https?://")
|
||||
|
||||
|
||||
def __init__(self,
|
||||
backend,
|
||||
user: str,
|
||||
|
@ -112,12 +131,16 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
@property
|
||||
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"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
|
||||
return f"{__display_name__}{os_}"
|
||||
|
||||
|
||||
async def login(self, password: str, device_name: str = "") -> None:
|
||||
"""Login to the server using the account's password."""
|
||||
|
||||
response = await super().login(
|
||||
password, device_name or self.default_device_name,
|
||||
)
|
||||
|
@ -125,17 +148,21 @@ class MatrixClient(nio.AsyncClient):
|
|||
if isinstance(response, nio.LoginError):
|
||||
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:
|
||||
"""Login to the server using an existing access token."""
|
||||
|
||||
response = nio.LoginResponse(user_id, device_id, token)
|
||||
await self.receive_response(response)
|
||||
|
||||
asyncio.ensure_future(self.start())
|
||||
asyncio.ensure_future(self._start())
|
||||
|
||||
|
||||
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):
|
||||
if task:
|
||||
task.cancel()
|
||||
|
@ -148,14 +175,20 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
@property
|
||||
def syncing(self) -> bool:
|
||||
"""Return whether this client is currently syncing with the server."""
|
||||
|
||||
if not self.sync_task:
|
||||
return False
|
||||
|
||||
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:
|
||||
"""Update our model `Account` with the received profile details."""
|
||||
|
||||
exception = future.exception()
|
||||
|
||||
if exception:
|
||||
|
@ -192,10 +225,14 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
@property
|
||||
def all_rooms(self) -> Dict[str, nio.MatrixRoom]:
|
||||
"""Return dict containing both our joined and invited rooms."""
|
||||
|
||||
return {**self.invited_rooms, **self.rooms}
|
||||
|
||||
|
||||
async def send_text(self, room_id: str, text: str) -> None:
|
||||
"""Send a markdown `m.text` or `m.notice` (with `/me`) message ."""
|
||||
|
||||
escape = False
|
||||
if text.startswith("//") or text.startswith(r"\/"):
|
||||
escape = True
|
||||
|
@ -228,6 +265,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
|
@ -240,6 +279,11 @@ class MatrixClient(nio.AsyncClient):
|
|||
async def _send_file(
|
||||
self, item_uuid: UUID, room_id: str, path: Union[Path, str],
|
||||
) -> 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
|
||||
|
||||
transaction_id = uuid4()
|
||||
|
@ -361,7 +405,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
else:
|
||||
content["info"]["thumbnail_url"] = thumb_url
|
||||
|
||||
content["info"]["thumbnail_info"] = thumb_info._asdict()
|
||||
content["info"]["thumbnail_info"] = thumb_info.as_dict()
|
||||
|
||||
elif kind == "audio":
|
||||
event_type = \
|
||||
|
@ -422,9 +466,27 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
async def _local_echo(
|
||||
self, room_id: str, transaction_id: UUID,
|
||||
event_type: Type[nio.Event], **event_fields,
|
||||
self,
|
||||
room_id: str,
|
||||
transaction_id: UUID,
|
||||
event_type: Type[nio.Event],
|
||||
**event_fields,
|
||||
) -> 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]
|
||||
|
||||
|
@ -453,6 +515,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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]:
|
||||
response = await self.room_send(
|
||||
|
@ -467,6 +530,14 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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 \
|
||||
room_id in self.invited_rooms or \
|
||||
room_id in self.cleared_events_rooms:
|
||||
|
@ -507,6 +578,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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]:
|
||||
asyncio.ensure_future(
|
||||
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:
|
||||
"""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]
|
||||
more = True
|
||||
|
||||
|
@ -522,6 +609,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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:
|
||||
raise InvalidUserInContext(invite)
|
||||
|
||||
|
@ -553,6 +642,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
encrypt: bool = False,
|
||||
federate: bool = True,
|
||||
) -> str:
|
||||
"""Create a new matrix room with the purpose of being a group chat."""
|
||||
|
||||
response = await super().room_create(
|
||||
name = name or None,
|
||||
|
@ -571,6 +661,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
return response.room_id
|
||||
|
||||
async def room_join(self, alias_or_id_or_url: str) -> str:
|
||||
"""Join an existing matrix room."""
|
||||
|
||||
string = alias_or_id_or_url.strip()
|
||||
|
||||
if self.http_s_url.match(string):
|
||||
|
@ -593,6 +685,12 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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_forget(room_id)
|
||||
self.models[Room, self.user_id].pop(room_id, None)
|
||||
|
@ -603,6 +701,13 @@ class MatrixClient(nio.AsyncClient):
|
|||
async def room_mass_invite(
|
||||
self, room_id: str, *user_ids: str,
|
||||
) -> 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(
|
||||
uid for uid in user_ids
|
||||
|
@ -640,6 +745,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
async def generate_thumbnail(
|
||||
self, data: UploadData, is_svg: bool = False,
|
||||
) -> Tuple[bytes, MatrixImageInfo]:
|
||||
"""Create a thumbnail from an image, return the bytes and info."""
|
||||
|
||||
png_modes = ("1", "L", "P", "RGBA")
|
||||
|
||||
|
@ -689,6 +795,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
encrypt: bool = False,
|
||||
monitor: Optional[nio.TransferMonitor] = None,
|
||||
) -> UploadReturn:
|
||||
"""Upload a file to the matrix homeserver."""
|
||||
|
||||
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:
|
||||
"""Upload an image to the homeserver and set it as our avatar."""
|
||||
|
||||
mime = await utils.guess_mime(path)
|
||||
|
||||
if mime.split("/")[0] != "image":
|
||||
|
@ -717,11 +826,15 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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 self.retry_decrypting_events()
|
||||
|
||||
|
||||
async def export_keys(self, outfile: str, passphrase: str) -> None:
|
||||
"""Export our decryption keys to a file."""
|
||||
|
||||
path = Path(outfile)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
@ -733,6 +846,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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():
|
||||
if not (isinstance(sync_id, tuple) and
|
||||
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:
|
||||
"""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)
|
||||
model = self.models[Event, self.user_id, room_id]
|
||||
if model:
|
||||
|
@ -769,6 +889,8 @@ class MatrixClient(nio.AsyncClient):
|
|||
# Functions to register data into models
|
||||
|
||||
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:
|
||||
return True
|
||||
|
||||
|
@ -780,6 +902,11 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
|
||||
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]
|
||||
room = model[room_id]
|
||||
|
||||
|
@ -813,8 +940,11 @@ class MatrixClient(nio.AsyncClient):
|
|||
model.sync_now()
|
||||
|
||||
|
||||
async def register_nio_room(self, room: nio.MatrixRoom, left: bool = False,
|
||||
) -> None:
|
||||
async def register_nio_room(
|
||||
self, room: nio.MatrixRoom, left: bool = False,
|
||||
) -> None:
|
||||
"""Register a `nio.MatrixRoom` as a `Room` object in our model."""
|
||||
|
||||
# Add room
|
||||
try:
|
||||
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)
|
||||
|
||||
|
||||
async def get_member_name_avatar(self, room_id: str, user_id: str,
|
||||
) -> Tuple[str, str]:
|
||||
async def get_member_name_avatar(
|
||||
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:
|
||||
item = self.models[Member, self.user_id, room_id][user_id]
|
||||
|
||||
except KeyError: # e.g. user is not anymore in the room
|
||||
try:
|
||||
info = await self.backend.get_profile(user_id)
|
||||
return (info.displayname or "", info.avatar_url or "")
|
||||
|
||||
except MatrixError:
|
||||
return ("", "")
|
||||
|
||||
else:
|
||||
return (item.display_name, item.avatar_url)
|
||||
|
||||
|
@ -897,6 +1037,11 @@ class MatrixClient(nio.AsyncClient):
|
|||
async def register_nio_event(
|
||||
self, room: nio.MatrixRoom, ev: nio.Event, **fields,
|
||||
) -> 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)
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user