Compare commits

...

19 Commits

Author SHA1 Message Date
Cristian Le 968832545e Merge remote-tracking branch 'origin/systemd' into systemd 2021-06-23 10:15:23 +09:00
Cristian Le 91885245f1
Add `RuntimeDirectory` for sockets 2021-06-23 10:14:54 +09:00
Cristian Le d93457fcff
Fix ordering and dependence 2021-06-23 10:14:53 +09:00
Cristian Le eb573a91fd
Use notify for systemd 2021-06-23 10:14:53 +09:00
Cristian Le 9ab94be628
Initial Systemd services 2021-06-23 10:14:53 +09:00
Andrew Ferrazzutti c124b4c49e Replace `str.removeprefix`
to avoid having to bump Python version to 3.9
2021-06-18 00:38:45 -04:00
Andrew Ferrazzutti 2fec597cba Tweak window size
Helps when running in Xvnc
2021-06-18 00:09:20 -04:00
Andrew Ferrazzutti 157f18d27f Update .gitignore 2021-06-18 00:08:41 -04:00
Andrew Ferrazzutti 2c026939c3 Minor formatting fixes 2021-06-17 23:59:25 -04:00
Andrew Ferrazzutti f05a91e95d Better checks for chat messages to appear
- Re-click on a chat item if one click didn't work for some reason
- Wait long for a single message to appear, but stop early when found
- After single message found, wait less for more changes
2021-06-17 23:55:19 -04:00
Andrew Ferrazzutti 40a48d12a2 Don't view groups/rooms when possible, via MSC2409
Use the sidebar to sync non-DM messages, just like what's done for DM
messages.

However, since sidebar messages don't say who sent a message, this
requires scanning read receipts with MSC2409: when a non-DM sidebar
message is seen, the bridge bot sends that message. Only when that
placeholder is viewed in Matrix does Puppeteer actually view the non-DM
chat to find who really sent the message. Then the bridgebot redacts its
message, and the message gets re-sent by the puppet of the LINE user who
really sent it.
2021-06-17 23:32:45 -04:00
Andrew Ferrazzutti 1ae30bcf1b Use read receipts to know when to sync media
Instead of having to view a LINE chat when a media message is sent, send
a placeholder message that gets replaced with the actual media when it's
viewed in Matrix.
2021-06-17 00:42:06 -04:00
Andrew Ferrazzutti a3e7caac27 Merge branch 'master' into better-receipts-msc2409 2021-06-16 22:14:26 -04:00
Andrew Ferrazzutti ee5ccf9b2f Attempt to wait for chat messages to appear
When opening a chat (like during a sync or in response to a new message
notification), the message list loads lazily, so not waiting for all
items to load can cause messages to be missed.

However, there doesn't seem to be any indicator for when a message list
has been fully loaded...

As a best effort attempt, simply wait until no new updates to the
message list have been seen for a while.
2021-06-16 02:27:27 -04:00
Andrew Ferrazzutti 555b19c289 Always use LINE puppet for own messages
...that are sent from another client.

Also look up the profile data for the user's LINE account on sync,
including at startup, so that there's always a puppet available.
2021-06-15 02:55:55 -04:00
Andrew Ferrazzutti 0d154c826e Fix outbound message sending
The message text input field in LINE chats doesn't play nice with
Puppeteer's "type" function for live-typing text...

It's a div instead of a text input, and uses innerText instead of value.
Setting its innerText directly seems to work best, so just use that.
2021-06-14 01:54:24 -04:00
Andrew Ferrazzutti 8fb0e2a101 Add missing await
And a safer null check
2021-06-14 01:53:26 -04:00
Andrew Ferrazzutti 33ca6223c5 Use MSC2409 to send outgoing read receipts
When a message is viewed in Matrix, make Puppeteer view its LINE chat
2021-06-11 02:53:30 -04:00
Andrew Ferrazzutti 85dc7a842e Don't view LINE DMs whenever possible
Use the sidebar to sync DM messages instead of visiting the DM itself,
so as to not make LINE (and your contact) think you really read the DM.

This cannot be done for non-text messages (which are not previwable in
the sidebar) and non-DM chats (whose sidebar messages don't say who sent
a message).
2021-06-10 02:54:12 -04:00
14 changed files with 486 additions and 157 deletions

6
.gitignore vendored
View File

@ -1,4 +1,6 @@
/.idea/
/.*project
/.settings/
/.venv
/env/
@ -13,8 +15,8 @@ __pycache__
profiles
puppet/extension_files
/config.yaml
/registration.yaml
/config*.yaml
/registration*.yaml
*.log*
*.db
*.pickle

View File

