2019-11-09 01:20:38 +11:00
|
|
|
import asyncio
|
|
|
|
import json
|
|
|
|
import logging as log
|
|
|
|
from contextlib import suppress
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from datetime import datetime
|
|
|
|
from typing import Optional, Tuple
|
2019-11-27 21:30:42 +11:00
|
|
|
from urllib.parse import quote
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
import nio
|
|
|
|
|
|
|
|
from . import utils
|
2019-12-19 00:33:22 +11:00
|
|
|
from .html_markdown import HTML_PROCESSOR
|
2019-11-09 01:20:38 +11:00
|
|
|
from .matrix_client import MatrixClient
|
|
|
|
from .models.items import Account, Room, TypeSpecifier
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
class NioCallbacks:
|
2019-12-19 05:24:55 +11:00
|
|
|
"""Register callbacks for nio's request responses and room events.
|
|
|
|
|
|
|
|
For every nio `Response` and `Event` subclasses, this class can have a
|
|
|
|
method named `on<ClassName>` (e.g. `onRoomMessageText`) that will
|
|
|
|
automatically be registered in nio.
|
|
|
|
|
|
|
|
For room event content strings, the `%1` and `%2` placeholders
|
|
|
|
refer to the event's sender and who this event targets (`state_key`).
|
|
|
|
These are processed from QML, to allow translations of the strings.
|
|
|
|
"""
|
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
client: MatrixClient = field()
|
|
|
|
|
|
|
|
|
|
|
|
def __post_init__(self) -> None:
|
2019-12-19 05:24:55 +11:00
|
|
|
"""Register our methods as callbacks."""
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
for name, class_ in utils.classes_defined_in(nio.responses).items():
|
|
|
|
with suppress(AttributeError):
|
2019-12-19 05:24:55 +11:00
|
|
|
method = getattr(self, f"on{name}")
|
|
|
|
self.client.add_response_callback(method, class_)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
for name, class_ in utils.classes_defined_in(nio.events).items():
|
|
|
|
with suppress(AttributeError):
|
2019-12-19 05:24:55 +11:00
|
|
|
method = getattr(self, f"on{name}")
|
|
|
|
self.client.add_event_callback(method, class_)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
self.client.add_ephemeral_callback(
|
2019-11-09 01:20:38 +11:00
|
|
|
self.onTypingNoticeEvent, nio.events.TypingNoticeEvent,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
# Response callbacks
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
async def onSyncResponse(self, resp: nio.SyncResponse) -> None:
|
2019-11-09 01:20:38 +11:00
|
|
|
for room_id, info in resp.rooms.join.items():
|
2019-12-19 05:24:55 +11:00
|
|
|
if room_id not in self.client.past_tokens:
|
|
|
|
self.client.past_tokens[room_id] = info.timeline.prev_batch
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
# TODO: way of knowing if a nio.MatrixRoom is left
|
|
|
|
for room_id, info in resp.rooms.leave.items():
|
|
|
|
# TODO: handle in nio, these are rooms that were left before
|
|
|
|
# starting the client.
|
2019-12-19 05:24:55 +11:00
|
|
|
if room_id not in self.client.all_rooms:
|
2019-11-09 01:20:38 +11:00
|
|
|
log.warning("Left room not in MatrixClient.rooms: %r", room_id)
|
|
|
|
continue
|
|
|
|
|
|
|
|
# TODO: handle left events in nio async client
|
|
|
|
for ev in info.timeline.events:
|
|
|
|
if isinstance(ev, nio.RoomMemberEvent):
|
2019-12-19 05:24:55 +11:00
|
|
|
await self.onRoomMemberEvent(
|
|
|
|
self.client.all_rooms[room_id], ev,
|
|
|
|
)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
await self.client.register_nio_room(
|
|
|
|
self.client.all_rooms[room_id], left=True,
|
|
|
|
)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
if not self.client.first_sync_done.is_set():
|
2019-11-22 19:27:20 +11:00
|
|
|
self.client.load_rooms_task = asyncio.ensure_future(
|
2019-12-19 05:24:55 +11:00
|
|
|
self.client.load_rooms_without_visible_events(),
|
2019-11-22 19:27:20 +11:00
|
|
|
)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
self.client.first_sync_done.set()
|
|
|
|
self.client.first_sync_date = datetime.now()
|
|
|
|
|
|
|
|
account = self.client.models[Account][self.client.user_id]
|
|
|
|
account.first_sync_done = True
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
|
|
|
|
async def onErrorResponse(self, resp: nio.ErrorResponse) -> None:
|
|
|
|
# TODO: show something in the client, must be seen on login screen too
|
|
|
|
try:
|
|
|
|
log.warning("%s - %s", resp, json.dumps(vars(resp), indent=4))
|
|
|
|
except Exception:
|
|
|
|
log.warning(repr(resp))
|
|
|
|
|
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
# Event callbacks
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
async def onRoomMessageText(self, room, ev) -> None:
|
2019-12-19 00:33:22 +11:00
|
|
|
co = HTML_PROCESSOR.filter(
|
2019-11-09 01:20:38 +11:00
|
|
|
ev.formatted_body
|
|
|
|
if ev.format == "org.matrix.custom.html" else
|
|
|
|
utils.plain2html(ev.body),
|
|
|
|
)
|
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-11-30 22:10:48 +11:00
|
|
|
async def onRoomMessageNotice(self, room, ev) -> None:
|
|
|
|
await self.onRoomMessageText(room, ev)
|
|
|
|
|
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
async def onRoomMessageEmote(self, room, ev) -> None:
|
2019-11-30 22:10:48 +11:00
|
|
|
await self.onRoomMessageText(room, ev)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
|
|
|
|
async def onRoomMessageUnknown(self, room, ev) -> None:
|
2019-12-09 03:46:25 +11:00
|
|
|
co = f"%1 sent an unsupported <b>{ev.msgtype}</b> message"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomMessageMedia(self, room, ev) -> None:
|
|
|
|
info = ev.source["content"].get("info", {})
|
|
|
|
media_crypt_dict = ev.source["content"].get("file", {})
|
|
|
|
thumb_info = info.get("thumbnail_info", {})
|
|
|
|
thumb_crypt_dict = info.get("thumbnail_file", {})
|
|
|
|
|
|
|
|
await self.client.register_nio_event(
|
|
|
|
room,
|
|
|
|
ev,
|
|
|
|
content = "",
|
|
|
|
inline_content = ev.body,
|
|
|
|
|
|
|
|
media_url = ev.url,
|
|
|
|
media_title = ev.body,
|
|
|
|
media_width = info.get("w") or 0,
|
|
|
|
media_height = info.get("h") or 0,
|
|
|
|
media_duration = info.get("duration") or 0,
|
|
|
|
media_size = info.get("size") or 0,
|
|
|
|
media_mime = info.get("mimetype") or 0,
|
|
|
|
media_crypt_dict = media_crypt_dict,
|
|
|
|
|
|
|
|
thumbnail_url =
|
|
|
|
info.get("thumbnail_url") or thumb_crypt_dict.get("url") or "",
|
|
|
|
|
|
|
|
thumbnail_width = thumb_info.get("w") or 0,
|
|
|
|
thumbnail_height = thumb_info.get("h") or 0,
|
|
|
|
thumbnail_crypt_dict = thumb_crypt_dict,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomEncryptedMedia(self, room, ev) -> None:
|
|
|
|
await self.onRoomMessageMedia(room, ev)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomCreateEvent(self, room, ev) -> None:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 allowed users on other matrix servers to join this room" \
|
2019-11-09 01:20:38 +11:00
|
|
|
if ev.federate else \
|
2019-12-07 09:23:25 +11:00
|
|
|
"%1 blocked users on other matrix servers from joining this room"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomGuestAccessEvent(self, room, ev) -> None:
|
|
|
|
allowed = "allowed" if ev.guest_access else "forbad"
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 {allowed} guests to join the room"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomJoinRulesEvent(self, room, ev) -> None:
|
|
|
|
access = "public" if ev.join_rule == "public" else "invite-only"
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 made the room {access}"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomHistoryVisibilityEvent(self, room, ev) -> None:
|
|
|
|
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(vars(ev), indent=4))
|
|
|
|
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 made future room history visible to {to}"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onPowerLevelsEvent(self, room, ev) -> None:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 changed the room's permissions" # TODO: improve
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def process_room_member_event(
|
2019-12-19 05:24:55 +11:00
|
|
|
self, room: nio.MatrixRoom, ev: nio.RoomMemberEvent,
|
2019-11-09 01:20:38 +11:00
|
|
|
) -> Optional[Tuple[TypeSpecifier, str]]:
|
2019-12-19 05:24:55 +11:00
|
|
|
"""Return a `TypeSpecifier` and string describing a member event.
|
2019-11-09 01:20:38 +11:00
|
|
|
|
2019-12-19 05:24:55 +11:00
|
|
|
Matrix member events can represent many actions:
|
|
|
|
a user joined the room, a user banned another, a user changed their
|
|
|
|
display name, etc.
|
|
|
|
"""
|
2019-11-09 01:20:38 +11:00
|
|
|
if ev.prev_content == ev.content:
|
|
|
|
return None
|
|
|
|
|
|
|
|
prev = ev.prev_content
|
|
|
|
now = ev.content
|
|
|
|
membership = ev.membership
|
|
|
|
prev_membership = ev.prev_membership
|
|
|
|
ev_date = datetime.fromtimestamp(ev.server_timestamp / 1000)
|
|
|
|
|
|
|
|
member_change = TypeSpecifier.membership_change
|
|
|
|
|
|
|
|
# Membership changes
|
|
|
|
if not prev or membership != prev_membership:
|
2019-12-07 09:23:25 +11:00
|
|
|
reason = f". Reason: {now['reason']}" if now.get("reason") else ""
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
if membership == "join":
|
|
|
|
return (
|
|
|
|
member_change,
|
2019-12-07 09:23:25 +11:00
|
|
|
"%1 accepted their invitation"
|
2019-11-09 01:20:38 +11:00
|
|
|
if prev and prev_membership == "invite" else
|
2019-12-07 09:23:25 +11:00
|
|
|
"%1 joined the room",
|
2019-11-09 01:20:38 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
if membership == "invite":
|
2019-12-07 09:23:25 +11:00
|
|
|
return (member_change, "%1 invited %2 to the room")
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
if membership == "leave":
|
|
|
|
if ev.state_key == ev.sender:
|
|
|
|
return (
|
|
|
|
member_change,
|
2019-12-07 09:23:25 +11:00
|
|
|
f"%1 declined their invitation{reason}"
|
2019-11-09 01:20:38 +11:00
|
|
|
if prev and prev_membership == "invite" else
|
2019-12-07 09:23:25 +11:00
|
|
|
f"%1 left the room{reason}",
|
2019-11-09 01:20:38 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
member_change,
|
|
|
|
|
2019-12-07 09:23:25 +11:00
|
|
|
f"%1 withdrew %2's invitation{reason}"
|
2019-11-09 01:20:38 +11:00
|
|
|
if prev and prev_membership == "invite" else
|
|
|
|
|
2019-12-07 09:23:25 +11:00
|
|
|
f"%1 unbanned %2 from the room{reason}"
|
2019-11-09 01:20:38 +11:00
|
|
|
if prev and prev_membership == "ban" else
|
|
|
|
|
2019-12-07 09:23:25 +11:00
|
|
|
f"%1 kicked out %2 from the room{reason}",
|
2019-11-09 01:20:38 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
if membership == "ban":
|
2019-12-07 09:23:25 +11:00
|
|
|
return (member_change, f"%1 banned %2 from the room{reason}")
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
# Profile changes
|
|
|
|
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:
|
|
|
|
# Update our account profile if the event is newer than last update
|
|
|
|
if ev.state_key == self.client.user_id:
|
|
|
|
account = self.client.models[Account][self.client.user_id]
|
|
|
|
updated = account.profile_updated
|
|
|
|
|
|
|
|
if not updated or updated < ev_date:
|
|
|
|
account.profile_updated = ev_date
|
|
|
|
account.display_name = now["displayname"] or ""
|
|
|
|
account.avatar_url = now["avatar_url"] or ""
|
|
|
|
|
|
|
|
# Hide profile events from the timeline - XXX
|
|
|
|
self.client.skipped_events[room.room_id] += 1
|
|
|
|
return None
|
|
|
|
|
|
|
|
return (
|
|
|
|
TypeSpecifier.profile_change,
|
2019-12-07 09:23:25 +11:00
|
|
|
"%1 changed their {}".format(" and ".join(changed)),
|
2019-11-09 01:20:38 +11:00
|
|
|
)
|
|
|
|
|
|
|
|
log.warning("Unknown member event: %s", json.dumps(vars(ev), indent=4))
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomMemberEvent(self, room, ev) -> None:
|
|
|
|
type_and_content = await self.process_room_member_event(room, ev)
|
|
|
|
|
2019-12-13 01:03:39 +11:00
|
|
|
if type_and_content is not None:
|
2019-11-09 01:20:38 +11:00
|
|
|
type_specifier, content = type_and_content
|
2019-12-15 04:50:21 +11:00
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(
|
|
|
|
room, ev, content=content, type_specifier=type_specifier,
|
|
|
|
)
|
2019-12-13 01:03:39 +11:00
|
|
|
else:
|
|
|
|
# Normally, register_nio_event() will call register_nio_room().
|
|
|
|
# but in this case we don't have any event we want to register.
|
|
|
|
await self.client.register_nio_room(room)
|
2019-11-09 01:20:38 +11:00
|
|
|
|
|
|
|
|
|
|
|
async def onRoomAliasEvent(self, room, ev) -> None:
|
2019-11-27 21:18:06 +11:00
|
|
|
if ev.canonical_alias:
|
2019-11-27 21:30:42 +11:00
|
|
|
url = f"https://matrix.to/#/{quote(ev.canonical_alias)}"
|
|
|
|
link = f"<a href='{url}'>{ev.canonical_alias}</a>"
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 set the room's main address to {link}"
|
2019-11-27 21:18:06 +11:00
|
|
|
else:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 removed the room's main address"
|
2019-11-27 21:18:06 +11:00
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomNameEvent(self, room, ev) -> None:
|
2019-11-27 21:18:06 +11:00
|
|
|
if ev.name:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 changed the room's name to \"{ev.name}\""
|
2019-11-27 21:18:06 +11:00
|
|
|
else:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 removed the room's name"
|
2019-11-27 21:18:06 +11:00
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-11-27 21:18:06 +11:00
|
|
|
async def onRoomAvatarEvent(self, room, ev) -> None:
|
|
|
|
if ev.avatar_url:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 changed the room's picture"
|
2019-11-27 21:18:06 +11:00
|
|
|
else:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 removed the room's picture"
|
2019-11-27 21:18:06 +11:00
|
|
|
|
|
|
|
await self.client.register_nio_event(
|
|
|
|
room, ev, content=co, media_url=ev.avatar_url,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
async def onRoomTopicEvent(self, room, ev) -> None:
|
2019-11-27 21:18:06 +11:00
|
|
|
if ev.topic:
|
2019-12-19 00:33:22 +11:00
|
|
|
topic = HTML_PROCESSOR.filter_inline(ev.topic)
|
2019-12-07 09:23:25 +11:00
|
|
|
co = f"%1 changed the room's topic to \"{topic}\""
|
2019-11-27 21:18:06 +11:00
|
|
|
else:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 removed the room's topic"
|
2019-11-27 21:18:06 +11:00
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onRoomEncryptionEvent(self, room, ev) -> None:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 turned on encryption for this room"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onMegolmEvent(self, room, ev) -> None:
|
2019-12-07 09:23:25 +11:00
|
|
|
co = "%1 sent an undecryptable message"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onBadEvent(self, room, ev) -> None:
|
2019-12-09 03:46:25 +11:00
|
|
|
co = f"%1 sent a malformed <b>{ev.type}</b> event"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onUnknownBadEvent(self, room, ev) -> None:
|
2019-12-09 03:46:25 +11:00
|
|
|
co = "%1 sent a malformed event lacking a minimal structure"
|
2019-12-05 09:20:30 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
|
|
|
async def onUnknownEvent(self, room, ev) -> None:
|
2019-12-09 03:46:25 +11:00
|
|
|
co = f"%1 sent an unsupported <b>{ev.type}</b> event"
|
2019-11-09 01:20:38 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-12-05 09:20:30 +11:00
|
|
|
async def onUnknownEncryptedEvent(self, room, ev) -> None:
|
2019-12-09 03:46:25 +11:00
|
|
|
co = (
|
|
|
|
f"%1 sent an <b>{ev.type}</b> event encrypted with "
|
|
|
|
f"unsupported <b>{ev.algorithm}</b> algorithm"
|
|
|
|
)
|
2019-12-05 09:20:30 +11:00
|
|
|
await self.client.register_nio_event(room, ev, content=co)
|
|
|
|
|
|
|
|
|
2019-11-09 01:20:38 +11:00
|
|
|
async def onInviteEvent(self, room, ev) -> None:
|
|
|
|
await self.client.register_nio_room(room)
|
|
|
|
|
|
|
|
|
|
|
|
async def onTypingNoticeEvent(self, room, ev) -> None:
|
|
|
|
# Prevent recent past typing notices from being shown for a split
|
|
|
|
# second on client startup:
|
|
|
|
if not self.client.first_sync_done.is_set():
|
|
|
|
return
|
|
|
|
|
|
|
|
if room.room_id not in self.client.models[Room, self.client.user_id]:
|
|
|
|
return
|
|
|
|
|
|
|
|
room_item = self.client.models[Room, self.client.user_id][room.room_id]
|
|
|
|
|
|
|
|
room_item.typing_members = sorted(
|
|
|
|
room.user_name(user_id) for user_id in ev.users
|
|
|
|
if user_id not in self.client.backend.clients
|
|
|
|
)
|