2019-07-03 03:59:52 +10:00
|
|
|
import asyncio
|
2019-07-03 12:22:29 +10:00
|
|
|
import html
|
2019-07-03 03:59:52 +10:00
|
|
|
import inspect
|
2019-07-03 12:22:29 +10:00
|
|
|
import json
|
2019-07-03 03:59:52 +10:00
|
|
|
import logging as log
|
|
|
|
import platform
|
|
|
|
from contextlib import suppress
|
2019-07-04 11:20:49 +10:00
|
|
|
from datetime import datetime
|
2019-07-03 03:59:52 +10:00
|
|
|
from types import ModuleType
|
2019-07-07 12:35:42 +10:00
|
|
|
from typing import DefaultDict, Dict, Optional, Type
|
2019-07-05 09:49:55 +10:00
|
|
|
from uuid import uuid4
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
import nio
|
2019-07-03 05:06:45 +10:00
|
|
|
from nio.rooms import MatrixRoom
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
from . import __about__
|
|
|
|
from .events import rooms, users
|
2019-07-03 12:22:29 +10:00
|
|
|
from .events.rooms import TimelineEventReceived, TimelineMessageReceived
|
2019-07-03 03:59:52 +10:00
|
|
|
from .html_filter import HTML_FILTER
|
|
|
|
|
|
|
|
|
|
|
|
class MatrixClient(nio.AsyncClient):
|
|
|
|
def __init__(self,
|
2019-07-05 09:11:22 +10:00
|
|
|
backend,
|
2019-07-03 03:59:52 +10:00
|
|
|
user: str,
|
|
|
|
homeserver: str = "https://matrix.org",
|
|
|
|
device_id: Optional[str] = None) -> None:
|
|
|
|
# TODO: ensure homeserver starts with a scheme://
|
2019-07-04 11:20:49 +10:00
|
|
|
|
2019-07-05 09:11:22 +10:00
|
|
|
from .backend import Backend
|
|
|
|
self.backend: Backend = backend
|
|
|
|
|
2019-07-03 03:59:52 +10:00
|
|
|
self.sync_task: Optional[asyncio.Future] = None
|
2019-07-04 11:20:49 +10:00
|
|
|
|
2019-07-05 09:49:55 +10:00
|
|
|
self.send_locks: DefaultDict[str, asyncio.Lock] = \
|
|
|
|
DefaultDict(asyncio.Lock) # {room_id: lock}
|
|
|
|
|
2019-07-03 03:59:52 +10:00
|
|
|
super().__init__(homeserver=homeserver, user=user, device_id=device_id)
|
|
|
|
|
|
|
|
self.connect_callbacks()
|
|
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
return "%s(user_id=%r, homeserver=%r, device_id=%r)" % (
|
|
|
|
type(self).__name__, self.user_id, self.homeserver, self.device_id
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def _classes_defined_in(module: ModuleType) -> Dict[str, Type]:
|
|
|
|
return {
|
|
|
|
m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass)
|
|
|
|
if not m[0].startswith("_") and
|
|
|
|
m[1].__module__.startswith(module.__name__)
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def connect_callbacks(self) -> None:
|
|
|
|
for name, class_ in self._classes_defined_in(nio.responses).items():
|
|
|
|
with suppress(AttributeError):
|
|
|
|
self.add_response_callback(getattr(self, f"on{name}"), class_)
|
|
|
|
|
2019-07-03 05:06:45 +10:00
|
|
|
for name, class_ in self._classes_defined_in(nio.events).items():
|
|
|
|
with suppress(AttributeError):
|
|
|
|
self.add_event_callback(getattr(self, f"on{name}"), class_)
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
|
|
|
|
async def start_syncing(self) -> None:
|
|
|
|
self.sync_task = asyncio.ensure_future(
|
|
|
|
self.sync_forever(timeout=10_000)
|
|
|
|
)
|
|
|
|
|
|
|
|
def callback(task):
|
|
|
|
raise task.exception()
|
|
|
|
|
|
|
|
self.sync_task.add_done_callback(callback)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
def default_device_name(self) -> str:
|
|
|
|
os_ = f" on {platform.system()}".rstrip()
|
|
|
|
os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else ""
|
|
|
|
return f"{__about__.__pretty_name__}{os_}"
|
|
|
|
|
|
|
|
|
2019-07-03 05:06:45 +10:00
|
|
|
async def login(self, password: str, device_name: str = "") -> None:
|
|
|
|
response = await super().login(
|
|
|
|
password, device_name or self.default_device_name
|
|
|
|
)
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
if isinstance(response, nio.LoginError):
|
|
|
|
print(response)
|
|
|
|
else:
|
|
|
|
await self.start_syncing()
|
|
|
|
|
|
|
|
|
|
|
|
async def resume(self, user_id: str, token: str, device_id: str) -> None:
|
|
|
|
response = nio.LoginResponse(user_id, device_id, token)
|
|
|
|
await self.receive_response(response)
|
|
|
|
await self.start_syncing()
|
|
|
|
|
|
|
|
|
|
|
|
async def logout(self) -> None:
|
|
|
|
if self.sync_task:
|
|
|
|
self.sync_task.cancel()
|
|
|
|
with suppress(asyncio.CancelledError):
|
|
|
|
await self.sync_task
|
|
|
|
|
|
|
|
await self.close()
|
|
|
|
|
|
|
|
|
|
|
|
async def request_user_update_event(self, user_id: str) -> None:
|
2019-07-05 08:37:15 +10:00
|
|
|
print("Requesting user profile:", user_id)
|
2019-07-03 03:59:52 +10:00
|
|
|
response = await self.get_profile(user_id)
|
|
|
|
|
|
|
|
if isinstance(response, nio.ProfileGetError):
|
2019-07-05 16:45:30 +10:00
|
|
|
log.warning("%s: %s", user_id, response)
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
users.UserUpdated(
|
|
|
|
user_id = user_id,
|
2019-07-05 08:37:15 +10:00
|
|
|
display_name = getattr(response, "displayname", "") or "",
|
|
|
|
avatar_url = getattr(response, "avatar_url", "") or "",
|
|
|
|
status_message = "", # TODO
|
2019-07-03 03:59:52 +10:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
@property
|
|
|
|
def all_rooms(self) -> Dict[str, MatrixRoom]:
|
|
|
|
return {**self.invited_rooms, **self.rooms}
|
|
|
|
|
|
|
|
|
2019-07-04 11:20:49 +10:00
|
|
|
async def send_markdown(self, room_id: str, text: str) -> None:
|
|
|
|
content = {
|
|
|
|
"body": text,
|
|
|
|
"formatted_body": HTML_FILTER.from_markdown(text),
|
|
|
|
"format": "org.matrix.custom.html",
|
|
|
|
"msgtype": "m.text",
|
|
|
|
}
|
|
|
|
|
|
|
|
TimelineMessageReceived(
|
|
|
|
event_type = nio.RoomMessageText,
|
|
|
|
room_id = room_id,
|
2019-07-05 09:49:55 +10:00
|
|
|
event_id = f"local_echo.{uuid4()}",
|
2019-07-04 11:20:49 +10:00
|
|
|
sender_id = self.user_id,
|
|
|
|
date = datetime.now(),
|
|
|
|
content = content["formatted_body"],
|
|
|
|
is_local_echo = True,
|
|
|
|
)
|
|
|
|
|
2019-07-05 09:49:55 +10:00
|
|
|
async with self.send_locks[room_id]:
|
2019-07-05 12:25:06 +10:00
|
|
|
response = await self.room_send(room_id, "m.room.message", content)
|
|
|
|
|
|
|
|
if isinstance(response, nio.RoomSendError):
|
|
|
|
log.error("Failed to send message: %s", response)
|
2019-07-04 11:20:49 +10:00
|
|
|
|
|
|
|
|
2019-07-05 17:18:24 +10:00
|
|
|
async def load_past_events(self, room_id: str, limit: int = 25) -> bool:
|
2019-07-05 16:45:30 +10:00
|
|
|
if room_id in self.backend.fully_loaded_rooms:
|
|
|
|
return False
|
|
|
|
|
|
|
|
response = await self.room_messages(
|
|
|
|
room_id = room_id,
|
|
|
|
start = self.backend.past_tokens[room_id],
|
|
|
|
limit = limit,
|
|
|
|
)
|
|
|
|
|
|
|
|
more_to_load = True
|
|
|
|
print(len(response.chunk))
|
|
|
|
|
|
|
|
if self.backend.past_tokens[room_id] == response.end:
|
|
|
|
self.backend.fully_loaded_rooms.add(room_id)
|
|
|
|
more_to_load = False
|
|
|
|
|
|
|
|
self.backend.past_tokens[room_id] = response.end
|
|
|
|
|
|
|
|
for event in response.chunk:
|
|
|
|
for cb in self.event_callbacks:
|
|
|
|
if (cb.filter is None or isinstance(event, cb.filter)):
|
|
|
|
await cb.func(
|
|
|
|
self.all_rooms[room_id], event, from_past=True
|
|
|
|
)
|
|
|
|
|
|
|
|
return more_to_load
|
|
|
|
|
|
|
|
|
2019-07-03 03:59:52 +10:00
|
|
|
# Callbacks for nio responses
|
|
|
|
|
|
|
|
async def onSyncResponse(self, resp: nio.SyncResponse) -> None:
|
2019-07-07 12:35:42 +10:00
|
|
|
up = rooms.RoomUpdated.from_nio
|
2019-07-03 03:59:52 +10:00
|
|
|
|
2019-07-07 12:35:42 +10:00
|
|
|
for room_id, info in resp.rooms.invite.items():
|
|
|
|
up(self.user_id, "Invites", self.invited_rooms[room_id], info)
|
2019-07-03 03:59:52 +10:00
|
|
|
|
2019-07-07 12:35:42 +10:00
|
|
|
for room_id, info in resp.rooms.join.items():
|
2019-07-05 16:45:30 +10:00
|
|
|
if room_id not in self.backend.past_tokens:
|
|
|
|
self.backend.past_tokens[room_id] = info.timeline.prev_batch
|
|
|
|
|
2019-07-07 12:35:42 +10:00
|
|
|
up(self.user_id, "Rooms", self.rooms[room_id], info)
|
|
|
|
|
|
|
|
for room_id, info in resp.rooms.leave.items():
|
|
|
|
lev = None
|
|
|
|
|
|
|
|
for ev in info.timeline.events:
|
|
|
|
is_member_ev = isinstance(ev, nio.RoomMemberEvent)
|
|
|
|
|
|
|
|
if is_member_ev and ev.membership in ("leave", "ban"):
|
|
|
|
lev = ev
|
|
|
|
|
|
|
|
up(self.user_id, "Left", self.rooms[room_id], info, left_event=lev)
|
2019-07-03 03:59:52 +10:00
|
|
|
|
|
|
|
|
2019-07-07 07:29:00 +10:00
|
|
|
async def onErrorResponse(self, resp: nio.ErrorResponse) -> None:
|
2019-07-05 12:25:06 +10:00
|
|
|
# TODO: show something in the client
|
2019-07-07 07:29:00 +10:00
|
|
|
log.warning(resp)
|
2019-07-05 12:25:06 +10:00
|
|
|
|
|
|
|
|
2019-07-03 03:59:52 +10:00
|
|
|
# Callbacks for nio events
|
|
|
|
|
2019-07-03 12:22:29 +10:00
|
|
|
# Special %tokens for event contents:
|
|
|
|
# %S = sender's displayname
|
|
|
|
# %T = target (ev.state_key)'s displayname
|
2019-07-05 16:45:30 +10:00
|
|
|
# pylint: disable=unused-argument
|
|
|
|
|
|
|
|
async def onRoomMessageText(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = HTML_FILTER.filter(
|
|
|
|
ev.formatted_body
|
|
|
|
if ev.format == "org.matrix.custom.html" else html.escape(ev.body)
|
2019-07-03 03:59:52 +10:00
|
|
|
)
|
2019-07-04 11:20:49 +10:00
|
|
|
|
2019-07-03 12:22:29 +10:00
|
|
|
TimelineMessageReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomCreateEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = "%S allowed users on other matrix servers to join this room." \
|
|
|
|
if ev.federate else \
|
|
|
|
"%S blocked users on other matrix servers from joining this room."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomGuestAccessEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
allowed = "allowed" if ev.guest_access else "forbad"
|
|
|
|
co = f"%S {allowed} guests to join the room."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomJoinRulesEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
access = "public" if ev.join_rule == "public" else "invite-only"
|
|
|
|
co = f"%S made the room {access}."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomHistoryVisibilityEvent(self, room, ev, from_past=False
|
|
|
|
) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
if ev.history_visibility == "shared":
|
|
|
|
to = "all room members"
|
|
|
|
elif ev.history_visibility == "world_readable":
|
|
|
|
to = "any member or outsider"
|
|
|
|
elif ev.history_visibility == "joined":
|
|
|
|
to = "all room members, since the time they joined"
|
|
|
|
elif ev.history_visibility == "invited":
|
|
|
|
to = "all room members, since the time they were invited"
|
|
|
|
else:
|
|
|
|
to = "???"
|
|
|
|
log.warning("Invalid visibility - %s",
|
|
|
|
json.dumps(ev.__dict__, indent=4))
|
|
|
|
|
|
|
|
co = f"%S made future room history visible to {to}."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onPowerLevelsEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = "%S changed the room's permissions." # TODO: improve
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 09:11:22 +10:00
|
|
|
async def _get_room_member_event_content(self, ev) -> Optional[str]:
|
2019-07-03 12:22:29 +10:00
|
|
|
prev = ev.prev_content
|
|
|
|
now = ev.content
|
2019-07-07 12:38:56 +10:00
|
|
|
membership = ev.membership
|
|
|
|
prev_membership = ev.prev_membership
|
2019-07-03 12:22:29 +10:00
|
|
|
|
|
|
|
if not prev or membership != prev_membership:
|
|
|
|
reason = f" Reason: {now['reason']}" if now.get("reason") else ""
|
|
|
|
|
|
|
|
if membership == "join":
|
|
|
|
did = "accepted" if prev and prev_membership == "invite" else \
|
|
|
|
"declined"
|
|
|
|
return f"%S {did} their invitation."
|
|
|
|
|
|
|
|
if membership == "invite":
|
|
|
|
return f"%S invited %T to the room."
|
|
|
|
|
|
|
|
if membership == "leave":
|
|
|
|
if ev.state_key == ev.sender:
|
|
|
|
return (
|
|
|
|
f"%S declined their invitation.{reason}"
|
|
|
|
if prev and prev_membership == "invite" else
|
|
|
|
f"%S left the room.{reason}"
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
f"%S withdrew %T's invitation.{reason}"
|
|
|
|
if prev and prev_membership == "invite" else
|
|
|
|
|
|
|
|
f"%S unbanned %T from the room.{reason}"
|
|
|
|
if prev and prev_membership == "ban" else
|
|
|
|
|
|
|
|
f"%S kicked out %T from the room.{reason}"
|
|
|
|
)
|
|
|
|
|
|
|
|
if membership == "ban":
|
|
|
|
return f"%S banned %T from the room.{reason}"
|
|
|
|
|
2019-07-05 09:11:22 +10:00
|
|
|
|
|
|
|
if ev.sender in self.backend.clients:
|
|
|
|
# Don't put our own name/avatar changes in the timeline
|
|
|
|
return None
|
|
|
|
|
2019-07-03 12:22:29 +10:00
|
|
|
changed = []
|
|
|
|
|
|
|
|
if prev and now["avatar_url"] != prev["avatar_url"]:
|
|
|
|
changed.append("profile picture") # TODO: <img>s
|
|
|
|
|
|
|
|
|
|
|
|
if prev and now["displayname"] != prev["displayname"]:
|
|
|
|
changed.append('display name from "{}" to "{}"'.format(
|
|
|
|
prev["displayname"] or ev.state_key,
|
|
|
|
now["displayname"] or ev.state_key,
|
|
|
|
))
|
|
|
|
|
|
|
|
if changed:
|
|
|
|
return "%S changed their {}.".format(" and ".join(changed))
|
|
|
|
|
|
|
|
log.warning("Invalid member event - %s",
|
|
|
|
json.dumps(ev.__dict__, indent=4))
|
2019-07-05 09:11:22 +10:00
|
|
|
return None
|
2019-07-03 12:22:29 +10:00
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomMemberEvent(self, room, ev, from_past=False) -> None:
|
2019-07-07 12:38:56 +10:00
|
|
|
if not from_past and ev.membership != "leave":
|
2019-07-05 08:37:15 +10:00
|
|
|
users.UserUpdated(
|
|
|
|
user_id = ev.state_key,
|
|
|
|
display_name = ev.content["displayname"] or "",
|
|
|
|
avatar_url = ev.content["avatar_url"] or "",
|
|
|
|
status_message = "", # TODO
|
|
|
|
)
|
|
|
|
|
2019-07-03 12:22:29 +10:00
|
|
|
co = await self._get_room_member_event_content(ev)
|
2019-07-05 09:11:22 +10:00
|
|
|
|
|
|
|
if co is not None:
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
2019-07-03 12:22:29 +10:00
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomAliasEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S set the room's main address to {ev.canonical_alias}."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomNameEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S changed the room's name to \"{ev.name}\"."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomTopicEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S changed the room's topic to \"{ev.topic}\"."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onRoomEncryptionEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S turned on encryption for this room."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onOlmEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S hasn't sent your device the keys to decrypt this message."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onMegolmEvent(self, room, ev, from_past=False) -> None:
|
|
|
|
await self.onOlmEvent(room, ev, from_past=False)
|
2019-07-03 12:22:29 +10:00
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onBadEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S sent a malformed event."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-07-05 16:45:30 +10:00
|
|
|
async def onUnknownBadEvent(self, room, ev, from_past=False) -> None:
|
2019-07-03 12:22:29 +10:00
|
|
|
co = f"%S sent an event this client doesn't understand."
|
|
|
|
TimelineEventReceived.from_nio(room, ev, content=co)
|