@ -29,20 +29,18 @@ class Message:
mxid: EventID
mx_room: RoomID
mid: int
mid: Optional[int]
chat_id: str
async def insert(self) -> None:
q = "INSERT INTO message (mxid, mx_room, mid, chat_id) VALUES ($1, $2, $3, $4)"
await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id)
async def delete(self) -> None:
q = "DELETE FROM message WHERE mid=$1"
await self.db.execute(q, self.mid)
@classmethod
async def delete_all(cls, room_id: RoomID) -> None:
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
async def update_ids(self, new_mxid: EventID, new_mid: int) -> None:
q = ("UPDATE message SET mxid=$1, mid=$2 "
"WHERE mxid=$3 AND mx_room=$4 AND chat_id=$5")
await self.db.execute(q, new_mxid, new_mid,
self.mxid, self.mx_room, self.chat_id)
@classmethod
async def get_max_mid(cls, room_id: RoomID) -> int:
@ -57,6 +55,23 @@ class Message:
data[row["chat_id"]] = row["max_mid"]
return data
@classmethod
async def get_num_noid_msgs(cls, room_id: RoomID) -> int:
return await cls.db.fetchval("SELECT COUNT(*) FROM message "
"WHERE mid IS NULL AND mx_room=$1", room_id)
@classmethod
async def is_last_by_mxid(cls, mxid: EventID, room_id: RoomID) -> bool:
q = ("SELECT mxid "
"FROM message INNER JOIN ( "
" SELECT mx_room, MAX(mid) AS max_mid "
" FROM message GROUP BY mx_room "
") by_room "
"ON mid=max_mid "
"WHERE by_room.mx_room=$1")
last_mxid = await cls.db.fetchval(q, room_id)
return last_mxid == mxid
@classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']:
row = await cls.db.fetchrow("SELECT mxid, mx_room, mid, chat_id "
@ -72,3 +87,18 @@ class Message:
if not row:
return None
return cls(**row)
@classmethod
async def get_next_noid_msg(cls, room_id: RoomID) -> Optional['Message']:
row = await cls.db.fetchrow("SELECT mxid, mx_room, mid, chat_id FROM message "
"WHERE mid IS NULL AND mx_room=$1", room_id)
if not row:
return None
return cls(**row)
@classmethod
async def delete_all_noid_msgs(cls, room_id: RoomID) -> None:
status = await cls.db.execute("DELETE FROM message "
"WHERE mid IS NULL AND mx_room=$1", room_id)
# Skip leading "DELETE "
return int(status[7:])

View File

@ -128,4 +128,11 @@ async def upgrade_strangers(conn: Connection) -> None:
FOREIGN KEY (fake_mid)
REFERENCES puppet (mid)
ON DELETE CASCADE
)""")
)""")
@upgrade_table.register(description="Track messages that lack an ID")
async def upgrade_noid_msgs(conn: Connection) -> None:
await conn.execute("ALTER TABLE message DROP CONSTRAINT IF EXISTS message_pkey")
await conn.execute("ALTER TABLE message ALTER COLUMN mid DROP NOT NULL")
await conn.execute("ALTER TABLE message ADD UNIQUE (mid)")

View File

@ -57,6 +57,12 @@ appservice:
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
# This is REQUIRED in order to bypass Puppeteer needing to "view" a LINE chat
# (thus triggering a LINE read receipt on your behalf) to sync its messages.
ephemeral_events: false
# Prometheus telemetry config. Requires prometheus-client to be installed.
metrics:
enabled: false

View File

@ -17,9 +17,11 @@ from typing import TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent,
ReceiptEvent, SingleReceiptEventContent,
EventID, RoomID, UserID)
from . import portal as po, puppet as pu, user as u
from .db import Message as DBMessage
if TYPE_CHECKING:
from .__main__ import MessagesBridge
@ -35,8 +37,9 @@ class MatrixHandler(BaseMatrixHandler):
super().__init__(bridge=bridge)
def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent,
RedactionEvent)):
if isinstance(evt, ReceiptEvent):
return False
if not isinstance(evt, (MessageEvent, StateEvent, EncryptedEvent)):
return True
return (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
@ -59,3 +62,16 @@ class MatrixHandler(BaseMatrixHandler):
return
await portal.handle_matrix_leave(user)
async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
data: SingleReceiptEventContent) -> None:
# When reading a bridged message, view its chat in LINE, to make it send a read receipt.
# TODO Use *null* mids for last messages in a chat!!
# Only visit a LINE chat when its LAST bridge message has been read,
# because LINE lacks per-message read receipts--it's all or nothing!
# TODO Also view if message is non-last but for media, so it can be loaded.
#if await DBMessage.is_last_by_mxid(event_id, portal.mxid):
# Viewing a chat by updating it whole-hog, lest a ninja arrives
await user.sync_portal(portal)

View File

