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.displayname_max_length")
copy("bridge.initial_conversation_sync") copy("bridge.initial_conversation_sync")
copy("bridge.invite_own_puppet_to_pm")
copy("bridge.login_shared_secret") copy("bridge.login_shared_secret")
copy("bridge.federate_rooms") copy("bridge.federate_rooms")
copy("bridge.backfill.invite_own_puppet") copy("bridge.backfill.invite_own_puppet")

View File

@ -78,6 +78,9 @@ bridge:
# Number of conversations to sync (and create portals for) on login. # Number of conversations to sync (and create portals for) on login.
# Set 0 to disable automatic syncing. # Set 0 to disable automatic syncing.
initial_conversation_sync: 10 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 # Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
# #
# If set, custom puppets will be enabled automatically for local users # 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.appservice import AppService, IntentAPI
from mautrix.bridge import BasePortal, NotificationDisabler from mautrix.bridge import BasePortal, NotificationDisabler
from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType,
TextMessageEventContent, MediaMessageEventContent, TextMessageEventContent, MediaMessageEventContent, Membership,
ContentURI, EncryptedFile) ContentURI, EncryptedFile)
from mautrix.errors import MatrixError from mautrix.errors import MatrixError
from mautrix.util.simple_lock import SimpleLock from mautrix.util.simple_lock import SimpleLock
@ -49,6 +49,7 @@ ReuploadedMediaInfo = NamedTuple('ReuploadedMediaInfo', mxc=Optional[ContentURI]
class Portal(DBPortal, BasePortal): class Portal(DBPortal, BasePortal):
invite_own_puppet_to_pm: bool = False
by_mxid: Dict[RoomID, 'Portal'] = {} by_mxid: Dict[RoomID, 'Portal'] = {}
by_chat_id: Dict[int, 'Portal'] = {} by_chat_id: Dict[int, 'Portal'] = {}
config: Config config: Config
@ -60,8 +61,6 @@ class Portal(DBPortal, BasePortal):
backfill_lock: SimpleLock backfill_lock: SimpleLock
_last_participant_update: Set[str] _last_participant_update: Set[str]
_main_intent: IntentAPI
def __init__(self, chat_id: int, other_user: Optional[str] = None, def __init__(self, chat_id: int, other_user: Optional[str] = None,
mxid: Optional[RoomID] = None, name: Optional[str] = None, encrypted: bool = False mxid: Optional[RoomID] = None, name: Optional[str] = None, encrypted: bool = False
) -> None: ) -> None:
@ -77,7 +76,15 @@ class Portal(DBPortal, BasePortal):
@property @property
def is_direct(self) -> bool: 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 @property
def main_intent(self) -> IntentAPI: def main_intent(self) -> IntentAPI:
@ -92,6 +99,7 @@ class Portal(DBPortal, BasePortal):
cls.az = bridge.az cls.az = bridge.az
cls.loop = bridge.loop cls.loop = bridge.loop
cls.bridge = bridge cls.bridge = bridge
cls.invite_own_puppet_to_pm = cls.config["bridge.invite_own_puppet_to_pm"]
NotificationDisabler.puppet_cls = p.Puppet NotificationDisabler.puppet_cls = p.Puppet
NotificationDisabler.config_enabled = cls.config["bridge.backfill.disable_notifications"] 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}") self.log.debug(f"{user.mxid} left portal to {self.chat_id}")
# TODO cleanup if empty # 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 evt.is_outgoing:
if not source.intent: if source.intent:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled") intent = source.intent
return else:
intent = source.intent 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: elif self.other_user:
intent = (await p.Puppet.get_by_mid(self.other_user)).intent intent = (await p.Puppet.get_by_mid(self.other_user)).intent
elif sender:
intent = sender.intent
else: else:
# TODO group chats self.log.warning(f"Ignoring message {evt.id}: sender puppet is unavailable")
self.log.warning(f"Ignoring message {evt.id}: group chats aren't supported yet")
return return
if await DBMessage.get_by_mid(evt.id): 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)) return ReuploadedMediaInfo(mxc, decryption_info, mime_type, file_name, len(data))
async def update_info(self, conv: ChatInfo) -> None: async def update_info(self, conv: ChatInfo) -> None:
# TODO Not true: a single-participant chat could be a group! if self.is_direct:
if len(conv.participants) == 1:
self.other_user = conv.participants[0].id self.other_user = conv.participants[0].id
if self._main_intent is self.az.intent: if self._main_intent is self.az.intent:
self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent self._main_intent = (await p.Puppet.get_by_mid(self.other_user)).intent
for participant in conv.participants: for participant in conv.participants:
puppet = await p.Puppet.get_by_mid(participant.id) puppet = await p.Puppet.get_by_mid(participant.id)
await puppet.update_info(participant) 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: if changed:
await self.update_bridge_info() await self.update_bridge_info()
await self.update() 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: async def _update_name(self, name: str) -> bool:
if self.name != name: if self.name != name:
@ -251,8 +284,11 @@ class Portal(DBPortal, BasePortal):
reason="User had left this chat") reason="User had left this chat")
async def backfill(self, source: 'u.User') -> None: async def backfill(self, source: 'u.User') -> None:
with self.backfill_lock: try:
await self._backfill(source) 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: async def _backfill(self, source: 'u.User') -> None:
self.log.debug("Backfilling history through %s", source.mxid) 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)) self.log.debug("Got %d messages from server", len(messages))
async with NotificationDisabler(self.mxid, source): 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: 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) self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
@property @property
@ -325,6 +368,8 @@ class Portal(DBPortal, BasePortal):
await puppet.az.intent.ensure_joined(self.mxid) await puppet.az.intent.ensure_joined(self.mxid)
await self.update_info(info) 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]: async def _create_matrix_room(self, source: 'u.User', info: ChatInfo) -> Optional[RoomID]:
if self.mxid: if self.mxid:
@ -352,8 +397,10 @@ class Portal(DBPortal, BasePortal):
}) })
if self.is_direct: if self.is_direct:
invites.append(self.az.bot_mxid) invites.append(self.az.bot_mxid)
if self.encrypted or not self.is_direct: # NOTE Set the room title even for direct chats, because
name = self.name # 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"]: if self.config["appservice.community_id"]:
initial_state.append({ initial_state.append({
"type": "m.room.related_groups", "type": "m.room.related_groups",
@ -394,6 +441,11 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Matrix room created: {self.mxid}") self.log.debug(f"Matrix room created: {self.mxid}")
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self
if not self.is_direct: 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) await self._update_participants(info.participants)
else: else:
puppet = await p.Puppet.get_by_custom_mxid(source.mxid) puppet = await p.Puppet.get_by_custom_mxid(source.mxid)
@ -403,11 +455,7 @@ class Portal(DBPortal, BasePortal):
except MatrixError: except MatrixError:
self.log.debug("Failed to join custom puppet into newly created portal", self.log.debug("Failed to join custom puppet into newly created portal",
exc_info=True) exc_info=True)
try:
await self.backfill(source) await self.backfill(source)
except Exception:
self.log.exception("Failed to backfill new portal")
return self.mxid return self.mxid

View File

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

View File

@ -35,6 +35,7 @@ class ChatListInfo(SerializableAttrs['ChatListInfo']):
@dataclass @dataclass
class Participant(SerializableAttrs['Participant']): class Participant(SerializableAttrs['Participant']):
id: str id: str
# TODO avatar: str
name: str name: str
@ -48,6 +49,7 @@ class Message(SerializableAttrs['Message']):
id: int id: int
chat_id: int chat_id: int
is_outgoing: bool is_outgoing: bool
sender: Optional[Participant]
timestamp: int = None timestamp: int = None
text: Optional[str] = None text: Optional[str] = None
image: 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) chat = await self.client.get_chat(chat.id)
if portal.mxid: if portal.mxid:
await portal.update_matrix_room(self, chat) await portal.update_matrix_room(self, chat)
await portal.backfill(self)
else: else:
await portal.create_matrix_room(self, chat) await portal.create_matrix_room(self, chat)
@ -145,10 +144,11 @@ class User(DBUser, BaseUser):
async def handle_message(self, evt: Message) -> None: async def handle_message(self, evt: Message) -> None:
self.log.trace("Received message %s", evt) self.log.trace("Received message %s", evt)
portal = await po.Portal.get_by_chat_id(evt.chat_id, create=True) 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: if not portal.mxid:
chat_info = await self.client.get_chat(evt.chat_id) chat_info = await self.client.get_chat(evt.chat_id)
await portal.create_matrix_room(self, chat_info) 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: def _add_to_cache(self) -> None:
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self

View File

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

View File

@ -51,15 +51,46 @@ window.__mautrixExpiry = function (button) {}
* @return {Promise<void>} * @return {Promise<void>}
*/ */
window.__mautrixReceiveMessageID = function(id) {} window.__mautrixReceiveMessageID = function(id) {}
/**
* @return {Promise<Element>}
*/
window.__mautrixGetParticipantsList = function() {}
const ChatTypeEnum = Object.freeze({
DIRECT: 1,
GROUP: 2,
ROOM: 3,
})
class MautrixController { class MautrixController {
constructor() { constructor(ownID) {
this.chatListObserver = null this.chatListObserver = null
this.qrChangeObserver = null this.qrChangeObserver = null
this.qrAppearObserver = null this.qrAppearObserver = null
this.emailAppearObserver = null this.emailAppearObserver = null
this.pinAppearObserver = null this.pinAppearObserver = null
this.expiryObserver = 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 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 * @typedef MessageData
* @type {object} * @type {object}
* @property {number} id - The ID of the message. Seems to be sequential. * @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 {number} timestamp - The unix timestamp of the message. Not very accurate.
* @property {boolean} is_outgoing - Whether or not this user sent the message. * @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} [text] - The text in the message.
* @property {string} [image] - The URL to the image 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 {Date} date - The most recent date indicator.
* @param {Element} element - The message element. * @param {Element} element - The message element.
* @param {int} chatType - What kind of chat this message is part of.
* @return {MessageData} * @return {MessageData}
* @private * @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 = { const messageData = {
id: +element.getAttribute("data-local-id"), id: +element.getAttribute("data-local-id"),
timestamp: date ? date.getTime() : null, timestamp: date ? date.getTime() : null,
is_outgoing: element.classList.contains("mdRGT07Own"), is_outgoing: is_outgoing,
sender: sender,
} }
const messageElement = element.querySelector(".mdRGT07Body > .mdRGT07Msg") const messageElement = element.querySelector(".mdRGT07Body > .mdRGT07Msg")
if (messageElement.classList.contains("mdRGT07Text")) { if (messageElement.classList.contains("mdRGT07Text")) {
@ -184,9 +271,14 @@ class MautrixController {
/** /**
* Parse the message list of whatever the currently-viewed chat is. * 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. * @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 msgList = document.querySelector("#_chat_room_msg_list")
const messages = [] const messages = []
let refDate = null let refDate = null
@ -202,7 +294,7 @@ class MautrixController {
const timeElement = child.querySelector("time") const timeElement = child.querySelector("time")
if (timeElement) { if (timeElement) {
const messageDate = await this._tryParseDate(timeElement.innerText, refDate) 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 * @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. * Parse a group participants list.
* TODO Find what works for a *room* participants list...! * TODO Find what works for a *room* participants list...!
@ -226,16 +327,26 @@ class MautrixController {
* @return {[Participant]} - The list of participants. * @return {[Participant]} - The list of participants.
*/ */
parseParticipantList(element) { parseParticipantList(element) {
// TODO Slice to exclude first member, which is always yourself (right?) // TODO Might need to explicitly exclude own user if double-puppeting is enabled.
// TODO Only slice if double-puppeting is enabled! // TODO The first member is always yourself, right?
//return Array.from(element.children).slice(1).map(child => { const ownParticipant = {
return Array.from(element.children).map(child => { // 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 { 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, // 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 noSandbox = false
static viewport = { width: 960, height: 880 } static viewport = { width: 960, height: 880 }
static url = undefined 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) 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. * Start the browser and open the messages for web page.
* This must be called before doing anything else. * This must be called before doing anything else.
@ -97,6 +105,7 @@ export default class MessagesPuppeteer {
id => this.sentMessageIDs.add(id)) id => this.sentMessageIDs.add(id))
await this.page.exposeFunction("__mautrixReceiveChanges", await this.page.exposeFunction("__mautrixReceiveChanges",
this._receiveChatListChanges.bind(this)) this._receiveChatListChanges.bind(this))
await this.page.exposeFunction("__mautrixShowParticipantsList", this._showParticipantList.bind(this))
await this.page.exposeFunction("__chronoParseDate", chrono.parseDate) 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 // 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 return value
}), }),
() => this.page.waitForSelector("#login_incorrect", {visible: true, timeout: 2000}) () => 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(), () => this._waitForLoginCancel(),
].map(promiseFn => cancelableResolve(promiseFn))) ].map(promiseFn => cancelableResolve(promiseFn)))
this.log("Removing observers") 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.removeQRChangeObserver())
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
@ -402,68 +412,99 @@ export default class MessagesPuppeteer {
} }
async _switchChat(id) { async _switchChat(id) {
// TODO Allow passing in an element directly
this.log(`Switching to chat ${id}`) this.log(`Switching to chat ${id}`)
const chatListItem = await this.page.$(this._listItemSelector(id)) const chatListItem = await this.page.$(this._listItemSelector(id))
await chatListItem.click()
const chatHeader = await this.page.waitForSelector("#_chat_header_area > .mdRGT04Link") const chatName = await chatListItem.evaluate(
const chatListInfo = await chatListItem.evaluate( element => window.__mautrixController.getChatListItemName(element))
(e, id) => window.__mautrixController.parseChatListItem(e, id),
id)
this.log(`Waiting for chat header title to be "${chatListInfo.name}"`) const isCorrectChatVisible = (targetText) => {
const chatHeaderTitleElement = await chatHeader.$(".mdRGT04Ttl") const chatHeader = document.querySelector("#_chat_header_area > .mdRGT04Link")
await this.page.waitForFunction( if (!chatHeader) return false
(element, targetText) => element.innerText == targetText, const chatHeaderTitleElement = chatHeader.querySelector(".mdRGT04Ttl")
{}, return chatHeaderTitleElement.innerText == targetText
chatHeaderTitleElement, chatListInfo.name) }
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) { 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] let [isDirect, isGroup, isRoom] = [false,false,false]
switch (id.charAt(0)) { switch (id.charAt(0)) {
case 'u': case "u":
isDirect = true isDirect = true
break break
case 'c': case "c":
isGroup = true isGroup = true
break break
case 'r': case "r":
isRoom = true isRoom = true
break break
} }
// TODO This will mark the chat as "read"!
const [chatListItem, chatListInfo, chatHeader] = await this._switchChat(id)
let participants let participants
if (isGroup || isRoom) { if (!isDirect) {
this.log("Found multi-user chat, so clicking chat header to get participants") this.log("Found multi-user chat, so clicking chat header to get participants")
await chatHeader.click() // TODO This will mark the chat as "read"!
const participantList = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul") await this._switchChat(id)
if (isGroup) { const participantList = await this.getParticipantList()
// TODO Is a group not actually created until a message is sent(?) // 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. // If so, maybe don't create a portal until there is a message.
participants = await participantList.evaluate( participants = await participantList.evaluate(
elem => window.__mautrixController.parseParticipantList(elem)) element => window.__mautrixController.parseParticipantList(element))
} else if (isRoom) { } else {
this.log("TODO: Room participant lists don't have user IDs...")
participants = []
}
}
else
{
this.log(`Found direct chat with ${id}`) this.log(`Found direct chat with ${id}`)
//const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") //const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name //await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{ participants = [{
id: id, id: id,
// TODO avatar, or leave null since this is a 1:1 chat
name: chatListInfo.name, name: chatListInfo.name,
}] }]
} }
this.log("Found participants:")
for (const participant of participants) {
this.log(participant)
}
return {participants, ...chatListInfo} return {participants, ...chatListInfo}
} }
@ -504,7 +545,7 @@ export default class MessagesPuppeteer {
await this._switchChat(id) await this._switchChat(id)
this.log("Waiting for messages to load") this.log("Waiting for messages to load")
const messages = await this.page.evaluate( 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)) return messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
} }
@ -582,7 +623,7 @@ export default class MessagesPuppeteer {
_sendLoginFailure(reason) { _sendLoginFailure(reason) {
this.loginRunning = false this.loginRunning = false
this.error(`Login failure: ${reason ? reason : 'cancelled'}`) this.error(`Login failure: ${reason ? reason : "cancelled"}`)
if (this.client) { if (this.client) {
this.client.sendFailure(reason).catch(err => this.client.sendFailure(reason).catch(err =>
this.error("Failed to send failure reason to client:", err)) this.error("Failed to send failure reason to client:", err))