multi-user chats

This commit is contained in:
Andrew Ferrazzutti 2021-02-25 22:21:11 -05:00
parent b25bac8cea
commit 20f419a5c3
9 changed files with 286 additions and 77 deletions

View File

@ -58,6 +58,7 @@ class Config(BaseBridgeConfig):
copy("bridge.displayname_max_length")
copy("bridge.initial_conversation_sync")
copy("bridge.invite_own_puppet_to_pm")
copy("bridge.login_shared_secret")
copy("bridge.federate_rooms")
copy("bridge.backfill.invite_own_puppet")

View File

@ -78,6 +78,9 @@ bridge:
# Number of conversations to sync (and create portals for) on login.
# Set 0 to disable automatic syncing.
initial_conversation_sync: 10
# Whether or not the LINE users of logged in Matrix users should be
# invited to private chats when the user sends a message from another client.
invite_own_puppet_to_pm: false
# Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, custom puppets will be enabled automatically for local users

View File

@ -22,7 +22,7 @@ import magic
from mautrix.appservice import AppService, IntentAPI
from mautrix.bridge import BasePortal, NotificationDisabler
from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType,
TextMessageEventContent, MediaMessageEventContent,
TextMessageEventContent, MediaMessageEventContent, Membership,
ContentURI, EncryptedFile)
from mautrix.errors import MatrixError
from mautrix.util.simple_lock import SimpleLock
@ -49,6 +49,7 @@ ReuploadedMediaInfo = NamedTuple('ReuploadedMediaInfo', mxc=Optional[ContentURI]
class Portal(DBPortal, BasePortal):
invite_own_puppet_to_pm: bool = False
by_mxid: Dict[RoomID, 'Portal'] = {}
by_chat_id: Dict[int, 'Portal'] = {}
config: Config
@ -60,8 +61,6 @@ class Portal(DBPortal, BasePortal):
backfill_lock: SimpleLock
_last_participant_update: Set[str]
_main_intent: IntentAPI
def __init__(self, chat_id: int, other_user: Optional[str] = None,
mxid: Optional[RoomID] = None, name: Optional[str] = None, encrypted: bool = False
) -> None:
@ -77,7 +76,15 @@ class Portal(DBPortal, BasePortal):
@property
def is_direct(self) -> bool:
return self.other_user is not None
return self.chat_id[0] == "u"
@property
def is_group(self) -> bool:
return self.chat_id[0] == "c"
@property
def is_room(self) -> bool:
return self.chat_id[0] == "r"
@property
def main_intent(self) -> IntentAPI:
@ -92,6 +99,7 @@ class Portal(DBPortal, BasePortal):
cls.az = bridge.az
cls.loop = bridge.loop
cls.bridge = bridge
cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"]
NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"]
@ -145,17 +153,39 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"{user.mxid} left portal to {self.chat_id}")
# TODO cleanup if empty
async def handle_remote_message(self, source: 'u.User', evt: Message) -> None:
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
invite: bool = True) -> Optional[IntentAPI]:
# Use bridge bot as puppet for own user when puppet for own user is unavailable
# TODO Use own LINE puppet instead, if it's available
intent = sender.intent if sender else self.az.intent
if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user):
if self.invite_own_puppet_to_pm and invite:
await self.main_intent.invite_user(self.mxid, intent.mxid)
elif await self.az.state_store.get_membership(self.mxid,
intent.mxid) != Membership.JOIN:
self.log.warning(f"Ignoring own {mid} in private chat because own puppet is not in"
" room.")
intent = None
return intent
async def handle_remote_message(self, source: 'u.User', sender: Optional['p.Puppet'],
evt: Message) -> None:
if evt.is_outgoing:
if not source.intent:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return
intent = source.intent
if source.intent:
intent = source.intent
else:
if not self.invite_own_puppet_to_pm:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return
intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
if not intent:
return
elif self.other_user:
intent = (await p.Puppet.get_by_mid(self.other_user)).intent
elif sender:
intent = sender.intent
else:
# TODO group chats
self.log.warning(f"Ignoring message {evt.id}: group chats aren't supported yet")
self.log.warning(f"Ignoring message {evt.id}: sender puppet is unavailable")
return
if await DBMessage.get_by_mid(evt.id):
@ -200,19 +230,22 @@ class Portal(DBPortal, BasePortal):
return ReuploadedMediaInfo(mxc, decryption_info, mime_type, file_name, len(data))
async def update_info(self, conv: ChatInfo) -> None:
# TODO Not true: a single-participant chat could be a group!
if len(conv.participants) == 1:
if self.is_direct:
self.other_user = conv.participants[0].id
if self._main_intent is self.az.intent:
self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent
for participant in conv.participants:
puppet = await p.Puppet.get_by_mid(participant.id)
await puppet.update_info(participant)
changed = await self._update_name(conv.name)
# TODO Consider setting no room name for non-group chats.
# But then the LINE bot itself may appear in the title...
changed = await self._update_name(f"{conv.name} (LINE)")
if changed:
await self.update_bridge_info()
await self.update()
await self._update_participants(conv.participants)
# NOTE Don't call this yet, lest puppets join earlier than
# when their user actually joined or sent a message.
#await self._update_participants(conv.participants)
async def _update_name(self, name: str) -> bool:
if self.name != name:
@ -251,8 +284,11 @@ class Portal(DBPortal, BasePortal):
reason="User had left this chat")
async def backfill(self, source: 'u.User') -> None:
with self.backfill_lock:
await self._backfill(source)
try:
with self.backfill_lock:
await self._backfill(source)
except Exception:
self.log.exception("Failed to backfill portal")
async def _backfill(self, source: 'u.User') -> None:
self.log.debug("Backfilling history through %s", source.mxid)
@ -267,8 +303,15 @@ class Portal(DBPortal, BasePortal):
self.log.debug("Got %d messages from server", len(messages))
async with NotificationDisabler(self.mxid, source):
# Member joins/leaves are not shown in chat history.
# Best we can do is have a puppet join if its user had sent a message.
members_known = set(await self.main_intent.get_room_members(self.mxid)) if not self.is_direct else None
for evt in messages:
await self.handle_remote_message(source, evt)
puppet = await p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None
if puppet and evt.sender.id not in members_known:
await puppet.update_info(evt.sender)
members_known.add(evt.sender.id)
await self.handle_remote_message(source, puppet, evt)
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
@property
@ -325,6 +368,8 @@ class Portal(DBPortal, BasePortal):
await puppet.az.intent.ensure_joined(self.mxid)
await self.update_info(info)
await self.backfill(source)
await self._update_participants(info.participants)
async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]:
if self.mxid:
@ -352,8 +397,10 @@ class Portal(DBPortal, BasePortal):
})
if self.is_direct:
invites.append(self.az.bot_mxid)
if self.encrypted or not self.is_direct:
name = self.name
# NOTE Set the room title even for direct chats, because
# the LINE bot itself may appear in the title otherwise.
#if self.encrypted or not self.is_direct:
name = self.name
if self.config["appservice.community_id"]:
initial_state.append({
"type": "m.room.related_groups",
@ -394,6 +441,11 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Matrix room created: {self.mxid}")
self.by_mxid[self.mxid] = self
if not self.is_direct:
# For multi-user chats, backfill before updating participants,
# to act as as a best guess of when users actually joined.
# No way to tell when a user actually left, so just check the
# participants list after backfilling.
await self.backfill(source)
await self._update_participants(info.participants)
else:
puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
@ -403,11 +455,7 @@ class Portal(DBPortal, BasePortal):
except MatrixError:
self.log.debug("Failed to join custom puppet into newly created portal",
exc_info=True)
try:
await self.backfill(source)
except Exception:
self.log.exception("Failed to backfill new portal")
return self.mxid

View File

@ -98,6 +98,7 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod
async def get_by_mid(cls, mid: str, create: bool = True) -> Optional['Puppet']:
# TODO Might need to parse a real id from "_OWN"
try:
return cls.by_mid[mid]
except KeyError:

View File

@ -35,6 +35,7 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']):
@dataclass
class Participant(SerializableAttrs['Participant']):
id: str
# TODO avatar: str
name: str
@ -48,6 +49,7 @@ class Message(SerializableAttrs['Message']):
id: int
chat_id: int
is_outgoing: bool
sender: Optional[Participant]
timestamp: int = None
text: Optional[str] = None
image: Optional[str] = None

View File

@ -124,7 +124,6 @@ class User(DBUser, BaseUser):
chat = await self.client.get_chat(chat.id)
if portal.mxid:
await portal.update_matrix_room(self, chat)
await portal.backfill(self)
else:
await portal.create_matrix_room(self, chat)
@ -145,10 +144,11 @@ class User(DBUser, BaseUser):
async def handle_message(self, evt: Message) -> None:
self.log.trace("Received message %s", evt)
portal = await po.Portal.get_by_chat_id(evt.chat_id, create=True)
puppet = await pu.Puppet.get_by_mid(evt.sender.id) if not portal.is_direct else None
if not portal.mxid:
chat_info = await self.client.get_chat(evt.chat_id)
await portal.create_matrix_room(self, chat_info)
await portal.handle_remote_message(self, evt)
await portal.handle_remote_message(self, puppet, evt)
def _add_to_cache(self) -> None:
self.by_mxid[self.mxid] = self

View File

@ -99,7 +99,7 @@ export default class Client {
}
sendMessage(message) {
this.log("Sending", message, "to client")
this.log(`Sending message ${message.id} to client`)
return this._write({
id: --this.notificationID,
command: "message",
@ -204,7 +204,9 @@ export default class Client {
this.log("Ignoring old request", req.id)
return
}
this.log("Received request", req.id, "with command", req.command)
if (req.command != "is_connected") {
this.log("Received request", req.id, "with command", req.command)
}
this.maxCommandID = req.id
let handler
if (!this.userID) {

View File

@ -51,15 +51,46 @@ window.__mautrixExpiry = function (button) {}
* @return {Promise<void>}
*/
window.__mautrixReceiveMessageID = function(id) {}
/**
* @return {Promise<Element>}
*/
window.__mautrixGetParticipantsList = function() {}
const ChatTypeEnum = Object.freeze({
DIRECT: 1,
GROUP: 2,
ROOM: 3,
})
class MautrixController {
constructor() {
constructor(ownID) {
this.chatListObserver = null
this.qrChangeObserver = null
this.qrAppearObserver = null
this.emailAppearObserver = null
this.pinAppearObserver = null
this.expiryObserver = null
this.ownID = null
}
setOwnID(ownID) {
// Remove characters that will conflict with mxid grammar
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
}
// TODO Commonize with Node context
getChatType(id) {
switch (id.charAt(0)) {
case "u":
return ChatTypeEnum.DIRECT
case "c":
return ChatTypeEnum.GROUP
case "r":
return ChatTypeEnum.ROOM
default:
throw `Invalid chat ID: ${id}`
}
}
/**
@ -98,12 +129,23 @@ class MautrixController {
return newDate && newDate <= now ? newDate : null
}
/**
* Try to match a user against an entry in the friends list to get their ID.
*
* @param {Element} element - The display name of the user to find the ID for.
* @return {null|str} - The user's ID if found.
*/
getUserIdFromFriendsList(senderName) {
return document.querySelector(`#contact_wrap_friends > ul > li[title='${senderName}']`)?.getAttribute("data-mid")
}
/**
* @typedef MessageData
* @type {object}
* @property {number} id - The ID of the message. Seems to be sequential.
* @property {number} timestamp - The unix timestamp of the message. Not very accurate.
* @property {boolean} is_outgoing - Whether or not this user sent the message.
* @property {null|Participant} sender - Full data of the participant who sent the message, if needed and available.
* @property {string} [text] - The text in the message.
* @property {string} [image] - The URL to the image in the message.
*/
@ -113,14 +155,59 @@ class MautrixController {
*
* @param {Date} date - The most recent date indicator.
* @param {Element} element - The message element.
* @param {int} chatType - What kind of chat this message is part of.
* @return {MessageData}
* @private
*/
_tryParseMessage(date, element) {
async _tryParseMessage(date, element, chatType) {
const is_outgoing = element.classList.contains("mdRGT07Own")
let sender = {}
// TODO Clean up participantsList access...
const participantsListSelector = "#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul"
// Don't need sender ID for direct chats, since the portal will have it already.
if (chatType == ChatTypeEnum.DIRECT) {
sender = null
} else if (!is_outgoing) {
sender.name = element.querySelector(".mdRGT07Body > .mdRGT07Ttl").innerText
// Room members are always friends (right?),
// so search the friend list for the sender's name
// and get their ID from there.
// TODO For rooms, allow creating Matrix puppets in case
// a message is sent by someone who since left the
// room and never had a puppet made for them yet.
sender.id = this.getUserIdFromFriendsList(sender.name)
// Group members aren't necessarily friends,
// but the participant list includes their ID.
if (!sender.id) {
await window.__mautrixShowParticipantsList()
const participantsList = document.querySelector(participantsListSelector)
sender.id = participantsList.querySelector(`img[alt='${senderName}'`).parentElement.parentElement.getAttribute("data-mid")
}
// TODO Avatar
} else {
// TODO Get own ID and store it somewhere appropriate.
// Unable to get own ID from a room chat...
// if (chatType == ChatTypeEnum.GROUP) {
// await window.__mautrixShowParticipantsList()
// const participantsList = document.querySelector("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
// // TODO The first member is always yourself, right?
// // TODO Cache this so own ID can be used later
// sender = participantsList.children[0].getAttribute("data-mid")
// }
await window.__mautrixShowParticipantsList()
const participantsList = document.querySelector(participantsListSelector)
sender.name = this.getParticipantListItemName(participantsList.children[0])
// TODO avatar
sender.id = this.ownID
}
const messageData = {
id: +element.getAttribute("data-local-id"),
timestamp: date ? date.getTime() : null,
is_outgoing: element.classList.contains("mdRGT07Own"),
is_outgoing: is_outgoing,
sender: sender,
}
const messageElement = element.querySelector(".mdRGT07Body > .mdRGT07Msg")
if (messageElement.classList.contains("mdRGT07Text")) {
@ -184,9 +271,14 @@ class MautrixController {
/**
* Parse the message list of whatever the currently-viewed chat is.
*
* @param {null|string} chatId - The ID of the currently-viewed chat, if known.
* @return {[MessageData]} - A list of messages.
*/
async parseMessageList() {
async parseMessageList(chatId) {
if (!chatId) {
chatId = this.getChatListItemId(document.querySelector("#_chat_list_body > .ExSelected > div"))
}
const chatType = this.getChatType(chatId);
const msgList = document.querySelector("#_chat_room_msg_list")
const messages = []
let refDate = null
@ -202,7 +294,7 @@ class MautrixController {
const timeElement = child.querySelector("time")
if (timeElement) {
const messageDate = await this._tryParseDate(timeElement.innerText, refDate)
messages.push(this._tryParseMessage(messageDate, child))
messages.push(await this._tryParseMessage(messageDate, child, chatType))
}
}
}
@ -218,6 +310,15 @@ class MautrixController {
* @property {string} name - The contact list name of the participant
*/
getParticipantListItemName(element) {
return element.querySelector(".mdRGT13Ttl").innerText
}
getParticipantListItemId(element) {
// TODO Cache own ID
return element.getAttribute("data-mid")
}
/**
* Parse a group participants list.
* TODO Find what works for a *room* participants list...!
@ -226,16 +327,26 @@ class MautrixController {
* @return {[Participant]} - The list of participants.
*/
parseParticipantList(element) {
// TODO Slice to exclude first member, which is always yourself (right?)
// TODO Only slice if double-puppeting is enabled!
//return Array.from(element.children).slice(1).map(child => {
return Array.from(element.children).map(child => {
// TODO Might need to explicitly exclude own user if double-puppeting is enabled.
// TODO The first member is always yourself, right?
const ownParticipant = {
// TODO Find way to make this work with multiple mxids using the bridge.
// One idea is to add real ID as suffix if we're in a group, and
// put in the puppet DB table somehow.
id: this.ownID,
// TODO avatar: child.querySelector("img").src,
name: this.getParticipantListItemName(element.children[0]),
}
return [ownParticipant].concat(Array.from(element.children).slice(1).map(child => {
const name = this.getParticipantListItemName(child)
const id = this.getParticipantListItemId(child) || this.getUserIdFromFriendsList(name)
return {
id: child.getAttribute("data-mid"),
id: id, // NOTE Don't want non-own user's ID to ever be null.
// TODO avatar: child.querySelector("img").src,
name: child.querySelector(".mdRGT13Ttl").innerText,
name: name,
}
})
}))
}
/**

View File

@ -29,7 +29,7 @@ export default class MessagesPuppeteer {
static noSandbox = false
static viewport = { width: 960, height: 880 }
static url = undefined
static extensionDir = 'extension_files'
static extensionDir = "extension_files"
/**
*
@ -58,6 +58,14 @@ export default class MessagesPuppeteer {
console.error(`[Puppeteer/${this.id}]`, ...text)
}
/**
* Get the inner text of an element.
* To be called in browser context.
*/
_getInnerText(element) {
return element?.innerText
}
/**
* Start the browser and open the messages for web page.
* This must be called before doing anything else.
@ -97,6 +105,7 @@ export default class MessagesPuppeteer {
id => this.sentMessageIDs.add(id))
await this.page.exposeFunction("__mautrixReceiveChanges",
this._receiveChatListChanges.bind(this))
await this.page.exposeFunction("__mautrixShowParticipantsList", this._showParticipantList.bind(this))
await this.page.exposeFunction("__chronoParseDate", chrono.parseDate)
// NOTE Must *always* re-login on a browser session, so no need to check if already logged in
@ -204,11 +213,12 @@ export default class MessagesPuppeteer {
return value
}),
() => this.page.waitForSelector("#login_incorrect", {visible: true, timeout: 2000})
.then(value => this.page.evaluate(element => element.innerText, value)),
.then(value => this.page.evaluate(_getInnerText, value)),
() => this._waitForLoginCancel(),
].map(promiseFn => cancelableResolve(promiseFn)))
this.log("Removing observers")
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id)
await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver())
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
@ -402,68 +412,99 @@ export default class MessagesPuppeteer {
}
async _switchChat(id) {
// TODO Allow passing in an element directly
this.log(`Switching to chat ${id}`)
const chatListItem = await this.page.$(this._listItemSelector(id))
await chatListItem.click()
const chatHeader = await this.page.waitForSelector("#_chat_header_area > .mdRGT04Link")
const chatListInfo = await chatListItem.evaluate(
(e, id) => window.__mautrixController.parseChatListItem(e, id),
id)
const chatName = await chatListItem.evaluate(
element => window.__mautrixController.getChatListItemName(element))
this.log(`Waiting for chat header title to be "${chatListInfo.name}"`)
const chatHeaderTitleElement = await chatHeader.$(".mdRGT04Ttl")
await this.page.waitForFunction(
(element, targetText) => element.innerText == targetText,
{},
chatHeaderTitleElement, chatListInfo.name)
const isCorrectChatVisible = (targetText) => {
const chatHeader = document.querySelector("#_chat_header_area > .mdRGT04Link")
if (!chatHeader) return false
const chatHeaderTitleElement = chatHeader.querySelector(".mdRGT04Ttl")
return chatHeaderTitleElement.innerText == targetText
}
return [chatListItem, chatListInfo, chatHeader]
if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
this.log("Already viewing chat, no need to switch")
} else {
await chatListItem.click()
this.log(`Waiting for chat header title to be "${chatName}"`)
await this.page.waitForFunction(
isCorrectChatVisible,
{polling: "mutation"},
chatName)
// For consistent behaviour later, wait for the chat details sidebar to be hidden
await this.page.waitForFunction(
detailArea => detailArea.childElementCount == 0,
{},
await this.page.$("#_chat_detail_area"))
}
}
// TODO Commonize
async getParticipantList() {
await this._showParticipantList()
return await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
}
async _showParticipantList() {
const selector = "#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul"
let participantList = await this.page.$(selector)
if (!participantList) {
this.log("Participant list hidden, so clicking chat header to show it")
await this.page.click("#_chat_header_area > .mdRGT04Link")
participantList = await this.page.waitForSelector(selector)
}
//return participantList
}
async _getChatInfoUnsafe(id) {
const chatListItem = await this.page.$(this._listItemSelector(id))
const chatListInfo = await chatListItem.evaluate(
(element, id) => window.__mautrixController.parseChatListItem(element, id),
id)
let [isDirect, isGroup, isRoom] = [false,false,false]
switch (id.charAt(0)) {
case 'u':
case "u":
isDirect = true
break
case 'c':
case "c":
isGroup = true
break
case 'r':
case "r":
isRoom = true
break
}
// TODO This will mark the chat as "read"!
const [chatListItem, chatListInfo, chatHeader] = await this._switchChat(id)
let participants
if (isGroup || isRoom) {
if (!isDirect) {
this.log("Found multi-user chat, so clicking chat header to get participants")
await chatHeader.click()
const participantList = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
if (isGroup) {
// TODO Is a group not actually created until a message is sent(?)
// If so, maybe don't create a portal until there is a message.
participants = await participantList.evaluate(
elem => window.__mautrixController.parseParticipantList(elem))
} else if (isRoom) {
this.log("TODO: Room participant lists don't have user IDs...")
participants = []
}
}
else
{
// TODO This will mark the chat as "read"!
await this._switchChat(id)
const participantList = await this.getParticipantList()
// TODO Is a group not actually created until a message is sent(?)
// If so, maybe don't create a portal until there is a message.
participants = await participantList.evaluate(
element => window.__mautrixController.parseParticipantList(element))
} else {
this.log(`Found direct chat with ${id}`)
//const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{
id: id,
// TODO avatar, or leave null since this is a 1:1 chat
name: chatListInfo.name,
}]
}
this.log("Found participants:")
for (const participant of participants) {
this.log(participant)
}
return {participants, ...chatListInfo}
}
@ -504,7 +545,7 @@ export default class MessagesPuppeteer {
await this._switchChat(id)
this.log("Waiting for messages to load")
const messages = await this.page.evaluate(
() => window.__mautrixController.parseMessageList())
id => window.__mautrixController.parseMessageList(id), id)
return messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
}
@ -582,7 +623,7 @@ export default class MessagesPuppeteer {
_sendLoginFailure(reason) {
this.loginRunning = false
this.error(`Login failure: ${reason ? reason : 'cancelled'}`)
this.error(`Login failure: ${reason ? reason : "cancelled"}`)
if (this.client) {
this.client.sendFailure(reason).catch(err =>
this.error("Failed to send failure reason to client:", err))