@ -124,6 +124,11 @@ class Portal(DBPortal, BasePortal):
except Exception:
self.log.exception("Failed to send delivery receipt for %s", event_id)
async def _cleanup_noid_msgs(self) -> None:
num_noid_msgs = await DBMessage.delete_all_noid_msgs(self.mxid)
if num_noid_msgs > 0:
self.log.warn(f"Found {num_noid_msgs} messages in chat {self.chat_id} with no ID that could not be matched with a real ID")
async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
event_id: EventID) -> None:
if not sender.client:
@ -163,6 +168,8 @@ class Portal(DBPortal, BasePortal):
self.log.warning(f"Failed to upload media {event_id} to chat {self.chat_id}: {e}")
message_id = -1
remove(file_path)
await self._cleanup_noid_msgs()
msg = None
if message_id != -1:
try:
@ -175,20 +182,18 @@ class Portal(DBPortal, BasePortal):
if not msg and self.config["bridge.delivery_error_reports"]:
await self.main_intent.send_notice(
self.mxid,
"Posting this message to LINE may have failed.",
relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id))
"Posting this message to LINE may have failed.",
relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id))
async def handle_matrix_leave(self, user: 'u.User') -> None:
self.log.info(f"{user.mxid} left portal to {self.chat_id}, "
f"cleaning up and deleting...")
await self.cleanup_and_delete()
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
async def _bridge_own_message_pm(self, source: 'u.User', puppet: Optional['p.Puppet'], mid: str,
invite: bool = True) -> Optional[IntentAPI]:
# Use bridge bot as puppet for own user when puppet for own user is unavailable
# TODO Use own LINE puppet instead, and create it if it's not available yet
intent = sender.intent if sender else self.az.intent
if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user):
intent = puppet.intent if puppet else (await source.get_own_puppet()).intent
if self.is_direct and (not puppet or puppet.mid == source.mid and not puppet.is_real_user):
if self.invite_own_puppet_to_pm and invite:
try:
await intent.ensure_joined(self.mxid)
@ -213,29 +218,55 @@ class Portal(DBPortal, BasePortal):
if evt.is_outgoing:
if source.intent:
sender = None
intent = source.intent
else:
if not self.invite_own_puppet_to_pm:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return
sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None
intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
puppet = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None
intent = await self._bridge_own_message_pm(source, puppet, f"message {evt.id}")
if not intent:
return
else:
sender = await p.Puppet.get_by_mid(self.other_user if self.is_direct else evt.sender.id)
# TODO Respond to name/avatar changes of users in a DM
if not self.is_direct:
if sender:
await sender.update_info(evt.sender, source.client)
if self.is_direct:
# TODO Respond to name/avatar changes of users in a DM
intent = (await p.Puppet.get_by_mid(self.other_user)).intent
elif evt.sender:
puppet = await p.Puppet.get_by_mid(evt.sender.id)
if puppet:
await puppet.update_info(evt.sender, source.client)
else:
self.log.warning(f"Could not find ID of LINE user who sent event {evt.id}")
sender = await p.Puppet.get_by_profile(evt.sender, source.client)
intent = sender.intent
self.log.warning(f"Could not find ID of LINE user who sent message {evt.id or 'with no ID'}")
puppet = await p.Puppet.get_by_profile(evt.sender, source.client)
intent = puppet.intent
else:
self.log.info(f"Using bridgebot for unknown sender of message {evt.id or 'with no ID'}")
intent = self.az.intent
await intent.ensure_joined(self.mxid)
if evt.image and evt.image.url:
if evt.id:
msg = await DBMessage.get_next_noid_msg(self.mxid)
if not msg:
self.log.info(f"Handling new message {evt.id} in chat {self.mxid}")
prev_event_id = None
else:
self.log.info(f"Handling preseen message {evt.id} in chat {self.mxid}: {msg.mxid}")
if not self.is_direct:
# Non-DM previews are always sent by bridgebot.
# Must delete the bridgebot message and send a new message from the correct puppet.
await self.az.intent.redact(self.mxid, msg.mxid, "Found actual sender")
prev_event_id = None
else:
prev_event_id = msg.mxid
else:
self.log.info(f"Handling new message with no ID in chat {self.mxid}")
msg = None
prev_event_id = None
if prev_event_id and evt.html:
# No need to update a previewed text message, as their previews are accurate
event_id = prev_event_id
elif evt.image and evt.image.url:
if not evt.image.is_sticker or self.config["bridge.receive_stickers"]:
media_info = await self._handle_remote_media(
source, intent, evt.image.url,
@ -252,9 +283,11 @@ class Portal(DBPortal, BasePortal):
else:
media_info = None
send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info
if send_sticker:
event_id = await intent.send_sticker(
self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
# TODO Element Web messes up text->sticker edits!!
# File a case on it
if send_sticker and not prev_event_id:
#relates_to = RelatesTo(rel_type=RelationType.REPLACE, event_id=prev_event_id) if prev_event_id else None
event_id = await intent.send_sticker(self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
else:
if media_info:
content = MediaMessageEventContent(
@ -266,8 +299,11 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=f"<{'sticker' if evt.image.is_sticker else 'image'}>")
if prev_event_id:
content.set_edit(prev_event_id)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
elif evt.html and not evt.html.isspace():
chunks = []
def handle_data(data):
@ -331,17 +367,24 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent(
msgtype=MessageType.NOTICE,
body="<Unbridgeable message>")
if prev_event_id:
content.set_edit(prev_event_id)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
if evt.is_outgoing and evt.receipt_count:
await self._handle_receipt(event_id, evt.receipt_count)
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
try:
await msg.insert()
await self._send_delivery_receipt(event_id)
self.log.debug(f"Handled remote message {evt.id} -> {event_id}")
except UniqueViolationError as e:
self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {e}")
if not msg:
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
try:
await msg.insert()
#await self._send_delivery_receipt(event_id)
self.log.debug(f"Handled remote message {evt.id or 'with no ID'} -> {event_id}")
except UniqueViolationError as e:
self.log.debug(f"Failed to handle remote message {evt.id or 'with no ID'} -> {event_id}: {e}")
else:
await msg.update_ids(new_mxid=event_id, new_mid=evt.id)
self.log.debug(f"Handled preseen remote message {evt.id} -> {event_id}")
async def handle_remote_receipt(self, receipt: Receipt) -> None:
msg = await DBMessage.get_by_mid(receipt.id)
@ -529,11 +572,12 @@ class Portal(DBPortal, BasePortal):
continue
mid = p.Puppet.get_id_from_mxid(user_id)
if mid and mid not in current_members:
is_own_puppet = p.Puppet.is_mid_for_own_puppet(mid)
if mid and mid not in current_members and not is_own_puppet:
print(mid)
await self.main_intent.kick_user(self.mxid, user_id,
reason="User had left this chat")
elif forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(mid):
elif forbid_own_puppets and is_own_puppet:
await self.main_intent.kick_user(self.mxid, user_id,
reason="Kicking own puppet")
@ -553,6 +597,7 @@ class Portal(DBPortal, BasePortal):
if not messages:
self.log.debug("Didn't get any entries from server")
await self._cleanup_noid_msgs()
return
self.log.debug("Got %d messages from server", len(messages))
@ -560,6 +605,7 @@ class Portal(DBPortal, BasePortal):
for evt in messages:
await self.handle_remote_message(source, evt)
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
await self._cleanup_noid_msgs()
@property
def bridge_info_state_key(self) -> str:
@ -713,9 +759,6 @@ class Portal(DBPortal, BasePortal):
self._main_intent = self.az.intent
async def delete(self) -> None:
if self.mxid:
# TODO Handle this with db foreign keys instead
await DBMessage.delete_all(self.mxid)
self.by_chat_id.pop(self.chat_id, None)
self.by_mxid.pop(self.mxid, None)
await super().delete()

View File

@ -19,7 +19,7 @@ from base64 import b64decode
import asyncio
from .rpc import RPCClient
from .types import ChatListInfo, ChatInfo, Message, Receipt, ImageData, StartStatus
from .types import ChatListInfo, ChatInfo, ImageData, Message, Participant, Receipt, StartStatus
class LoginCommand(TypedDict):
@ -41,12 +41,15 @@ class Client(RPCClient):
async def resume(self) -> None:
await self.request("resume")
async def get_own_profile(self) -> Participant:
return Participant.deserialize(await self.request("get_own_profile"))
async def get_chats(self) -> List[ChatListInfo]:
resp = await self.request("get_chats")
return [ChatListInfo.deserialize(data) for data in resp]
async def get_chat(self, chat_id: str) -> ChatInfo:
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id))
async def get_chat(self, chat_id: str, force_view: bool = False) -> ChatInfo:
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id, force_view=force_view))
async def get_messages(self, chat_id: str) -> List[Message]:
resp = await self.request("get_messages", chat_id=chat_id)

