From 85814f97938b528cfdcbd59211ebb21f41bd7afb Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 10 Feb 2021 02:34:19 -0500 Subject: [PATCH] More changes --- .gitignore | 1 + mautrix_line/commands/auth.py | 110 ++++++++--- mautrix_line/commands/conn.py | 2 +- mautrix_line/example-config.yaml | 14 +- mautrix_line/matrix.py | 2 +- mautrix_line/portal.py | 6 +- mautrix_line/puppet.py | 5 +- mautrix_line/rpc/client.py | 47 +++-- mautrix_line/user.py | 3 +- mautrix_line/web/provisioning_api.py | 8 +- puppet/example-config.json | 3 +- puppet/package.json | 2 +- puppet/src/client.js | 21 ++- puppet/src/contentscript.js | 151 +++++++++++++-- puppet/src/main.js | 1 + puppet/src/puppet.js | 266 +++++++++++++++++++++------ puppet/yarn.lock | 36 ++-- 17 files changed, 535 insertions(+), 143 deletions(-) diff --git a/.gitignore b/.gitignore index 363f7dd..08e4297 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__ /.eggs profiles +puppet/extension_files /config.yaml /registration.yaml diff --git a/mautrix_line/commands/auth.py b/mautrix_line/commands/auth.py index 4ae4975..e80d686 100644 --- a/mautrix_line/commands/auth.py +++ b/mautrix_line/commands/auth.py @@ -13,13 +13,13 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional +from typing import Optional, AsyncGenerator, Tuple import io import qrcode import PIL as _ -from mautrix.types import MediaMessageEventContent, MessageType, ImageInfo, EventID +from mautrix.types import TextMessageEventContent, MediaMessageEventContent, MessageType, ImageInfo, EventID from mautrix.bridge.commands import HelpSection, command_handler from .typehint import CommandEvent @@ -27,29 +27,91 @@ from .typehint import CommandEvent SECTION_AUTH = HelpSection("Authentication", 10, "") -@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, - help_text="Log into Android Messages") -async def login(evt: CommandEvent) -> None: +async def login_prep(evt: CommandEvent, login_type: str) -> bool: status = await evt.sender.client.start() if status.is_logged_in: await evt.reply("You're already logged in") - return - qr_event_id: Optional[EventID] = None - async for url in evt.sender.client.login(): - buffer = io.BytesIO() - image = qrcode.make(url) - size = image.pixel_size - image.save(buffer, "PNG") - qr = buffer.getvalue() - mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr)) - content = MediaMessageEventContent(body=url, url=mxc, msgtype=MessageType.IMAGE, - info=ImageInfo(mimetype="image/png", size=len(qr), - width=size, height=size)) - if qr_event_id: - content.set_edit(qr_event_id) - await evt.az.intent.send_message(evt.room_id, content) + return False + + if evt.sender.command_status is not None: + action = evt.sender.command_status["action"] + if action == "Login": + await evt.reply( + "A login is already in progress. Please follow the login instructions, " + "or use the `$cmdprefix+sp cancel` command to start over.") else: - content.set_reply(evt.event_id) - qr_event_id = await evt.az.intent.send_message(evt.room_id, content) - await evt.reply("Successfully logged in, now syncing") - await evt.sender.sync() + await evt.reply(f"Cannot login while a {action} command is active.") + return False + + evt.sender.command_status = { + "action": "Login", + "login_type": login_type, + } + return True + +async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]) -> None: + qr_event_id: Optional[EventID] = None + pin_event_id: Optional[EventID] = None + failure = False + async for item in gen: + if item[0] == "qr": + url = item[1] + buffer = io.BytesIO() + image = qrcode.make(url) + size = image.pixel_size + image.save(buffer, "PNG") + qr = buffer.getvalue() + mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr)) + content = MediaMessageEventContent(body=url, url=mxc, msgtype=MessageType.IMAGE, + info=ImageInfo(mimetype="image/png", size=len(qr), + width=size, height=size)) + if qr_event_id: + content.set_edit(qr_event_id) + await evt.az.intent.send_message(evt.room_id, content) + else: + content.set_reply(evt.event_id) + qr_event_id = await evt.az.intent.send_message(evt.room_id, content) + elif item[0] == "pin": + pin = item[1] + content = TextMessageEventContent(body=pin, msgtype=MessageType.NOTICE) + if pin_event_id: + content.set_edit(pin_event_id) + await evt.az.intent.send_message(evt.room_id, content) + else: + content.set_reply(evt.event_id) + pin_event_id = await evt.az.intent.send_message(evt.room_id, content) + elif item[0] in ("failure", "error"): + # TODO Handle errors differently? + reason = item[1] + failure = True + content = TextMessageEventContent(body=reason, msgtype=MessageType.NOTICE) + await evt.az.intent.send_message(evt.room_id, content) + # else: pass + + if not failure and evt.sender.command_status: + await evt.reply("Successfully logged in, now syncing") + await evt.sender.sync() + # else command was cancelled or failed. Don't post message about it, "cancel" command or failure did already + evt.sender.command_status = None + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Log into LINE via QR code") +async def login_qr(evt: CommandEvent) -> None: + if not await login_prep(evt, "qr"): + return + gen = evt.sender.client.login(evt.sender) + await login_do(evt, gen) + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Log into LINE via email/password", + help_args="<_email_> <_password_>") +async def login_email(evt: CommandEvent) -> None: + if len(evt.args) != 2: + await evt.reply("Usage: `$cmdprefix+sp login `") + return + if not await login_prep(evt, "email"): + return + gen = evt.sender.client.login( + evt.sender, + login_data=dict(email=evt.args[0], password=evt.args[1])) + await login_do(evt, gen) diff --git a/mautrix_line/commands/conn.py b/mautrix_line/commands/conn.py index 8a9ac06..5796c52 100644 --- a/mautrix_line/commands/conn.py +++ b/mautrix_line/commands/conn.py @@ -29,7 +29,7 @@ async def set_notice_room(evt: CommandEvent) -> None: @command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION, - help_text="Check if you're logged into Android Messages") + help_text="Check if you're logged into LINE") async def ping(evt: CommandEvent) -> None: status = await evt.sender.client.start() if status.is_logged_in: diff --git a/mautrix_line/example-config.yaml b/mautrix_line/example-config.yaml index 61d708f..2eeaa93 100644 --- a/mautrix_line/example-config.yaml +++ b/mautrix_line/example-config.yaml @@ -39,18 +39,18 @@ appservice: shared_secret: generate # The unique ID of this appservice. - id: amp + id: line # Username of the appservice bot. - bot_username: ampbot + bot_username: linebot # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty # to leave display name/avatar as-is. - bot_displayname: Android Messages bridge bot + bot_displayname: LINE bridge bot bot_avatar: mxc://maunium.net/VuvevQiMRlOxuBVMBNEZZrxi # Community ID for bridged users (changes registration file) and rooms. # Must be created manually. # - # Example: "+amp:example.com". Set to false to disable. + # Example: "+line:example.com". Set to false to disable. community_id: false # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. @@ -66,11 +66,11 @@ metrics: bridge: # Localpart template of MXIDs for remote users. # {userid} is replaced with the user ID (phone or name converted into a mxid-friendly format). - username_template: "amp_{userid}" + username_template: "line_{userid}" # Displayname template for remote users. # {displayname} is replaced with the display name of the user. # {phone} is replaced with the phone number or name of the user. - displayname_template: "{displayname} (SMS)" + displayname_template: "{displayname} (LINE)" # Maximum length of displayname displayname_max_length: 100 @@ -129,7 +129,7 @@ bridge: resend_bridge_info: false # The prefix for commands. Only required in non-management rooms. - command_prefix: "!am" + command_prefix: "!line" # This bridge only supports a single user user: "@admin:example.com" diff --git a/mautrix_line/matrix.py b/mautrix_line/matrix.py index dff3fe1..eeb5bd3 100644 --- a/mautrix_line/matrix.py +++ b/mautrix_line/matrix.py @@ -47,4 +47,4 @@ class MatrixHandler(BaseMatrixHandler): inviter.notice_room = room_id await inviter.update() await self.az.intent.send_notice(room_id, "This room has been marked as your " - "Android Messages bridge notice room.") + "LINE bridge notice room.") diff --git a/mautrix_line/portal.py b/mautrix_line/portal.py index 39b77dc..b122501 100644 --- a/mautrix_line/portal.py +++ b/mautrix_line/portal.py @@ -268,7 +268,7 @@ class Portal(DBPortal, BasePortal): @property def bridge_info_state_key(self) -> str: - return f"net.maunium.amp://androidmessages/{self.chat_id}" + return f"net.maunium.line://line/{self.chat_id}" @property def bridge_info(self) -> Dict[str, Any]: @@ -276,8 +276,8 @@ class Portal(DBPortal, BasePortal): "bridgebot": self.az.bot_mxid, "creator": self.main_intent.mxid, "protocol": { - "id": "androidmessages", - "displayname": "Android Messages", + "id": "line", + "displayname": "LINE", "avatar_url": self.config["appservice.bot_avatar"], }, "channel": { diff --git a/mautrix_line/puppet.py b/mautrix_line/puppet.py index b5f219d..f84fcaf 100644 --- a/mautrix_line/puppet.py +++ b/mautrix_line/puppet.py @@ -56,8 +56,9 @@ class Puppet(DBPuppet, BasePuppet): cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid", prefix="@", suffix=f":{cls.hs_domain}", type=str) secret = cls.config["bridge.login_shared_secret"] - cls.login_shared_secret_map[cls.hs_domain] = secret.encode("utf-8") if secret else None - cls.login_device_name = "Android Messages Bridge" + if secret: + cls.login_shared_secret_map[cls.hs_domain] = secret.encode("utf-8") + cls.login_device_name = "LINE Bridge" async def update_info(self, info: Participant) -> None: update = False diff --git a/mautrix_line/rpc/client.py b/mautrix_line/rpc/client.py index 2413284..4142d21 100644 --- a/mautrix_line/rpc/client.py +++ b/mautrix_line/rpc/client.py @@ -13,20 +13,17 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import AsyncGenerator, TypedDict, List, Dict, Callable, Awaitable, Any +from typing import AsyncGenerator, TypedDict, List, Tuple, Dict, Callable, Awaitable, Any from collections import deque import asyncio from .rpc import RPCClient from .types import ChatListInfo, ChatInfo, Message, StartStatus +from mautrix_line.rpc.types import RPCError -class QRCommand(TypedDict): - url: str - - -class LoginComplete(Exception): - pass +class LoginCommand(TypedDict): + content: str class Client(RPCClient): @@ -66,22 +63,48 @@ class Client(RPCClient): self.add_event_handler("message", wrapper) - async def login(self) -> AsyncGenerator[str, None]: + # TODO Type hint for sender + async def login(self, sender, **login_data) -> AsyncGenerator[Tuple[str, str], None]: + login_data["login_type"] = sender.command_status["login_type"] + data = deque() event = asyncio.Event() - async def qr_handler(req: QRCommand) -> None: - data.append(req["url"]) + async def qr_handler(req: LoginCommand) -> None: + data.append(("qr", req["url"])) event.set() + async def pin_handler(req: LoginCommand) -> None: + data.append(("pin", req["pin"])) + event.set() + + async def failure_handler(req: LoginCommand) -> None: + data.append(("failure", req["reason"])) + event.set() + + async def cancel_watcher() -> None: + try: + while sender.command_status is not None: + await asyncio.sleep(1) + await self._raw_request("cancel_login") + except asyncio.CancelledError: + pass + cancel_watcher_task = asyncio.create_task(cancel_watcher()) + def login_handler(_fut: asyncio.Future) -> None: + cancel_watcher_task.cancel() + e = _fut.exception() + if e is not None: + data.append(("error", str(e))) data.append(None) event.set() - login_future = await self._raw_request("login") + login_future = await self._raw_request("login", **login_data) login_future.add_done_callback(login_handler) self.add_event_handler("qr", qr_handler) + self.add_event_handler("pin", pin_handler) + self.add_event_handler("failure", failure_handler) try: while True: await event.wait() @@ -93,3 +116,5 @@ class Client(RPCClient): event.clear() finally: self.remove_event_handler("qr", qr_handler) + self.remove_event_handler("pin", pin_handler) + self.remove_event_handler("failure", failure_handler) diff --git a/mautrix_line/user.py b/mautrix_line/user.py index 5f6c7bb..45ea391 100644 --- a/mautrix_line/user.py +++ b/mautrix_line/user.py @@ -30,7 +30,7 @@ from . import puppet as pu, portal as po if TYPE_CHECKING: from .__main__ import MessagesBridge -METRIC_CONNECTED = Gauge("bridge_connected", "Users connected to Android Messages") +METRIC_CONNECTED = Gauge("bridge_connected", "Users connected to LINE") class User(DBUser, BaseUser): @@ -49,6 +49,7 @@ class User(DBUser, BaseUser): def __init__(self, mxid: UserID, notice_room: Optional[RoomID] = None) -> None: super().__init__(mxid=mxid, notice_room=notice_room) self._notice_room_lock = asyncio.Lock() + self.command_status = None self.is_whitelisted = self.is_admin = self.config["bridge.user"] == mxid self.log = self.log.getChild(self.mxid) self._metric_value = defaultdict(lambda: False) diff --git a/mautrix_line/web/provisioning_api.py b/mautrix_line/web/provisioning_api.py index 0a89e5a..5eb990a 100644 --- a/mautrix_line/web/provisioning_api.py +++ b/mautrix_line/web/provisioning_api.py @@ -64,8 +64,8 @@ class ProvisioningAPI: return None for part in auth_parts: part = part.strip() - if part.startswith("net.maunium.amp.auth-"): - return part[len("net.maunium.amp.auth-"):] + if part.startswith("net.maunium.line.auth-"): + return part[len("net.maunium.line.auth-"):] return None def check_token(self, request: web.Request) -> Awaitable['u.User']: @@ -94,7 +94,7 @@ class ProvisioningAPI: user = await self.check_token(request) data = { "mxid": user.mxid, - "amp": { + "line": { "connected": True, } if await user.is_logged_in() else None, } @@ -107,7 +107,7 @@ class ProvisioningAPI: if status.is_logged_in: raise web.HTTPConflict(text='{"error": "Already logged in"}', headers=self._headers) - ws = web.WebSocketResponse(protocols=["net.maunium.amp.login"]) + ws = web.WebSocketResponse(protocols=["net.maunium.line.login"]) await ws.prepare(request) try: async for url in user.client.login(): diff --git a/puppet/example-config.json b/puppet/example-config.json index 807bfca..baf3c19 100644 --- a/puppet/example-config.json +++ b/puppet/example-config.json @@ -4,5 +4,6 @@ "path": "/var/run/mautrix-line/puppet.sock" }, "profile_dir": "./profiles", - "url": "chrome-extension:///index.html" + "url": "chrome-extension:///index.html", + "extension_dir": "./extension_files" } diff --git a/puppet/package.json b/puppet/package.json index ff53078..6f02a35 100644 --- a/puppet/package.json +++ b/puppet/package.json @@ -17,7 +17,7 @@ "dependencies": { "arg": "^4.1.3", "chrono-node": "^2.1.7", - "puppeteer": "5.1.0" + "puppeteer": "5.5.0" }, "devDependencies": { "babel-eslint": "^10.1.0", diff --git a/puppet/src/client.js b/puppet/src/client.js index be3ac56..42081ef 100644 --- a/puppet/src/client.js +++ b/puppet/src/client.js @@ -116,6 +116,24 @@ export default class Client { }) } + sendPIN(pin) { + this.log(`Sending PIN ${pin} to client`) + return this._write({ + id: --this.notificationID, + command: "pin", + pin, + }) + } + + sendFailure(reason) { + this.log(`Sending failure "${reason}" to client`) + return this._write({ + id: --this.notificationID, + command: "failure", + reason, + }) + } + handleStart = async (req) => { let started = false if (this.puppet === null) { @@ -205,7 +223,8 @@ export default class Client { start: this.handleStart, stop: this.handleStop, disconnect: () => this.stop(), - login: () => this.puppet.waitForLogin(), + login: req => this.puppet.waitForLogin(req.login_type, req.login_data), + cancel_login: () => this.puppet.cancelLogin(), send: req => this.puppet.sendMessage(req.chat_id, req.text), set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids), get_chats: () => this.puppet.getRecentChats(), diff --git a/puppet/src/contentscript.js b/puppet/src/contentscript.js index dfcc059..7d31d3e 100644 --- a/puppet/src/contentscript.js +++ b/puppet/src/contentscript.js @@ -32,6 +32,20 @@ window.__mautrixReceiveChanges = function (changes) {} * @return {Promise} */ window.__mautrixReceiveQR = function (url) {} +/** + * @return {Promise} + */ +window.__mautrixSendEmailCredentials = function () {} +/** + * @param {string} pin - The login PIN. + * @return {Promise} + */ +window.__mautrixReceivePIN = function (pin) {} +/** + * @param {Element} button - The button to click when a QR code or PIN expires. + * @return {Promise} + */ +window.__mautrixExpiry = function (button) {} /** * @param {number} id - The ID of the message that was sent * @return {Promise} @@ -41,7 +55,11 @@ window.__mautrixReceiveMessageID = function(id) {} class MautrixController { constructor() { this.chatListObserver = null - this.qrCodeObserver = null + this.qrChangeObserver = null + this.qrAppearObserver = null + this.emailAppearObserver = null + this.pinAppearObserver = null + this.expiryObserver = null } /** @@ -312,6 +330,7 @@ class MautrixController { if (this.chatListObserver !== null) { this.removeChatListObserver() } + /* TODO this.chatListObserver = new MutationObserver(mutations => { try { this._observeChatListMutations(mutations) @@ -320,6 +339,7 @@ class MautrixController { } }) this.chatListObserver.observe(element, { childList: true, subtree: true }) + */ console.debug("Started chat list observer") } @@ -334,27 +354,132 @@ class MautrixController { } } - addQRObserver(element) { - if (this.qrCodeObserver !== null) { - this.removeQRObserver() + addQRChangeObserver(element) { + if (this.qrChangeObserver !== null) { + this.removeQRChangeObserver() } - this.qrCodeObserver = new MutationObserver(changes => { + this.qrChangeObserver = new MutationObserver(changes => { for (const change of changes) { - if (change.attributeName === "data-qr-code" && change.target instanceof Element) { - window.__mautrixReceiveQR(change.target.getAttribute("data-qr-code")) + if (change.attributeName === "title" && change.target instanceof Element) { + window.__mautrixReceiveQR(change.target.getAttribute("title")) } } }) - this.qrCodeObserver.observe(element, { + this.qrChangeObserver.observe(element, { attributes: true, - attributeFilter: ["data-qr-code"], + attributeFilter: ["title"], }) } - removeQRObserver() { - if (this.qrCodeObserver !== null) { - this.qrCodeObserver.disconnect() - this.qrCodeObserver = null + removeQRChangeObserver() { + if (this.qrChangeObserver !== null) { + this.qrChangeObserver.disconnect() + this.qrChangeObserver = null + } + } + + addQRAppearObserver(element) { + if (this.qrAppearObserver !== null) { + this.removeQRAppearObserver() + } + this.qrAppearObserver = new MutationObserver(changes => { + for (const change of changes) { + for (const node of change.addedNodes) { + const qrElement = node.querySelector("#login_qrcode_area div[title]") + if (qrElement) { + window.__mautrixReceiveQR(qrElement.title) + window.__mautrixController.addQRChangeObserver(element) + return + } + } + } + }) + this.qrAppearObserver.observe(element, { + childList: true, + }) + } + + removeQRAppearObserver() { + if (this.qrAppearObserver !== null) { + this.qrAppearObserver.disconnect() + this.qrAppearObserver = null + } + } + + addEmailAppearObserver(element, login_type) { + if (this.emailAppearObserver !== null) { + this.removeEmailAppearObserver() + } + this.emailAppearObserver = new MutationObserver(changes => { + for (const change of changes) { + for (const node of change.addedNodes) { + const emailElement = node.querySelector("#login_email_btn") + if (emailElement) { + window.__mautrixSendEmailCredentials() + return + } + } + } + }) + this.emailAppearObserver.observe(element, { + childList: true, + }) + } + + removeEmailAppearObserver() { + if (this.emailAppearObserver !== null) { + this.emailAppearObserver.disconnect() + this.emailAppearObserver = null + } + } + + addPINAppearObserver(element, login_type) { + if (this.pinAppearObserver !== null) { + this.removePINAppearObserver() + } + this.pinAppearObserver = new MutationObserver(changes => { + for (const change of changes) { + for (const node of change.addedNodes) { + const pinElement = node.querySelector("div.mdCMN01Code") + if (pinElement) { + window.__mautrixReceivePIN(pinElement.innerText) + return + } + } + } + }) + this.pinAppearObserver.observe(element, { + childList: true, + }) + } + + removePINAppearObserver() { + if (this.pinAppearObserver !== null) { + this.pinAppearObserver.disconnect() + this.pinAppearObserver = null + } + } + + addExpiryObserver(element) { + if (this.expiryObserver !== null) { + this.removeExpiryObserver() + } + const button = element.querySelector("dialog button") + this.expiryObserver = new MutationObserver(changes => { + if (changes.length == 1 && !changes[0].target.getAttribute("class").includes("MdNonDisp")) { + window.__mautrixExpiry(button) + } + }) + this.expiryObserver.observe(element, { + attributes: true, + attributeFilter: ["class"], + }) + } + + removeExpiryObserver() { + if (this.expiryObserver !== null) { + this.expiryObserver.disconnect() + this.expiryObserver = null } } } diff --git a/puppet/src/main.js b/puppet/src/main.js index 82384a9..d1b2a21 100644 --- a/puppet/src/main.js +++ b/puppet/src/main.js @@ -38,6 +38,7 @@ const config = JSON.parse(fs.readFileSync(configPath).toString()) MessagesPuppeteer.profileDir = config.profile_dir || MessagesPuppeteer.profileDir MessagesPuppeteer.disableDebug = !!config.disable_debug MessagesPuppeteer.url = config.url +MessagesPuppeteer.extensionDir = config.extension_dir || MessagesPuppeteer.extensionDir const api = new PuppetAPI(config.listen) diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js index 26627f9..17db325 100644 --- a/puppet/src/puppet.js +++ b/puppet/src/puppet.js @@ -27,8 +27,9 @@ export default class MessagesPuppeteer { static executablePath = undefined static disableDebug = false static noSandbox = false - static viewport = { width: 1920, height: 1080 } + //static viewport = { width: 1920, height: 1080 } static url = undefined + static extensionDir = 'extension_files' /** * @@ -61,14 +62,13 @@ export default class MessagesPuppeteer { * Start the browser and open the messages for web page. * This must be called before doing anything else. */ - async start(debug = false) { + async start() { this.log("Launching browser") - const pathToExtension = require('path').join(__dirname, 'extension_files'); const extensionArgs = [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}` - ]; + `--disable-extensions-except=${MessagesPuppeteer.extensionDir}`, + `--load-extension=${MessagesPuppeteer.extensionDir}` + ] this.browser = await puppeteer.launch({ executablePath: MessagesPuppeteer.executablePath, @@ -85,67 +85,182 @@ export default class MessagesPuppeteer { this.page = await this.browser.newPage() } this.log("Opening", MessagesPuppeteer.url) - await this.page.goto(MessagesPuppeteer.url) + await this.page.setBypassCSP(true) // Needed to load content scripts + await this._preparePage(true) - this.log("Injecting content script") - await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" }) this.log("Exposing functions") await this.page.exposeFunction("__mautrixReceiveQR", this._receiveQRChange.bind(this)) + await this.page.exposeFunction("__mautrixSendEmailCredentials", this._sendEmailCredentials.bind(this)) + await this.page.exposeFunction("__mautrixReceivePIN", this._receivePIN.bind(this)) + await this.page.exposeFunction("__mautrixExpiry", this._receiveExpiry.bind(this)) + /* TODO await this.page.exposeFunction("__mautrixReceiveMessageID", id => this.sentMessageIDs.add(id)) await this.page.exposeFunction("__mautrixReceiveChanges", this._receiveChatListChanges.bind(this)) await this.page.exposeFunction("__chronoParseDate", chrono.parseDate) + */ - this.log("Waiting for load") - // Wait for the page to load (either QR code for login or chat list when already logged in) - await Promise.race([ - this.page.waitForSelector("mw-main-container mws-conversations-list .conv-container", - { visible: true, timeout: 60000 }), - this.page.waitForSelector("mw-authentication-container mw-qr-code", - { visible: true, timeout: 60000 }), - this.page.waitForSelector("mw-unable-to-connect-container", - { visible: true, timeout: 60000 }), - ]) + // NOTE Must *always* re-login on a browser session, so no need to check if already logged in + this.loginRunning = false + this.loginCancelled = false this.taskQueue.start() - if (await this.isLoggedIn()) { - await this.startObserving() - } this.log("Startup complete") } + async _preparePage(navigateTo) { + if (navigateTo) { + await this.page.goto(MessagesPuppeteer.url) + } else { + await this.page.reload() + } + this.log("Injecting content script") + await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" }) + } + /** - * Wait for the session to be logged in and monitor QR code changes while it's not. + * Wait for the session to be logged in and monitor changes while it's not. */ - async waitForLogin() { + async waitForLogin(login_type, login_data) { if (await this.isLoggedIn()) { return } - const qrSelector = "mw-authentication-container mw-qr-code" - if (!await this.page.$("mat-slide-toggle.mat-checked")) { - this.log("Clicking Remember Me button") - await this.page.click("mat-slide-toggle:not(.mat-checked) > label") - } else { - this.log("Remember Me button already clicked") + this.loginRunning = true + this.loginCancelled = false + + const loginContentArea = await this.page.waitForSelector("#login_content") + + switch (login_type) { + case "qr": { + this.log("Running QR login") + const qrButton = await this.page.waitForSelector("#login_qr_btn") + await qrButton.click() + + const qrElement = await this.page.waitForSelector("#login_qrcode_area div[title]", {visible: true}) + const currentQR = await this.page.evaluate(element => element.title, qrElement) + this._receiveQRChange(currentQR) + + await this.page.evaluate( + element => window.__mautrixController.addQRChangeObserver(element), qrElement) + await this.page.evaluate( + element => window.__mautrixController.addQRAppearObserver(element), loginContentArea) + + break } - this.log("Fetching current QR code") - const currentQR = await this.page.$eval(qrSelector, - element => element.getAttribute("data-qr-code")) - this._receiveQRChange(currentQR) - this.log("Adding QR observer") - await this.page.$eval(qrSelector, - element => window.__mautrixController.addQRObserver(element)) - this.log("Waiting for login") - await this.page.waitForSelector("mws-conversations-list .conv-container", { - visible: true, - timeout: 0, - }) - this.log("Removing QR observer") - await this.page.evaluate(() => window.__mautrixController.removeQRObserver()) + case "email": { + this.log("Running email login") + if (!login_data) { + _sendLoginFailure("No login credentials provided for email login") + return + } + + const emailButton = await this.page.waitForSelector("#login_email_btn") + await emailButton.click() + + const emailArea = await this.page.waitForSelector("#login_email_area", {visible: true}) + this.login_email = login_data["email"] + this.login_password = login_data["password"] + this._sendEmailCredentials() + + await this.page.evaluate( + element => window.__mautrixController.addEmailAppearObserver(element), loginContentArea) + + break + } + // TODO Phone number login + default: + _sendLoginFailure(`Invalid login type: ${login_type}`) + return + } + + await this.page.evaluate( + element => window.__mautrixController.addPINAppearObserver(element), loginContentArea) + await this.page.$eval("#layer_contents", + element => window.__mautrixController.addExpiryObserver(element)) + + this.log("Waiting for login response") + let doneWaiting = false + let loginSuccess = false + const cancelableResolve = (promiseWithShortTimeout) => { + const executor = (resolve, reject) => { + promiseWithShortTimeout.then( + value => { + this.log(`Done: ${value}`) + doneWaiting = true + resolve(value) + }, + reason => { + if (!doneWaiting) { + this.log(`Not done, waiting some more. ${reason}`) + setTimeout(executor, 3000, resolve, reject) + } else { + this.log(`Final fail. ${reason}`) + resolve() + } + } + ) + } + return new Promise(executor) + } + + const result = await Promise.race([ + this.page.waitForSelector("#wrap_message_sync", {timeout: 2000}) + .then(element => { + loginSuccess = true + return element + }), + this.page.waitForSelector("#login_incorrect", {visible: true, timeout: 2000}) + .then(element => element.innerText), + this._waitForLoginCancel(), + ].map(promise => cancelableResolve(promise))) + + this.log("Removing observers") + await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver()) + await this.page.evaluate(() => window.__mautrixController.removeLoginChildrenObserver(element)) + await this.page.evaluate(() => window.__mautrixController.removeExpiryObserver()) + delete this.login_email + delete this.login_password + + if (!loginSuccess) { + _sendLoginFailure(result) + return + } + + this.log("Waiting for sync") + await this.page.waitForFunction( + messageSyncElement => { + const text = messageSyncElement.innerText + return text == 'Syncing messages... 100%' + }, + {}, + result) + await this.startObserving() + this.loginRunning = false this.log("Login complete") } + /** + * Cancel an ongoing login attempt. + */ + async cancelLogin() { + if (this.loginRunning) { + this.loginCancelled = true + //await this._preparePage(false) + } + } + + _waitForLoginCancel() { + return new Promise((resolve, reject) => { + console.log(`>>>>> ${this.loginCancelled}`) + if (this.loginCancelled) { + resolve() + } else { + reject() + } + }) + } + /** * Close the browser. */ @@ -166,14 +281,17 @@ export default class MessagesPuppeteer { * @return {Promise} - Whether or not the session is logged in. */ async isLoggedIn() { - return await this.page.$("mw-main-container mws-conversations-list") !== null + return await this.page.$("#wrap_message_sync") !== null } async isPermanentlyDisconnected() { - return await this.page.$("mw-unable-to-connect-container") !== null + // TODO + //return await this.page.$("mw-unable-to-connect-container") !== null + return false } async isOpenSomewhereElse() { + /* TODO try { const text = await this.page.$eval("mws-dialog mat-dialog-content div", elem => elem.textContent) @@ -181,16 +299,15 @@ export default class MessagesPuppeteer { } catch (err) { return false } - } - - async clickDialogButton() { - await this.page.click("mws-dialog mat-dialog-actions button") + */ + return false } async isDisconnected() { if (!await this.isLoggedIn()) { return true } + /* TODO const offlineIndicators = await Promise.all([ this.page.$("mw-main-nav mw-banner mw-error-banner"), this.page.$("mw-main-nav mw-banner mw-information-banner[title='Connecting']"), @@ -198,6 +315,8 @@ export default class MessagesPuppeteer { this.isOpenSomewhereElse(), ]) return offlineIndicators.some(indicator => Boolean(indicator)) + */ + return false } /** @@ -206,8 +325,11 @@ export default class MessagesPuppeteer { * @return {Promise<[ChatListInfo]>} - List of chat IDs in order of most recent message. */ async getRecentChats() { + /* TODO return await this.page.$eval("mws-conversations-list .conv-container", elem => window.__mautrixController.parseChatList(elem)) + */ + return null } /** @@ -266,7 +388,7 @@ export default class MessagesPuppeteer { async startObserving() { this.log("Adding chat list observer") - await this.page.$eval("mws-conversations-list .conv-container", + await this.page.$eval("#wrap_chat_list", element => window.__mautrixController.addChatListObserver(element)) } @@ -276,7 +398,9 @@ export default class MessagesPuppeteer { } _listItemSelector(id) { - return `mws-conversation-list-item > a.list-item[href="/web/conversations/${id}"]` + // TODO + //return `mws-conversation-list-item > a.list-item[href="/web/conversations/${id}"]` + return '' } async _switchChatUnsafe(id) { @@ -365,6 +489,20 @@ export default class MessagesPuppeteer { } } + async _sendEmailCredentials() { + this.log("Inputting login credentials") + + // Triple-click email input field to select all existing text and replace it on type + const emailInput = await this.page.$("#line_login_email") + await emailInput.click({clickCount: 3}) + await emailInput.type(this.login_email) + + // Password input field always starts empty, so no need to select its text first + await this.page.type("#line_login_pwd", this.login_password) + + await this.page.click("button#login_btn") + } + _receiveQRChange(url) { if (this.client) { this.client.sendQRCode(url).catch(err => @@ -373,4 +511,28 @@ export default class MessagesPuppeteer { this.log("No client connected, not sending new QR") } } + + _receivePIN(pin) { + if (this.client) { + this.client.sendPIN(`Your PIN is: ${pin}`).catch(err => + this.error("Failed to send new PIN to client:", err)) + } else { + this.log("No client connected, not sending new PIN") + } + } + + _sendLoginFailure(reason) { + 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)) + } else { + this.log("No client connected, not sending failure reason") + } + } + + async _receiveExpiry(button) { + this.log("Something expired, clicking OK button to continue") + await this.page.click(button) + } } diff --git a/puppet/yarn.lock b/puppet/yarn.lock index eeffcd0..6c7af9c 100644 --- a/puppet/yarn.lock +++ b/puppet/yarn.lock @@ -364,10 +364,10 @@ define-properties@^1.1.2, define-properties@^1.1.3: dependencies: object-keys "^1.0.12" -devtools-protocol@0.0.767361: - version "0.0.767361" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.767361.tgz#5977f2558b84f9df36f62501bdddb82f3ae7b66b" - integrity sha512-ziRTdhEVQ9jEwedaUaXZ7kl9w9TF/7A3SXQ0XuqrJB+hMS62POHZUWTbumDN2ehRTfvWqTPc2Jw4gUl/jggmHA== +devtools-protocol@0.0.818844: + version "0.0.818844" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.818844.tgz#d1947278ec85b53e4c8ca598f607a28fa785ba9e" + integrity sha512-AD1hi7iVJ8OD0aMLQU5VK0XH9LDlA1+BcPIgrAxPfaibx2DbWucuyOhc4oyQCbnvDDO68nN6/LcKfqTP343Jjg== doctrine@1.5.0: version "1.5.0" @@ -918,11 +918,6 @@ lodash@^4.17.14, lodash@^4.17.19: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== -mime@^2.0.3: - version "2.4.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== - minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" @@ -935,11 +930,6 @@ minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mitt@^2.0.1: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mitt/-/mitt-2.1.0.tgz#f740577c23176c6205b121b2973514eade1b2230" - integrity sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg== - mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" @@ -967,6 +957,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + normalize-package-data@^2.3.2: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -1162,17 +1157,16 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -puppeteer@5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.1.0.tgz#e7bae2caa6e3a13a622755e4c27689d9812c38ca" - integrity sha512-IZBFG8XcA+oHxYo5rEpJI/HQignUis2XPijPoFpNxla2O+WufonGsUsSqrhRXgBKOME5zNfhRdUY2LvxAiKlhw== +puppeteer@5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.5.0.tgz#331a7edd212ca06b4a556156435f58cbae08af00" + integrity sha512-OM8ZvTXAhfgFA7wBIIGlPQzvyEETzDjeRa4mZRCRHxYL+GNH5WAuYUQdja3rpWZvkX/JKqmuVgbsxDNsDFjMEg== dependencies: debug "^4.1.0" - devtools-protocol "0.0.767361" + devtools-protocol "0.0.818844" extract-zip "^2.0.0" https-proxy-agent "^4.0.0" - mime "^2.0.3" - mitt "^2.0.1" + node-fetch "^2.6.1" pkg-dir "^4.2.0" progress "^2.0.1" proxy-from-env "^1.0.0"