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"