View File

@ -33,6 +33,7 @@ class RPCClient:
log: TraceLogger = logging.getLogger("mau.rpc")
user_id: UserID
ephemeral_events: bool
_reader: Optional[asyncio.StreamReader]
_writer: Optional[asyncio.StreamWriter]
_req_id: int
@ -40,10 +41,12 @@ class RPCClient:
_response_waiters: Dict[int, asyncio.Future]
_event_handlers: Dict[str, List[EventHandler]]
def __init__(self, user_id: UserID) -> None:
def __init__(self, user_id: UserID, own_id: str, ephemeral_events: bool) -> None:
self.log = self.log.getChild(user_id)
self.loop = asyncio.get_running_loop()
self.user_id = user_id
self.own_id = own_id
self.ephemeral_events = ephemeral_events
self._req_id = 0
self._min_broadcast_id = 0
self._event_handlers = {}
@ -67,7 +70,10 @@ class RPCClient:
self._writer = w
self.loop.create_task(self._try_read_loop())
self.loop.create_task(self._command_loop())
await self.request("register", user_id=self.user_id)
await self.request("register",
user_id=self.user_id,
own_id = self.own_id,
ephemeral_events=self.ephemeral_events)
async def disconnect(self) -> None:
self._writer.write_eof()

View File

@ -60,7 +60,7 @@ class MessageImage(SerializableAttrs['MessageImage']):
@dataclass
class Message(SerializableAttrs['Message']):
id: int
id: Optional[int]
chat_id: int
is_outgoing: bool
sender: Optional[Participant]

View File

@ -69,6 +69,14 @@ class User(DBUser, BaseUser):
self.log.debug(f"Sending bridge notice: {text}")
await self.az.intent.send_notice(self.notice_room, text)
@property
def own_id(self) -> str:
# Remove characters that will conflict with mxid grammar
return f"_OWN_{self.mxid[1:].replace(':', '_ON_')}"
async def get_own_puppet(self) -> 'pu.Puppet':
return await pu.Puppet.get_by_mid(self.own_id)
async def is_logged_in(self) -> bool:
try:
return self.client and (await self.client.start()).is_logged_in
@ -95,7 +103,7 @@ class User(DBUser, BaseUser):
async def connect(self) -> None:
self.loop.create_task(self.connect_double_puppet())
self.client = Client(self.mxid)
self.client = Client(self.mxid, self.own_id, self.config["appservice.ephemeral_events"])
self.log.debug("Starting client")
await self.send_bridge_notice("Starting up...")
state = await self.client.start()
@ -126,6 +134,7 @@ class User(DBUser, BaseUser):
self._connection_check_task.cancel()
self._connection_check_task = self.loop.create_task(self._check_connection_loop())
await self.client.pause()
await self.sync_own_profile()
await self.client.set_last_message_ids(await DBMessage.get_max_mids())
limit = self.config["bridge.initial_conversation_sync"]
self.log.info("Syncing chats")
@ -144,6 +153,20 @@ class User(DBUser, BaseUser):
await self.send_bridge_notice("Synchronization complete")
await self.client.resume()
async def sync_portal(self, portal: 'po.Portal') -> None:
chat_id = portal.chat_id
self.log.info(f"Viewing (and syncing) chat {chat_id}")
await self.client.pause()
chat = await self.client.get_chat(chat_id, True)
await portal.update_matrix_room(self, chat)
await self.client.resume()
async def sync_own_profile(self) -> None:
self.log.info("Syncing own LINE profile info")
own_profile = await self.client.get_own_profile()
puppet = await self.get_own_puppet()
await puppet.update_info(own_profile, self.client)
async def stop(self) -> None:
# TODO Notices for shutdown messages
if self._connection_check_task:

2
puppet/.gitignore vendored
View File

@ -1,2 +1,2 @@
/node_modules
/config.json
/config*.json

View File

