forked from fair/matrix-puppeteer-line
More changes
This commit is contained in:
parent
63dc7f0be3
commit
85814f9793
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,6 +11,7 @@ __pycache__
|
||||
/.eggs
|
||||
|
||||
profiles
|
||||
puppet/extension_files
|
||||
|
||||
/config.yaml
|
||||
/registration.yaml
|
||||
|
@ -13,13 +13,13 @@
|
||||
#
|
||||
# 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/>.
|
||||
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 <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)
|
||||
|
@ -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:
|
||||
|
@ -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"
|
||||
|
@ -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.")
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -13,20 +13,17 @@
|
||||
#
|
||||
# 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/>.
|
||||
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)
|
||||
|
@ -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)
|
||||
|
@ -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():
|
||||
|
@ -4,5 +4,6 @@
|
||||
"path": "/var/run/mautrix-line/puppet.sock"
|
||||
},
|
||||
"profile_dir": "./profiles",
|
||||
"url": "chrome-extension://<extension-uuid>/index.html"
|
||||
"url": "chrome-extension://<extension-uuid>/index.html",
|
||||
"extension_dir": "./extension_files"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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(),
|
||||
|
@ -32,6 +32,20 @@ window.__mautrixReceiveChanges = function (changes) {}
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
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
|
||||
* @return {Promise<void>}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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<boolean>} - 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)
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user