More changes

This commit is contained in:
Andrew Ferrazzutti 2021-02-10 02:34:19 -05:00
parent 63dc7f0be3
commit 85814f9793
17 changed files with 535 additions and 143 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ __pycache__
/.eggs /.eggs
profiles profiles
puppet/extension_files
/config.yaml /config.yaml
/registration.yaml /registration.yaml

View File

@ -13,13 +13,13 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional from typing import Optional, AsyncGenerator, Tuple
import io import io
import qrcode import qrcode
import PIL as _ 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 mautrix.bridge.commands import HelpSection, command_handler
from .typehint import CommandEvent from .typehint import CommandEvent
@ -27,29 +27,91 @@ from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "") SECTION_AUTH = HelpSection("Authentication", 10, "")
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, async def login_prep(evt: CommandEvent, login_type: str) -> bool:
help_text="Log into Android Messages")
async def login(evt: CommandEvent) -> None:
status = await evt.sender.client.start() status = await evt.sender.client.start()
if status.is_logged_in: if status.is_logged_in:
await evt.reply("You're already logged in") await evt.reply("You're already logged in")
return return False
qr_event_id: Optional[EventID] = None
async for url in evt.sender.client.login(): if evt.sender.command_status is not None:
buffer = io.BytesIO() action = evt.sender.command_status["action"]
image = qrcode.make(url) if action == "Login":
size = image.pixel_size await evt.reply(
image.save(buffer, "PNG") "A login is already in progress. Please follow the login instructions, "
qr = buffer.getvalue() "or use the `$cmdprefix+sp cancel` command to start over.")
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: else:
content.set_reply(evt.event_id) await evt.reply(f"Cannot login while a {action} command is active.")
qr_event_id = await evt.az.intent.send_message(evt.room_id, content) return False
await evt.reply("Successfully logged in, now syncing")
await evt.sender.sync() 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 <email> <password>`")
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)

View File

@ -29,7 +29,7 @@ async def set_notice_room(evt: CommandEvent) -> None:
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION, @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: async def ping(evt: CommandEvent) -> None:
status = await evt.sender.client.start() status = await evt.sender.client.start()
if status.is_logged_in: if status.is_logged_in:

View File

@ -39,18 +39,18 @@ appservice:
shared_secret: generate shared_secret: generate
# The unique ID of this appservice. # The unique ID of this appservice.
id: amp id: line
# Username of the appservice bot. # 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 # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is. # to leave display name/avatar as-is.
bot_displayname: Android Messages bridge bot bot_displayname: LINE bridge bot
bot_avatar: mxc://maunium.net/VuvevQiMRlOxuBVMBNEZZrxi bot_avatar: mxc://maunium.net/VuvevQiMRlOxuBVMBNEZZrxi
# Community ID for bridged users (changes registration file) and rooms. # Community ID for bridged users (changes registration file) and rooms.
# Must be created manually. # 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 community_id: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
@ -66,11 +66,11 @@ metrics:
bridge: bridge:
# Localpart template of MXIDs for remote users. # Localpart template of MXIDs for remote users.
# {userid} is replaced with the user ID (phone or name converted into a mxid-friendly format). # {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 template for remote users.
# {displayname} is replaced with the display name of the user. # {displayname} is replaced with the display name of the user.
# {phone} is replaced with the phone number or 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 # Maximum length of displayname
displayname_max_length: 100 displayname_max_length: 100
@ -129,7 +129,7 @@ bridge:
resend_bridge_info: false resend_bridge_info: false
# The prefix for commands. Only required in non-management rooms. # The prefix for commands. Only required in non-management rooms.
command_prefix: "!am" command_prefix: "!line"
# This bridge only supports a single user # This bridge only supports a single user
user: "@admin:example.com" user: "@admin:example.com"

View File

@ -47,4 +47,4 @@ class MatrixHandler(BaseMatrixHandler):
inviter.notice_room = room_id inviter.notice_room = room_id
await inviter.update() await inviter.update()
await self.az.intent.send_notice(room_id, "This room has been marked as your " await self.az.intent.send_notice(room_id, "This room has been marked as your "
"Android Messages bridge notice room.") "LINE bridge notice room.")

View File

@ -268,7 +268,7 @@ class Portal(DBPortal, BasePortal):
@property @property
def bridge_info_state_key(self) -> str: 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 @property
def bridge_info(self) -> Dict[str, Any]: def bridge_info(self) -> Dict[str, Any]:
@ -276,8 +276,8 @@ class Portal(DBPortal, BasePortal):
"bridgebot": self.az.bot_mxid, "bridgebot": self.az.bot_mxid,
"creator": self.main_intent.mxid, "creator": self.main_intent.mxid,
"protocol": { "protocol": {
"id": "androidmessages", "id": "line",
"displayname": "Android Messages", "displayname": "LINE",
"avatar_url": self.config["appservice.bot_avatar"], "avatar_url": self.config["appservice.bot_avatar"],
}, },
"channel": { "channel": {

View File

@ -56,8 +56,9 @@ class Puppet(DBPuppet, BasePuppet):
cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid", cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid",
prefix="@", suffix=f":{cls.hs_domain}", type=str) prefix="@", suffix=f":{cls.hs_domain}", type=str)
secret = cls.config["bridge.login_shared_secret"] secret = cls.config["bridge.login_shared_secret"]
cls.login_shared_secret_map[cls.hs_domain] = secret.encode("utf-8") if secret else None if secret:
cls.login_device_name = "Android Messages Bridge" 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: async def update_info(self, info: Participant) -> None:
update = False update = False

View File

@ -13,20 +13,17 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
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 from collections import deque
import asyncio import asyncio
from .rpc import RPCClient from .rpc import RPCClient
from .types import ChatListInfo, ChatInfo, Message, StartStatus from .types import ChatListInfo, ChatInfo, Message, StartStatus
from mautrix_line.rpc.types import RPCError
class QRCommand(TypedDict): class LoginCommand(TypedDict):
url: str content: str
class LoginComplete(Exception):
pass
class Client(RPCClient): class Client(RPCClient):
@ -66,22 +63,48 @@ class Client(RPCClient):
self.add_event_handler("message", wrapper) 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() data = deque()
event = asyncio.Event() event = asyncio.Event()
async def qr_handler(req: QRCommand) -> None: async def qr_handler(req: LoginCommand) -> None:
data.append(req["url"]) data.append(("qr", req["url"]))
event.set() 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: 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) data.append(None)
event.set() 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) login_future.add_done_callback(login_handler)
self.add_event_handler("qr", qr_handler) self.add_event_handler("qr", qr_handler)
self.add_event_handler("pin", pin_handler)
self.add_event_handler("failure", failure_handler)
try: try:
while True: while True:
await event.wait() await event.wait()
@ -93,3 +116,5 @@ class Client(RPCClient):
event.clear() event.clear()
finally: finally:
self.remove_event_handler("qr", qr_handler) self.remove_event_handler("qr", qr_handler)
self.remove_event_handler("pin", pin_handler)
self.remove_event_handler("failure", failure_handler)

View File

@ -30,7 +30,7 @@ from . import puppet as pu, portal as po
if TYPE_CHECKING: if TYPE_CHECKING:
from .__main__ import MessagesBridge 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): class User(DBUser, BaseUser):
@ -49,6 +49,7 @@ class User(DBUser, BaseUser):
def __init__(self, mxid: UserID, notice_room: Optional[RoomID] = None) -> None: def __init__(self, mxid: UserID, notice_room: Optional[RoomID] = None) -> None:
super().__init__(mxid=mxid, notice_room=notice_room) super().__init__(mxid=mxid, notice_room=notice_room)
self._notice_room_lock = asyncio.Lock() self._notice_room_lock = asyncio.Lock()
self.command_status = None
self.is_whitelisted = self.is_admin = self.config["bridge.user"] == mxid self.is_whitelisted = self.is_admin = self.config["bridge.user"] == mxid
self.log = self.log.getChild(self.mxid) self.log = self.log.getChild(self.mxid)
self._metric_value = defaultdict(lambda: False) self._metric_value = defaultdict(lambda: False)

View File

@ -64,8 +64,8 @@ class ProvisioningAPI:
return None return None
for part in auth_parts: for part in auth_parts:
part = part.strip() part = part.strip()
if part.startswith("net.maunium.amp.auth-"): if part.startswith("net.maunium.line.auth-"):
return part[len("net.maunium.amp.auth-"):] return part[len("net.maunium.line.auth-"):]
return None return None
def check_token(self, request: web.Request) -> Awaitable['u.User']: def check_token(self, request: web.Request) -> Awaitable['u.User']:
@ -94,7 +94,7 @@ class ProvisioningAPI:
user = await self.check_token(request) user = await self.check_token(request)
data = { data = {
"mxid": user.mxid, "mxid": user.mxid,
"amp": { "line": {
"connected": True, "connected": True,
} if await user.is_logged_in() else None, } if await user.is_logged_in() else None,
} }
@ -107,7 +107,7 @@ class ProvisioningAPI:
if status.is_logged_in: if status.is_logged_in:
raise web.HTTPConflict(text='{"error": "Already logged in"}', headers=self._headers) 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) await ws.prepare(request)
try: try:
async for url in user.client.login(): async for url in user.client.login():

View File

@ -4,5 +4,6 @@
"path": "/var/run/mautrix-line/puppet.sock" "path": "/var/run/mautrix-line/puppet.sock"
}, },
"profile_dir": "./profiles", "profile_dir": "./profiles",
"url": "chrome-extension://<extension-uuid>/index.html" "url": "chrome-extension://<extension-uuid>/index.html",
"extension_dir": "./extension_files"
} }

View File

@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"arg": "^4.1.3", "arg": "^4.1.3",
"chrono-node": "^2.1.7", "chrono-node": "^2.1.7",
"puppeteer": "5.1.0" "puppeteer": "5.5.0"
}, },
"devDependencies": { "devDependencies": {
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",

View File

@ -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) => { handleStart = async (req) => {
let started = false let started = false
if (this.puppet === null) { if (this.puppet === null) {
@ -205,7 +223,8 @@ export default class Client {
start: this.handleStart, start: this.handleStart,
stop: this.handleStop, stop: this.handleStop,
disconnect: () => this.stop(), 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), send: req => this.puppet.sendMessage(req.chat_id, req.text),
set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids), set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids),
get_chats: () => this.puppet.getRecentChats(), get_chats: () => this.puppet.getRecentChats(),

View File

@ -32,6 +32,20 @@ window.__mautrixReceiveChanges = function (changes) {}
* @return {Promise<void>} * @return {Promise<void>}
*/ */
window.__mautrixReceiveQR = function (url) {} window.__mautrixReceiveQR = function (url) {}
/**
* @return {Promise<void>}
*/
window.__mautrixSendEmailCredentials = function () {}
/**
* @param {string} pin - The login PIN.
* @return {Promise<void>}
*/
window.__mautrixReceivePIN = function (pin) {}
/**
* @param {Element} button - The button to click when a QR code or PIN expires.
* @return {Promise<void>}
*/
window.__mautrixExpiry = function (button) {}
/** /**
* @param {number} id - The ID of the message that was sent * @param {number} id - The ID of the message that was sent
* @return {Promise<void>} * @return {Promise<void>}
@ -41,7 +55,11 @@ window.__mautrixReceiveMessageID = function(id) {}
class MautrixController { class MautrixController {
constructor() { constructor() {
this.chatListObserver = null 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) { if (this.chatListObserver !== null) {
this.removeChatListObserver() this.removeChatListObserver()
} }
/* TODO
this.chatListObserver = new MutationObserver(mutations => { this.chatListObserver = new MutationObserver(mutations => {
try { try {
this._observeChatListMutations(mutations) this._observeChatListMutations(mutations)
@ -320,6 +339,7 @@ class MautrixController {
} }
}) })
this.chatListObserver.observe(element, { childList: true, subtree: true }) this.chatListObserver.observe(element, { childList: true, subtree: true })
*/
console.debug("Started chat list observer") console.debug("Started chat list observer")
} }
@ -334,27 +354,132 @@ class MautrixController {
} }
} }
addQRObserver(element) { addQRChangeObserver(element) {
if (this.qrCodeObserver !== null) { if (this.qrChangeObserver !== null) {
this.removeQRObserver() this.removeQRChangeObserver()
} }
this.qrCodeObserver = new MutationObserver(changes => { this.qrChangeObserver = new MutationObserver(changes => {
for (const change of changes) { for (const change of changes) {
if (change.attributeName === "data-qr-code" && change.target instanceof Element) { if (change.attributeName === "title" && change.target instanceof Element) {
window.__mautrixReceiveQR(change.target.getAttribute("data-qr-code")) window.__mautrixReceiveQR(change.target.getAttribute("title"))
} }
} }
}) })
this.qrCodeObserver.observe(element, { this.qrChangeObserver.observe(element, {
attributes: true, attributes: true,
attributeFilter: ["data-qr-code"], attributeFilter: ["title"],
}) })
} }
removeQRObserver() { removeQRChangeObserver() {
if (this.qrCodeObserver !== null) { if (this.qrChangeObserver !== null) {
this.qrCodeObserver.disconnect() this.qrChangeObserver.disconnect()
this.qrCodeObserver = null 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
} }
} }
} }

View File

@ -38,6 +38,7 @@ const config = JSON.parse(fs.readFileSync(configPath).toString())
MessagesPuppeteer.profileDir = config.profile_dir || MessagesPuppeteer.profileDir MessagesPuppeteer.profileDir = config.profile_dir || MessagesPuppeteer.profileDir
MessagesPuppeteer.disableDebug = !!config.disable_debug MessagesPuppeteer.disableDebug = !!config.disable_debug
MessagesPuppeteer.url = config.url MessagesPuppeteer.url = config.url
MessagesPuppeteer.extensionDir = config.extension_dir || MessagesPuppeteer.extensionDir
const api = new PuppetAPI(config.listen) const api = new PuppetAPI(config.listen)

View File

@ -27,8 +27,9 @@ export default class MessagesPuppeteer {
static executablePath = undefined static executablePath = undefined
static disableDebug = false static disableDebug = false
static noSandbox = false static noSandbox = false
static viewport = { width: 1920, height: 1080 } //static viewport = { width: 1920, height: 1080 }
static url = undefined 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. * 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.
*/ */
async start(debug = false) { async start() {
this.log("Launching browser") this.log("Launching browser")
const pathToExtension = require('path').join(__dirname, 'extension_files');
const extensionArgs = [ const extensionArgs = [
`--disable-extensions-except=${pathToExtension}`, `--disable-extensions-except=${MessagesPuppeteer.extensionDir}`,
`--load-extension=${pathToExtension}` `--load-extension=${MessagesPuppeteer.extensionDir}`
]; ]
this.browser = await puppeteer.launch({ this.browser = await puppeteer.launch({
executablePath: MessagesPuppeteer.executablePath, executablePath: MessagesPuppeteer.executablePath,
@ -85,67 +85,182 @@ export default class MessagesPuppeteer {
this.page = await this.browser.newPage() this.page = await this.browser.newPage()
} }
this.log("Opening", MessagesPuppeteer.url) 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") this.log("Exposing functions")
await this.page.exposeFunction("__mautrixReceiveQR", this._receiveQRChange.bind(this)) 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", await this.page.exposeFunction("__mautrixReceiveMessageID",
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("__chronoParseDate", chrono.parseDate) await this.page.exposeFunction("__chronoParseDate", chrono.parseDate)
*/
this.log("Waiting for load") // NOTE Must *always* re-login on a browser session, so no need to check if already logged in
// Wait for the page to load (either QR code for login or chat list when already logged in) this.loginRunning = false
await Promise.race([ this.loginCancelled = false
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 }),
])
this.taskQueue.start() this.taskQueue.start()
if (await this.isLoggedIn()) {
await this.startObserving()
}
this.log("Startup complete") 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()) { if (await this.isLoggedIn()) {
return return
} }
const qrSelector = "mw-authentication-container mw-qr-code" this.loginRunning = true
if (!await this.page.$("mat-slide-toggle.mat-checked")) { this.loginCancelled = false
this.log("Clicking Remember Me button")
await this.page.click("mat-slide-toggle:not(.mat-checked) > label") const loginContentArea = await this.page.waitForSelector("#login_content")
} else {
this.log("Remember Me button already clicked") 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") case "email": {
const currentQR = await this.page.$eval(qrSelector, this.log("Running email login")
element => element.getAttribute("data-qr-code")) if (!login_data) {
this._receiveQRChange(currentQR) _sendLoginFailure("No login credentials provided for email login")
this.log("Adding QR observer") return
await this.page.$eval(qrSelector, }
element => window.__mautrixController.addQRObserver(element))
this.log("Waiting for login") const emailButton = await this.page.waitForSelector("#login_email_btn")
await this.page.waitForSelector("mws-conversations-list .conv-container", { await emailButton.click()
visible: true,
timeout: 0, const emailArea = await this.page.waitForSelector("#login_email_area", {visible: true})
}) this.login_email = login_data["email"]
this.log("Removing QR observer") this.login_password = login_data["password"]
await this.page.evaluate(() => window.__mautrixController.removeQRObserver()) 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() await this.startObserving()
this.loginRunning = false
this.log("Login complete") 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. * Close the browser.
*/ */
@ -166,14 +281,17 @@ export default class MessagesPuppeteer {
* @return {Promise<boolean>} - Whether or not the session is logged in. * @return {Promise<boolean>} - Whether or not the session is logged in.
*/ */
async isLoggedIn() { async isLoggedIn() {
return await this.page.$("mw-main-container mws-conversations-list") !== null return await this.page.$("#wrap_message_sync") !== null
} }
async isPermanentlyDisconnected() { 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() { async isOpenSomewhereElse() {
/* TODO
try { try {
const text = await this.page.$eval("mws-dialog mat-dialog-content div", const text = await this.page.$eval("mws-dialog mat-dialog-content div",
elem => elem.textContent) elem => elem.textContent)
@ -181,16 +299,15 @@ export default class MessagesPuppeteer {
} catch (err) { } catch (err) {
return false return false
} }
} */
return false
async clickDialogButton() {
await this.page.click("mws-dialog mat-dialog-actions button")
} }
async isDisconnected() { async isDisconnected() {
if (!await this.isLoggedIn()) { if (!await this.isLoggedIn()) {
return true return true
} }
/* TODO
const offlineIndicators = await Promise.all([ const offlineIndicators = await Promise.all([
this.page.$("mw-main-nav mw-banner mw-error-banner"), this.page.$("mw-main-nav mw-banner mw-error-banner"),
this.page.$("mw-main-nav mw-banner mw-information-banner[title='Connecting']"), this.page.$("mw-main-nav mw-banner mw-information-banner[title='Connecting']"),
@ -198,6 +315,8 @@ export default class MessagesPuppeteer {
this.isOpenSomewhereElse(), this.isOpenSomewhereElse(),
]) ])
return offlineIndicators.some(indicator => Boolean(indicator)) 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. * @return {Promise<[ChatListInfo]>} - List of chat IDs in order of most recent message.
*/ */
async getRecentChats() { async getRecentChats() {
/* TODO
return await this.page.$eval("mws-conversations-list .conv-container", return await this.page.$eval("mws-conversations-list .conv-container",
elem => window.__mautrixController.parseChatList(elem)) elem => window.__mautrixController.parseChatList(elem))
*/
return null
} }
/** /**
@ -266,7 +388,7 @@ export default class MessagesPuppeteer {
async startObserving() { async startObserving() {
this.log("Adding chat list observer") 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)) element => window.__mautrixController.addChatListObserver(element))
} }
@ -276,7 +398,9 @@ export default class MessagesPuppeteer {
} }
_listItemSelector(id) { _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) { 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) { _receiveQRChange(url) {
if (this.client) { if (this.client) {
this.client.sendQRCode(url).catch(err => this.client.sendQRCode(url).catch(err =>
@ -373,4 +511,28 @@ export default class MessagesPuppeteer {
this.log("No client connected, not sending new QR") 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)
}
} }

View File

@ -364,10 +364,10 @@ define-properties@^1.1.2, define-properties@^1.1.3:
dependencies: dependencies:
object-keys "^1.0.12" object-keys "^1.0.12"
devtools-protocol@0.0.767361: devtools-protocol@0.0.818844:
version "0.0.767361" version "0.0.818844"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.767361.tgz#5977f2558b84f9df36f62501bdddb82f3ae7b66b" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.818844.tgz#d1947278ec85b53e4c8ca598f607a28fa785ba9e"
integrity sha512-ziRTdhEVQ9jEwedaUaXZ7kl9w9TF/7A3SXQ0XuqrJB+hMS62POHZUWTbumDN2ehRTfvWqTPc2Jw4gUl/jggmHA== integrity sha512-AD1hi7iVJ8OD0aMLQU5VK0XH9LDlA1+BcPIgrAxPfaibx2DbWucuyOhc4oyQCbnvDDO68nN6/LcKfqTP343Jjg==
doctrine@1.5.0: doctrine@1.5.0:
version "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" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== 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: minimatch@^3.0.4:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 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" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 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: mkdirp-classic@^0.5.2:
version "0.5.3" version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" 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" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= 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: normalize-package-data@^2.3.2:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" 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" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
puppeteer@5.1.0: puppeteer@5.5.0:
version "5.1.0" version "5.5.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.1.0.tgz#e7bae2caa6e3a13a622755e4c27689d9812c38ca" resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-5.5.0.tgz#331a7edd212ca06b4a556156435f58cbae08af00"
integrity sha512-IZBFG8XcA+oHxYo5rEpJI/HQignUis2XPijPoFpNxla2O+WufonGsUsSqrhRXgBKOME5zNfhRdUY2LvxAiKlhw== integrity sha512-OM8ZvTXAhfgFA7wBIIGlPQzvyEETzDjeRa4mZRCRHxYL+GNH5WAuYUQdja3rpWZvkX/JKqmuVgbsxDNsDFjMEg==
dependencies: dependencies:
debug "^4.1.0" debug "^4.1.0"
devtools-protocol "0.0.767361" devtools-protocol "0.0.818844"
extract-zip "^2.0.0" extract-zip "^2.0.0"
https-proxy-agent "^4.0.0" https-proxy-agent "^4.0.0"
mime "^2.0.3" node-fetch "^2.6.1"
mitt "^2.0.1"
pkg-dir "^4.2.0" pkg-dir "^4.2.0"
progress "^2.0.1" progress "^2.0.1"
proxy-from-env "^1.0.0" proxy-from-env "^1.0.0"