@ -99,7 +99,7 @@ export default class Client {
}
sendMessage(message) {
this.log(`Sending message ${message.id} to client`)
this.log(`Sending message ${message.id || "with no ID"} to client`)
return this._write({
id: --this.notificationID,
command: "message",
@ -164,7 +164,7 @@ export default class Client {
let started = false
if (this.puppet === null) {
this.log("Opening new puppeteer for", this.userID)
this.puppet = new MessagesPuppeteer(this.userID, this)
this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this.sendPlaceholders, this)
this.manager.puppets.set(this.userID, this.puppet)
await this.puppet.start(!!req.debug)
started = true
@ -194,11 +194,13 @@ export default class Client {
handleRegister = async (req) => {
this.userID = req.user_id
this.log("Registered socket", this.connID, "->", this.userID)
this.ownID = req.own_id
this.sendPlaceholders = req.ephemeral_events
this.log(`Registered socket ${this.connID} -> ${this.userID}${!this.sendPlaceholders ? "" : " (with placeholder message support)"}`)
if (this.manager.clients.has(this.userID)) {
const oldClient = this.manager.clients.get(this.userID)
this.manager.clients.set(this.userID, this)
this.log("Terminating previous socket", oldClient.connID, "for", this.userID)
this.log(`Terminating previous socket ${oldClient.connID} for ${this.userID}`)
await oldClient.stop("Socket replaced by new connection")
} else {
this.manager.clients.set(this.userID, this)
@ -258,8 +260,9 @@ export default class Client {
set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids),
pause: () => this.puppet.stopObserving(),
resume: () => this.puppet.startObserving(),
get_own_profile: () => this.puppet.getOwnProfile(),
get_chats: () => this.puppet.getRecentChats(),
get_chat: req => this.puppet.getChatInfo(req.chat_id),
get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view),
get_messages: req => this.puppet.getMessages(req.chat_id),
read_image: req => this.puppet.readImage(req.image_url),
is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }),

View File

@ -23,7 +23,7 @@
*/
window.__chronoParseDate = function (text, ref, option) {}
/**
* @param {string[]} changes - The hrefs of the chats that changed.
* @param {ChatListInfo[]} changes - The chats that changed.
* @return {Promise<void>}
*/
window.__mautrixReceiveChanges = function (changes) {}
@ -102,9 +102,7 @@ class MautrixController {
}
setOwnID(ownID) {
// Remove characters that will conflict with mxid grammar
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
this.ownID = ownID
}
// TODO Commonize with Node context
@ -527,7 +525,7 @@ class MautrixController {
* @typedef PathImage
* @type object
* @property {?string} path - The virtual path of the image (behaves like an ID). Optional.
* @property {string} src - The URL of the image. Mandatory.
* @property {string} url - The URL of the image. Mandatory.
*/
/**
@ -609,6 +607,8 @@ class MautrixController {
* May be prefixed by sender name.
* @property {string} lastMsgDate - An imprecise date for the most recent message
* (e.g. "7:16 PM", "Thu" or "Aug 4")
* @property {number} notificationCount - The number of unread messages in the chat,
* signified by the number in its notification badge.
*/
getChatListItemID(element) {
@ -624,13 +624,17 @@ class MautrixController {
}
getChatListItemLastMsg(element) {
return element.querySelector(".mdCMN04Desc").innerText
return element.querySelector(".mdCMN04Desc").innerHTML
}
getChatListItemLastMsgDate(element) {
return element.querySelector("time").innerText
}
getChatListItemNotificationCount(element) {
return Number.parseInt(element.querySelector(".MdIcoBadge01:not(.MdNonDisp)")?.innerText) || 0
}
/**
* Parse a conversation list item element.
*
@ -645,6 +649,7 @@ class MautrixController {
icon: this.getChatListItemIcon(element),
lastMsg: this.getChatListItemLastMsg(element),
lastMsgDate: this.getChatListItemLastMsgDate(element),
notificationCount: this.getChatListItemNotificationCount(element),
}
}
@ -676,13 +681,56 @@ class MautrixController {
return promise
}
/**
* Wait for updates to the active chat's message list to settle down.
* Wait an additional bit of time every time an update is observed.
* TODO Look (harder) for an explicit signal of when a chat is fully updated...
*
* @returns Promise<void>
*/
waitForMessageListStability() {
// Increase this if messages get missed on sync / chat change.
// Decrease it if response times are too slow.
const delayMillis = 500
let myResolve
const promise = new Promise(resolve => {myResolve = resolve})
let observer
const onTimeout = () => {
console.log("Message list looks stable, continue")
console.debug(`timeoutID = ${timeoutID}`)
observer.disconnect()
myResolve()
}
let timeoutID
const startTimer = () => {
timeoutID = setTimeout(onTimeout, delayMillis)
}
observer = new MutationObserver(changes => {
clearTimeout(timeoutID)
console.log("CHANGE to message list detected! Wait a bit longer...")
console.debug(`timeoutID = ${timeoutID}`)
console.debug(changes)
startTimer()
})
observer.observe(
document.querySelector("#_chat_message_area"),
{childList: true, attributes: true, subtree: true})
startTimer()
return promise
}
/**
* @param {[MutationRecord]} mutations - The mutation records that occurred
* @private
*/
_observeChatListMutations(mutations) {
// TODO Observe *added/removed* chats, not just new messages
const changedChatIDs = new Set()
const changedChats = new Set()
for (const change of mutations) {
if (change.target.id == "_chat_list_body") {
// TODO
@ -692,26 +740,24 @@ class MautrixController {
for (const node of change.addedNodes) {
}
*/
} else if (change.target.tagName == "LI") {
} else if (change.target.tagName == "LI" && change.addedNodes.length == 1) {
if (change.target.classList.contains("ExSelected")) {
console.debug("Not using chat list mutation response for currently-active chat")
continue
}
for (const node of change.addedNodes) {
const chat = this.parseChatListItem(node)
if (chat) {
console.log("Added chat list item:", chat)
changedChatIDs.add(chat.id)
} else {
console.debug("Could not parse added node as a chat list item:", node)
}
const chat = this.parseChatListItem(change.addedNodes[0])
if (chat) {
console.log("Added chat list item:", chat)
changedChats.add(chat)
} else {
console.debug("Could not parse added node as a chat list item:", node)
}
}
// change.removedNodes tells you which chats that had notifications are now read.
}
if (changedChatIDs.size > 0) {
console.debug("Dispatching chat list mutations:", changedChatIDs)
window.__mautrixReceiveChanges(Array.from(changedChatIDs)).then(
if (changedChats.size > 0) {
console.debug("Dispatching chat list mutations:", changedChats)
window.__mautrixReceiveChanges(Array.from(changedChats)).then(
() => console.debug("Chat list mutations dispatched"),
err => console.error("Error dispatching chat list mutations:", err))
}

View File

@ -27,7 +27,7 @@ export default class MessagesPuppeteer {
static executablePath = undefined
static devtools = false
static noSandbox = false
static viewport = { width: 960, height: 880 }
static viewport = { width: 960, height: 840 }
static url = undefined
static extensionDir = "extension_files"
@ -36,16 +36,19 @@ export default class MessagesPuppeteer {
* @param {string} id
* @param {?Client} [client]
*/
constructor(id, client = null) {
constructor(id, ownID, sendPlaceholders, client = null) {
let profilePath = path.join(MessagesPuppeteer.profileDir, id)
if (!profilePath.startsWith("/")) {
profilePath = path.join(process.cwd(), profilePath)
}
this.id = id
this.ownID = ownID
this.sendPlaceholders = sendPlaceholders
this.profilePath = profilePath
this.updatedChats = new Set()
this.sentMessageIDs = new Set()
this.mostRecentMessages = new Map()
this.numChatNotifications = new Map()
this.taskQueue = new TaskQueue(this.id)
this.client = client
}
@ -65,18 +68,19 @@ export default class MessagesPuppeteer {
async start() {
this.log("Launching browser")
let extensionArgs = [
const args = [
`--disable-extensions-except=${MessagesPuppeteer.extensionDir}`,
`--load-extension=${MessagesPuppeteer.extensionDir}`
`--load-extension=${MessagesPuppeteer.extensionDir}`,
`--window-size=${MessagesPuppeteer.viewport.width},${MessagesPuppeteer.viewport.height+120}`,
]
if (MessagesPuppeteer.noSandbox) {
extensionArgs = extensionArgs.concat(`--no-sandbox`)
args = args.concat(`--no-sandbox`)
}
this.browser = await puppeteer.launch({
executablePath: MessagesPuppeteer.executablePath,
userDataDir: this.profilePath,
args: extensionArgs,
args: args,
headless: false, // Needed to load extensions
defaultViewport: MessagesPuppeteer.viewport,
devtools: MessagesPuppeteer.devtools,
@ -89,6 +93,10 @@ export default class MessagesPuppeteer {
} else {
this.page = await this.browser.newPage()
}
this.blankPage = await this.browser.newPage()
await this.page.bringToFront()
this.log("Opening", MessagesPuppeteer.url)
await this.page.setBypassCSP(true) // Needed to load content scripts
await this._preparePage(true)
@ -119,6 +127,7 @@ export default class MessagesPuppeteer {
}
async _preparePage(navigateTo) {
await this.page.bringToFront()
if (navigateTo) {
await this.page.goto(MessagesPuppeteer.url)
} else {
@ -128,6 +137,30 @@ export default class MessagesPuppeteer {
await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" })
}
async _interactWithPage(promiser) {
await this.page.bringToFront()
try {
await promiser()
} catch (e) {
this.error(`Error while interacting with page: ${e}`)
throw e
} finally {
await this.blankPage.bringToFront()
}
}
/**
* Set the contents of a text input field to the given text.
* Works by triple-clicking the input field to select all existing text, to replace it on type.
*
* @param {ElementHandle} inputElement - The input element to type into.
* @param {string} text - The text to input.
*/
async _enterText(inputElement, text) {
await inputElement.click({clickCount: 3})
await inputElement.type(text)
}
/**
* Wait for the session to be logged in and monitor changes while it's not.
*/
@ -136,6 +169,7 @@ export default class MessagesPuppeteer {
return
}
this.loginRunning = true
await this.page.bringToFront()
const loginContentArea = await this.page.waitForSelector("#login_content")
@ -219,7 +253,7 @@ export default class MessagesPuppeteer {
this.log("Removing observers")
// TODO __mautrixController is undefined when cancelling, why?
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id)
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.ownID)
await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver())
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
@ -254,15 +288,7 @@ export default class MessagesPuppeteer {
}
this.loginRunning = false
// Don't start observing yet, instead wait for explicit request.
// But at least view the most recent chat.
try {
const mostRecentChatID = await this.page.$eval("#_chat_list_body li",
element => window.__mautrixController.getChatListItemID(element.firstElementChild))
await this._switchChat(mostRecentChatID)
} catch (e) {
this.log("No chats available to focus on")
}
await this.blankPage.bringToFront()
this.log("Login complete")
}
@ -376,10 +402,11 @@ export default class MessagesPuppeteer {
* Get info about a chat.
*
* @param {string} chatID - The chat ID whose info to get.
* @param {boolean} forceView - Whether the LINE tab should always be viewed, even if the chat is already active.
* @return {Promise<ChatInfo>} - Info about the chat.
*/
async getChatInfo(chatID) {
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID))
async getChatInfo(chatID, forceView) {
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID, forceView))
}
/**
@ -400,7 +427,7 @@ export default class MessagesPuppeteer {
* @return {Promise<[MessageData]>} - The messages visible in the chat.
*/
async getMessages(chatID) {
return await this.taskQueue.push(async () => this._getMessagesUnsafe(chatID))
return await this.taskQueue.push(() => this._getMessagesUnsafe(chatID))
}
setLastMessageIDs(ids) {
@ -443,11 +470,46 @@ export default class MessagesPuppeteer {
() => window.__mautrixController.removeMsgListObserver())
}
async getOwnProfile() {
return await this.taskQueue.push(() => this._getOwnProfileUnsafe())
}
async _getOwnProfileUnsafe() {
// NOTE Will send a read receipt if a chat was in view!
// Best to use this on startup when no chat is viewed.
let ownProfile
await this._interactWithPage(async () => {
this.log("Opening settings view")
await this.page.click("button.mdGHD01SettingBtn")
await this.page.waitForSelector("#context_menu li#settings", {visible: true}).then(e => e.click())
await this.page.waitForSelector("#settings_contents", {visible: true})
this.log("Getting own profile info")
ownProfile = {
id: this.ownID,
name: await this.page.$eval("#settings_basic_name_input", e => e.innerText),
avatar: {
path: null,
url: await this.page.$eval(".mdCMN09ImgInput", e => {
const imgStr = e.style?.getPropertyValue("background-image")
const matches = imgStr.match(/url\("(blob:.*)"\)/)
return matches?.length == 2 ? matches[1] : null
}),
},
}
const backSelector = "#label_setting button"
await this.page.click(backSelector)
await this.page.waitForSelector(backSelector, {visible: false})
})
return ownProfile
}
_listItemSelector(id) {
return `#_chat_list_body div[data-chatid="${id}"]`
}
async _switchChat(chatID) {
async _switchChat(chatID, forceView = false) {
// TODO Allow passing in an element directly
this.log(`Switching to chat ${chatID}`)
const chatListItem = await this.page.$(this._listItemSelector(chatID))
@ -463,30 +525,63 @@ export default class MessagesPuppeteer {
}
if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
this.log("Already viewing chat, no need to switch")
if (!forceView) {
this.log("Already viewing chat, no need to switch")
} else {
await this._interactWithPage(async () => {
this.log("Already viewing chat, but got request to view it")
this.page.waitForTimeout(500)
})
}
} else {
this.log("Ensuring msg list observer is removed")
const hadMsgListObserver = await this.page.evaluate(
() => window.__mautrixController.removeMsgListObserver())
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
await chatListItem.click()
this.log(`Waiting for chat header title to be "${chatName}"`)
await this.page.waitForFunction(
isCorrectChatVisible,
{polling: "mutation"},
chatName)
await this._interactWithPage(async () => {
let numTries = 3
while (true) {
try {
this.log("Clicking chat list item")
chatListItem.click()
this.log(`Waiting for chat header title to be "${chatName}"`)
await this.page.waitForFunction(
isCorrectChatVisible,
{polling: "mutation", timeout: 1000},
chatName)
break
} catch (e) {
if (--numTries == 0) {
throw e
} else {
this.log("Clicking chat list item didn't work...try again")
}
}
}
// Always show the chat details sidebar, as this makes life easier
this.log("Waiting for detail area to be auto-hidden upon entering chat")
await this.page.waitForFunction(
detailArea => detailArea.childElementCount == 0,
{},
await this.page.$("#_chat_detail_area"))
this.log("Clicking chat header to show detail area")
await this.page.click("#_chat_header_area > .mdRGT04Link")
this.log("Waiting for detail area")
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
// Always show the chat details sidebar, as this makes life easier
this.log("Waiting for detail area to be auto-hidden upon entering chat")
await this.page.waitForFunction(
detailArea => detailArea.childElementCount == 0,
{},
await this.page.$("#_chat_detail_area"))
this.log("Clicking chat header to show detail area")
await this.page.click("#_chat_header_area > .mdRGT04Link")
this.log("Waiting for detail area")
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
})
this.log("Waiting for any item to appear in chat")
try {
await this.page.waitForSelector("#_chat_room_msg_list div", {timeout: 2000})
this.log("Waiting for chat to stabilize")
await this.page.evaluate(() => window.__mautrixController.waitForMessageListStability())
} catch (e) {
this.log("No messages in chat found. Maybe no messages were ever sent yet?")
}
if (hadMsgListObserver) {
this.log("Restoring msg list observer")
@ -499,7 +594,7 @@ export default class MessagesPuppeteer {
}
}
async _getChatInfoUnsafe(chatID) {
async _getChatInfoUnsafe(chatID, forceView) {
const chatListInfo = await this.page.$eval(this._listItemSelector(chatID),
(element, chatID) => window.__mautrixController.parseChatListItem(element, chatID),
chatID)
@ -521,7 +616,7 @@ export default class MessagesPuppeteer {
if (!isDirect) {
this.log("Found multi-user chat, so viewing it to get participants")
// TODO This will mark the chat as "read"!
await this._switchChat(chatID)
await this._switchChat(chatID, forceView)
const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
// TODO Is a group not actually created until a message is sent(?)
// If so, maybe don't create a portal until there is a message.
@ -529,6 +624,10 @@ export default class MessagesPuppeteer {
element => window.__mautrixController.parseParticipantList(element))
} else {
this.log(`Found direct chat with ${chatID}`)
if (forceView) {
this.log("Viewing chat on request")
await this._switchChat(chatID, forceView)
}
//const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{
@ -551,21 +650,27 @@ export default class MessagesPuppeteer {
// Always present, just made visible via classes
async _sendMessageUnsafe(chatID, text) {
await this._switchChat(chatID)
// Sync all messages in this chat first
this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true)
// TODO Initiate the promise in the content script
await this.page.evaluate(
() => window.__mautrixController.promiseOwnMessage(5000, "time"))
const input = await this.page.$("#_chat_room_input")
await input.click()
await input.type(text)
await input.press("Enter")
await this._interactWithPage(async () => {
// Live-typing in the field can have its text mismatch what was requested!!
// Probably because the input element is a div instead of a real text input...ugh!
// Setting its innerText directly works fine though...
await input.click()
await input.evaluate((e, text) => e.innerText = text, text)
await input.press("Enter")
})
return await this._waitForSentMessage(chatID)
}
async _sendFileUnsafe(chatID, filePath) {
await this._switchChat(chatID)
this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true)
await this.page.evaluate(
() => window.__mautrixController.promiseOwnMessage(
10000, // Use longer timeout for file uploads
@ -573,13 +678,15 @@ export default class MessagesPuppeteer {
"#_chat_message_fail_menu"))
try {
this.log(`About to ask for file chooser in ${chatID}`)
const [fileChooser] = await Promise.all([
this.page.waitForFileChooser(),
this.page.click("#_chat_room_plus_btn")
])
this.log(`About to upload ${filePath}`)
await fileChooser.accept([filePath])
this._interactWithPage(async () => {
this.log(`About to ask for file chooser in ${chatID}`)
const [fileChooser] = await Promise.all([
this.page.waitForFileChooser(),
this.page.click("#_chat_room_plus_btn")
])
this.log(`About to upload ${filePath}`)
await fileChooser.accept([filePath])
})
} catch (e) {
this.log(`Failed to upload file to ${chatID}`)
return -1
@ -604,9 +711,11 @@ export default class MessagesPuppeteer {
}
}
async _receiveMessages(chatID, messages) {
_receiveMessages(chatID, messages, skipProcessing = false) {
if (this.client) {
messages = await this._processMessages(chatID, messages)
if (!skipProcessing) {
messages = this._processMessages(chatID, messages)
}
for (const message of messages) {
this.client.sendMessage(message).catch(err =>
this.error("Failed to send message", message.id, "to client:", err))
@ -626,11 +735,13 @@ export default class MessagesPuppeteer {
// TODO Handle unloaded messages. Maybe scroll up
// TODO This will mark the chat as "read"!
await this._switchChat(chatID)
const messages = await this.page.evaluate(
// TODO Is it better to reset the notification count in _switchChat instead of here?
this.numChatNotifications.set(chatID, 0)
let messages = await this.page.evaluate(
mostRecentMessage => window.__mautrixController.parseMessageList(mostRecentMessage),
this.mostRecentMessages.get(chatID))
// Doing this before restoring the observer since it updates minID
const filteredMessages = await this._processMessages(chatID, messages)
messages = this._processMessages(chatID, messages)
if (hadMsgListObserver) {
this.log("Restoring msg list observer")
@ -641,10 +752,10 @@ export default class MessagesPuppeteer {
this.log("Not restoring msg list observer, as there never was one")
}
return filteredMessages
return messages
}
async _processMessages(chatID, messages) {
_processMessages(chatID, messages) {
// TODO Probably don't need minID filtering if Puppeteer context handles it now
const minID = this.mostRecentMessages.get(chatID) || 0
const filteredMessages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
@ -655,7 +766,6 @@ export default class MessagesPuppeteer {
this.mostRecentMessages.set(chatID, newLastID)
const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}`
this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filteredMessages.length} newer than ${minID} (${range})`)
for (const message of filteredMessages) {
message.chat_id = chatID
}
@ -665,19 +775,62 @@ export default class MessagesPuppeteer {
}
}
async _processChatListChangeUnsafe(chatID) {
async _processChatListChangeUnsafe(chatListInfo) {
const chatID = chatListInfo.id
this.updatedChats.delete(chatID)
this.log("Processing change to", chatID)
const messages = await this._getMessagesUnsafe(chatID)
if (messages.length === 0) {
this.log("No new messages found in", chatID)
// TODO Also process name/icon changes
const prevNumNotifications = this.numChatNotifications.get(chatID) || 0
const diffNumNotifications = chatListInfo.notificationCount - prevNumNotifications
if (chatListInfo.notificationCount == 0 && diffNumNotifications < 0) {
this.log("Notifications dropped--must have read messages from another LINE client, skip")
this.numChatNotifications.set(chatID, 0)
return
}
const mustSync =
// If >1, a notification was missed. Only way to get them is to view the chat.
// If == 0, might be own message...or just a shuffled chat, or something else.
// To play it safe, just sync them. Should be no harm, as they're viewed already.
diffNumNotifications != 1
// Without placeholders, some messages require visiting their chat to be synced.
|| !this.sendPlaceholders
&& (
// Can only use previews for DMs, because sender can't be found otherwise!
chatListInfo.id.charAt(0) != 'u'
// Sync when lastMsg is a canned message for a non-previewable message type.
|| chatListInfo.lastMsg.endsWith(" sent a photo.")
|| chatListInfo.lastMsg.endsWith(" sent a sticker.")
|| chatListInfo.lastMsg.endsWith(" sent a location.")
// TODO More?
)
let messages
if (!mustSync) {
messages = [{
chat_id: chatListInfo.id,
id: null, // because sidebar messages have no ID
timestamp: null, // because this message was sent right now
is_outgoing: false, // because there's no reliable way to detect own messages...
sender: null, // because there's no way to tell who sent a message
html: chatListInfo.lastMsg,
}]
this.numChatNotifications.set(chatID, chatListInfo.notificationCount)
} else {
messages = await this._getMessagesUnsafe(chatListInfo.id)
this.numChatNotifications.set(chatID, 0)
if (messages.length === 0) {
this.log("No new messages found in", chatListInfo.id)
return
}
}
if (this.client) {
for (const message of messages) {
await this.client.sendMessage(message).catch(err =>
this.error("Failed to send message", message.id, "to client:", err))
this.error("Failed to send message", message.id || "with no ID", "to client:", err))
}
} else {
this.log("No client connected, not sending messages")
@ -685,10 +838,10 @@ export default class MessagesPuppeteer {
}
_receiveChatListChanges(changes) {
this.log("Received chat list changes:", changes)
this.log(`Received chat list changes: ${changes.map(item => item.id)}`)
for (const item of changes) {
if (!this.updatedChats.has(item)) {
this.updatedChats.add(item)
if (!this.updatedChats.has(item.id)) {
this.updatedChats.add(item.id)
this.taskQueue.push(() => this._processChatListChangeUnsafe(item))
.catch(err => this.error("Error handling chat list changes:", err))
}
@ -724,18 +877,8 @@ export default class MessagesPuppeteer {
async _sendEmailCredentials() {
this.log("Inputting login credentials")
// Triple-click input fields to select all existing text and replace it on type
let input
input = await this.page.$("#line_login_email")
await input.click({clickCount: 3})
await input.type(this.login_email)
input = await this.page.$("#line_login_pwd")
await input.click({clickCount: 3})
await input.type(this.login_password)
await this._enterText(await this.page.$("#line_login_email"), this.login_email)
await this._enterText(await this.page.$("#line_login_pwd"), this.login_password)
await this.page.click("button#login_btn")
}
@ -781,6 +924,7 @@ export default class MessagesPuppeteer {
_onLoggedOut() {
this.log("Got logged out!")
this.stopObserving()
this.page.bringToFront()
if (this.client) {
this.client.sendLoggedOut().catch(err =>
this.error("Failed to send logout notice to client:", err))