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/ /.idea/
/.*project
/.settings/
/.venv /.venv
/env/ /env/
@ -13,8 +15,8 @@ __pycache__
profiles profiles
puppet/extension_files puppet/extension_files
/config.yaml /config*.yaml
/registration.yaml /registration*.yaml
*.log* *.log*
*.db *.db
*.pickle *.pickle

View File

@ -29,20 +29,18 @@ class Message:
mxid: EventID mxid: EventID
mx_room: RoomID mx_room: RoomID
mid: int mid: Optional[int]
chat_id: str chat_id: str
async def insert(self) -> None: async def insert(self) -> None:
q = "INSERT INTO message (mxid, mx_room, mid, chat_id) VALUES ($1, $2, $3, $4)" 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) await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id)
async def delete(self) -> None: async def update_ids(self, new_mxid: EventID, new_mid: int) -> None:
q = "DELETE FROM message WHERE mid=$1" q = ("UPDATE message SET mxid=$1, mid=$2 "
await self.db.execute(q, self.mid) "WHERE mxid=$3 AND mx_room=$4 AND chat_id=$5")
await self.db.execute(q, new_mxid, new_mid,
@classmethod self.mxid, self.mx_room, self.chat_id)
async def delete_all(cls, room_id: RoomID) -> None:
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
@classmethod @classmethod
async def get_max_mid(cls, room_id: RoomID) -> int: async def get_max_mid(cls, room_id: RoomID) -> int:
@ -57,6 +55,23 @@ class Message:
data[row["chat_id"]] = row["max_mid"] data[row["chat_id"]] = row["max_mid"]
return data 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 @classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']: 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 " row = await cls.db.fetchrow("SELECT mxid, mx_room, mid, chat_id "
@ -72,3 +87,18 @@ class Message:
if not row: if not row:
return None return None
return cls(**row) 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

@ -129,3 +129,10 @@ async def upgrade_strangers(conn: Connection) -> None:
REFERENCES puppet (mid) REFERENCES puppet (mid)
ON DELETE CASCADE 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" as_token: "This value is generated when generating the registration"
hs_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. # Prometheus telemetry config. Requires prometheus-client to be installed.
metrics: metrics:
enabled: false enabled: false

View File

@ -17,9 +17,11 @@ from typing import TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent, from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent,
ReceiptEvent, SingleReceiptEventContent,
EventID, RoomID, UserID) EventID, RoomID, UserID)
from . import portal as po, puppet as pu, user as u from . import portal as po, puppet as pu, user as u
from .db import Message as DBMessage
if TYPE_CHECKING: if TYPE_CHECKING:
from .__main__ import MessagesBridge from .__main__ import MessagesBridge
@ -35,8 +37,9 @@ class MatrixHandler(BaseMatrixHandler):
super().__init__(bridge=bridge) super().__init__(bridge=bridge)
def filter_matrix_event(self, evt: Event) -> bool: def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, if isinstance(evt, ReceiptEvent):
RedactionEvent)): return False
if not isinstance(evt, (MessageEvent, StateEvent, EncryptedEvent)):
return True return True
return (evt.sender == self.az.bot_mxid return (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None) or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
@ -59,3 +62,16 @@ class MatrixHandler(BaseMatrixHandler):
return return
await portal.handle_matrix_leave(user) 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: except Exception:
self.log.exception("Failed to send delivery receipt for %s", event_id) 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, async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
event_id: EventID) -> None: event_id: EventID) -> None:
if not sender.client: 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}") self.log.warning(f"Failed to upload media {event_id} to chat {self.chat_id}: {e}")
message_id = -1 message_id = -1
remove(file_path) remove(file_path)
await self._cleanup_noid_msgs()
msg = None msg = None
if message_id != -1: if message_id != -1:
try: try:
@ -183,12 +190,10 @@ class Portal(DBPortal, BasePortal):
f"cleaning up and deleting...") f"cleaning up and deleting...")
await self.cleanup_and_delete() 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]: invite: bool = True) -> Optional[IntentAPI]:
# Use bridge bot as puppet for own user when puppet for own user is unavailable intent = puppet.intent if puppet else (await source.get_own_puppet()).intent
# TODO Use own LINE puppet instead, and create it if it's not available yet if self.is_direct and (not puppet or puppet.mid == source.mid and not puppet.is_real_user):
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):
if self.invite_own_puppet_to_pm and invite: if self.invite_own_puppet_to_pm and invite:
try: try:
await intent.ensure_joined(self.mxid) await intent.ensure_joined(self.mxid)
@ -213,29 +218,55 @@ class Portal(DBPortal, BasePortal):
if evt.is_outgoing: if evt.is_outgoing:
if source.intent: if source.intent:
sender = None
intent = source.intent intent = source.intent
else: else:
if not self.invite_own_puppet_to_pm: if not self.invite_own_puppet_to_pm:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled") self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return return
sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None puppet = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None
intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}") intent = await self._bridge_own_message_pm(source, puppet, f"message {evt.id}")
if not intent: if not intent:
return return
else: else:
sender = await p.Puppet.get_by_mid(self.other_user if self.is_direct else evt.sender.id) if self.is_direct:
# TODO Respond to name/avatar changes of users in a DM # TODO Respond to name/avatar changes of users in a DM
if not self.is_direct: intent = (await p.Puppet.get_by_mid(self.other_user)).intent
if sender: elif evt.sender:
await sender.update_info(evt.sender, source.client) puppet = await p.Puppet.get_by_mid(evt.sender.id)
if puppet:
await puppet.update_info(evt.sender, source.client)
else: else:
self.log.warning(f"Could not find ID of LINE user who sent event {evt.id}") self.log.warning(f"Could not find ID of LINE user who sent message {evt.id or 'with no ID'}")
sender = await p.Puppet.get_by_profile(evt.sender, source.client) puppet = await p.Puppet.get_by_profile(evt.sender, source.client)
intent = sender.intent 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) 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"]: if not evt.image.is_sticker or self.config["bridge.receive_stickers"]:
media_info = await self._handle_remote_media( media_info = await self._handle_remote_media(
source, intent, evt.image.url, source, intent, evt.image.url,
@ -252,9 +283,11 @@ class Portal(DBPortal, BasePortal):
else: else:
media_info = None media_info = None
send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info
if send_sticker: # TODO Element Web messes up text->sticker edits!!
event_id = await intent.send_sticker( # File a case on it
self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp) 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: else:
if media_info: if media_info:
content = MediaMessageEventContent( content = MediaMessageEventContent(
@ -266,8 +299,11 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
body=f"<{'sticker' if evt.image.is_sticker else 'image'}>") 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) event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
elif evt.html and not evt.html.isspace(): elif evt.html and not evt.html.isspace():
chunks = [] chunks = []
def handle_data(data): def handle_data(data):
@ -331,17 +367,24 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
body="<Unbridgeable message>") body="<Unbridgeable message>")
if prev_event_id:
content.set_edit(prev_event_id)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp) event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
if evt.is_outgoing and evt.receipt_count: if evt.is_outgoing and evt.receipt_count:
await self._handle_receipt(event_id, evt.receipt_count) await self._handle_receipt(event_id, evt.receipt_count)
if not msg:
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id) msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
try: try:
await msg.insert() await msg.insert()
await self._send_delivery_receipt(event_id) #await self._send_delivery_receipt(event_id)
self.log.debug(f"Handled remote message {evt.id} -> {event_id}") self.log.debug(f"Handled remote message {evt.id or 'with no ID'} -> {event_id}")
except UniqueViolationError as e: except UniqueViolationError as e:
self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {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: async def handle_remote_receipt(self, receipt: Receipt) -> None:
msg = await DBMessage.get_by_mid(receipt.id) msg = await DBMessage.get_by_mid(receipt.id)
@ -529,11 +572,12 @@ class Portal(DBPortal, BasePortal):
continue continue
mid = p.Puppet.get_id_from_mxid(user_id) 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) print(mid)
await self.main_intent.kick_user(self.mxid, user_id, await self.main_intent.kick_user(self.mxid, user_id,
reason="User had left this chat") 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, await self.main_intent.kick_user(self.mxid, user_id,
reason="Kicking own puppet") reason="Kicking own puppet")
@ -553,6 +597,7 @@ class Portal(DBPortal, BasePortal):
if not messages: if not messages:
self.log.debug("Didn't get any entries from server") self.log.debug("Didn't get any entries from server")
await self._cleanup_noid_msgs()
return return
self.log.debug("Got %d messages from server", len(messages)) self.log.debug("Got %d messages from server", len(messages))
@ -560,6 +605,7 @@ class Portal(DBPortal, BasePortal):
for evt in messages: for evt in messages:
await self.handle_remote_message(source, evt) await self.handle_remote_message(source, evt)
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid) self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
await self._cleanup_noid_msgs()
@property @property
def bridge_info_state_key(self) -> str: def bridge_info_state_key(self) -> str:
@ -713,9 +759,6 @@ class Portal(DBPortal, BasePortal):
self._main_intent = self.az.intent self._main_intent = self.az.intent
async def delete(self) -> None: 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_chat_id.pop(self.chat_id, None)
self.by_mxid.pop(self.mxid, None) self.by_mxid.pop(self.mxid, None)
await super().delete() await super().delete()

View File

@ -19,7 +19,7 @@ from base64 import b64decode
import asyncio import asyncio
from .rpc import RPCClient 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): class LoginCommand(TypedDict):
@ -41,12 +41,15 @@ class Client(RPCClient):
async def resume(self) -> None: async def resume(self) -> None:
await self.request("resume") 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]: async def get_chats(self) -> List[ChatListInfo]:
resp = await self.request("get_chats") resp = await self.request("get_chats")
return [ChatListInfo.deserialize(data) for data in resp] return [ChatListInfo.deserialize(data) for data in resp]
async def get_chat(self, chat_id: str) -> ChatInfo: 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)) 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]: async def get_messages(self, chat_id: str) -> List[Message]:
resp = await self.request("get_messages", chat_id=chat_id) resp = await self.request("get_messages", chat_id=chat_id)

View File

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

View File

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

View File

@ -69,6 +69,14 @@ class User(DBUser, BaseUser):
self.log.debug(f"Sending bridge notice: {text}") self.log.debug(f"Sending bridge notice: {text}")
await self.az.intent.send_notice(self.notice_room, 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: async def is_logged_in(self) -> bool:
try: try:
return self.client and (await self.client.start()).is_logged_in return self.client and (await self.client.start()).is_logged_in
@ -95,7 +103,7 @@ class User(DBUser, BaseUser):
async def connect(self) -> None: async def connect(self) -> None:
self.loop.create_task(self.connect_double_puppet()) 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") self.log.debug("Starting client")
await self.send_bridge_notice("Starting up...") await self.send_bridge_notice("Starting up...")
state = await self.client.start() state = await self.client.start()
@ -126,6 +134,7 @@ class User(DBUser, BaseUser):
self._connection_check_task.cancel() self._connection_check_task.cancel()
self._connection_check_task = self.loop.create_task(self._check_connection_loop()) self._connection_check_task = self.loop.create_task(self._check_connection_loop())
await self.client.pause() await self.client.pause()
await self.sync_own_profile()
await self.client.set_last_message_ids(await DBMessage.get_max_mids()) await self.client.set_last_message_ids(await DBMessage.get_max_mids())
limit = self.config["bridge.initial_conversation_sync"] limit = self.config["bridge.initial_conversation_sync"]
self.log.info("Syncing chats") self.log.info("Syncing chats")
@ -144,6 +153,20 @@ class User(DBUser, BaseUser):
await self.send_bridge_notice("Synchronization complete") await self.send_bridge_notice("Synchronization complete")
await self.client.resume() 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: async def stop(self) -> None:
# TODO Notices for shutdown messages # TODO Notices for shutdown messages
if self._connection_check_task: if self._connection_check_task:

2
puppet/.gitignore vendored
View File

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

View File

@ -99,7 +99,7 @@ export default class Client {
} }
sendMessage(message) { sendMessage(message) {
this.log(`Sending message ${message.id} to client`) this.log(`Sending message ${message.id || "with no ID"} to client`)
return this._write({ return this._write({
id: --this.notificationID, id: --this.notificationID,
command: "message", command: "message",
@ -164,7 +164,7 @@ export default class Client {
let started = false let started = false
if (this.puppet === null) { if (this.puppet === null) {
this.log("Opening new puppeteer for", this.userID) 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) this.manager.puppets.set(this.userID, this.puppet)
await this.puppet.start(!!req.debug) await this.puppet.start(!!req.debug)
started = true started = true
@ -194,11 +194,13 @@ export default class Client {
handleRegister = async (req) => { handleRegister = async (req) => {
this.userID = req.user_id 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)) { if (this.manager.clients.has(this.userID)) {
const oldClient = this.manager.clients.get(this.userID) const oldClient = this.manager.clients.get(this.userID)
this.manager.clients.set(this.userID, this) 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") await oldClient.stop("Socket replaced by new connection")
} else { } else {
this.manager.clients.set(this.userID, this) 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), set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids),
pause: () => this.puppet.stopObserving(), pause: () => this.puppet.stopObserving(),
resume: () => this.puppet.startObserving(), resume: () => this.puppet.startObserving(),
get_own_profile: () => this.puppet.getOwnProfile(),
get_chats: () => this.puppet.getRecentChats(), 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), get_messages: req => this.puppet.getMessages(req.chat_id),
read_image: req => this.puppet.readImage(req.image_url), read_image: req => this.puppet.readImage(req.image_url),
is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }), is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }),

View File

@ -23,7 +23,7 @@
*/ */
window.__chronoParseDate = function (text, ref, option) {} 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>} * @return {Promise<void>}
*/ */
window.__mautrixReceiveChanges = function (changes) {} window.__mautrixReceiveChanges = function (changes) {}
@ -102,9 +102,7 @@ class MautrixController {
} }
setOwnID(ownID) { setOwnID(ownID) {
// Remove characters that will conflict with mxid grammar this.ownID = ownID
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
} }
// TODO Commonize with Node context // TODO Commonize with Node context
@ -527,7 +525,7 @@ class MautrixController {
* @typedef PathImage * @typedef PathImage
* @type object * @type object
* @property {?string} path - The virtual path of the image (behaves like an ID). Optional. * @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. * May be prefixed by sender name.
* @property {string} lastMsgDate - An imprecise date for the most recent message * @property {string} lastMsgDate - An imprecise date for the most recent message
* (e.g. "7:16 PM", "Thu" or "Aug 4") * (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) { getChatListItemID(element) {
@ -624,13 +624,17 @@ class MautrixController {
} }
getChatListItemLastMsg(element) { getChatListItemLastMsg(element) {
return element.querySelector(".mdCMN04Desc").innerText return element.querySelector(".mdCMN04Desc").innerHTML
} }
getChatListItemLastMsgDate(element) { getChatListItemLastMsgDate(element) {
return element.querySelector("time").innerText return element.querySelector("time").innerText
} }
getChatListItemNotificationCount(element) {
return Number.parseInt(element.querySelector(".MdIcoBadge01:not(.MdNonDisp)")?.innerText) || 0
}
/** /**
* Parse a conversation list item element. * Parse a conversation list item element.
* *
@ -645,6 +649,7 @@ class MautrixController {
icon: this.getChatListItemIcon(element), icon: this.getChatListItemIcon(element),
lastMsg: this.getChatListItemLastMsg(element), lastMsg: this.getChatListItemLastMsg(element),
lastMsgDate: this.getChatListItemLastMsgDate(element), lastMsgDate: this.getChatListItemLastMsgDate(element),
notificationCount: this.getChatListItemNotificationCount(element),
} }
} }
@ -676,13 +681,56 @@ class MautrixController {
return promise 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 * @param {[MutationRecord]} mutations - The mutation records that occurred
* @private * @private
*/ */
_observeChatListMutations(mutations) { _observeChatListMutations(mutations) {
// TODO Observe *added/removed* chats, not just new messages // TODO Observe *added/removed* chats, not just new messages
const changedChatIDs = new Set() const changedChats = new Set()
for (const change of mutations) { for (const change of mutations) {
if (change.target.id == "_chat_list_body") { if (change.target.id == "_chat_list_body") {
// TODO // TODO
@ -692,26 +740,24 @@ class MautrixController {
for (const node of change.addedNodes) { 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")) { if (change.target.classList.contains("ExSelected")) {
console.debug("Not using chat list mutation response for currently-active chat") console.debug("Not using chat list mutation response for currently-active chat")
continue continue
} }
for (const node of change.addedNodes) { const chat = this.parseChatListItem(change.addedNodes[0])
const chat = this.parseChatListItem(node)
if (chat) { if (chat) {
console.log("Added chat list item:", chat) console.log("Added chat list item:", chat)
changedChatIDs.add(chat.id) changedChats.add(chat)
} else { } else {
console.debug("Could not parse added node as a chat list item:", node) 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. // change.removedNodes tells you which chats that had notifications are now read.
} }
if (changedChatIDs.size > 0) { if (changedChats.size > 0) {
console.debug("Dispatching chat list mutations:", changedChatIDs) console.debug("Dispatching chat list mutations:", changedChats)
window.__mautrixReceiveChanges(Array.from(changedChatIDs)).then( window.__mautrixReceiveChanges(Array.from(changedChats)).then(
() => console.debug("Chat list mutations dispatched"), () => console.debug("Chat list mutations dispatched"),
err => console.error("Error dispatching chat list mutations:", err)) err => console.error("Error dispatching chat list mutations:", err))
} }

View File

@ -27,7 +27,7 @@ export default class MessagesPuppeteer {
static executablePath = undefined static executablePath = undefined
static devtools = false static devtools = false
static noSandbox = false static noSandbox = false
static viewport = { width: 960, height: 880 } static viewport = { width: 960, height: 840 }
static url = undefined static url = undefined
static extensionDir = "extension_files" static extensionDir = "extension_files"
@ -36,16 +36,19 @@ export default class MessagesPuppeteer {
* @param {string} id * @param {string} id
* @param {?Client} [client] * @param {?Client} [client]
*/ */
constructor(id, client = null) { constructor(id, ownID, sendPlaceholders, client = null) {
let profilePath = path.join(MessagesPuppeteer.profileDir, id) let profilePath = path.join(MessagesPuppeteer.profileDir, id)
if (!profilePath.startsWith("/")) { if (!profilePath.startsWith("/")) {
profilePath = path.join(process.cwd(), profilePath) profilePath = path.join(process.cwd(), profilePath)
} }
this.id = id this.id = id
this.ownID = ownID
this.sendPlaceholders = sendPlaceholders
this.profilePath = profilePath this.profilePath = profilePath
this.updatedChats = new Set() this.updatedChats = new Set()
this.sentMessageIDs = new Set() this.sentMessageIDs = new Set()
this.mostRecentMessages = new Map() this.mostRecentMessages = new Map()
this.numChatNotifications = new Map()
this.taskQueue = new TaskQueue(this.id) this.taskQueue = new TaskQueue(this.id)
this.client = client this.client = client
} }
@ -65,18 +68,19 @@ export default class MessagesPuppeteer {
async start() { async start() {
this.log("Launching browser") this.log("Launching browser")
let extensionArgs = [ const args = [
`--disable-extensions-except=${MessagesPuppeteer.extensionDir}`, `--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) { if (MessagesPuppeteer.noSandbox) {
extensionArgs = extensionArgs.concat(`--no-sandbox`) args = args.concat(`--no-sandbox`)
} }
this.browser = await puppeteer.launch({ this.browser = await puppeteer.launch({
executablePath: MessagesPuppeteer.executablePath, executablePath: MessagesPuppeteer.executablePath,
userDataDir: this.profilePath, userDataDir: this.profilePath,
args: extensionArgs, args: args,
headless: false, // Needed to load extensions headless: false, // Needed to load extensions
defaultViewport: MessagesPuppeteer.viewport, defaultViewport: MessagesPuppeteer.viewport,
devtools: MessagesPuppeteer.devtools, devtools: MessagesPuppeteer.devtools,
@ -89,6 +93,10 @@ export default class MessagesPuppeteer {
} else { } else {
this.page = await this.browser.newPage() this.page = await this.browser.newPage()
} }
this.blankPage = await this.browser.newPage()
await this.page.bringToFront()
this.log("Opening", MessagesPuppeteer.url) this.log("Opening", MessagesPuppeteer.url)
await this.page.setBypassCSP(true) // Needed to load content scripts await this.page.setBypassCSP(true) // Needed to load content scripts
await this._preparePage(true) await this._preparePage(true)
@ -119,6 +127,7 @@ export default class MessagesPuppeteer {
} }
async _preparePage(navigateTo) { async _preparePage(navigateTo) {
await this.page.bringToFront()
if (navigateTo) { if (navigateTo) {
await this.page.goto(MessagesPuppeteer.url) await this.page.goto(MessagesPuppeteer.url)
} else { } else {
@ -128,6 +137,30 @@ export default class MessagesPuppeteer {
await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" }) 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. * Wait for the session to be logged in and monitor changes while it's not.
*/ */
@ -136,6 +169,7 @@ export default class MessagesPuppeteer {
return return
} }
this.loginRunning = true this.loginRunning = true
await this.page.bringToFront()
const loginContentArea = await this.page.waitForSelector("#login_content") const loginContentArea = await this.page.waitForSelector("#login_content")
@ -219,7 +253,7 @@ export default class MessagesPuppeteer {
this.log("Removing observers") this.log("Removing observers")
// TODO __mautrixController is undefined when cancelling, why? // 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.removeQRChangeObserver())
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
@ -254,15 +288,7 @@ export default class MessagesPuppeteer {
} }
this.loginRunning = false this.loginRunning = false
// Don't start observing yet, instead wait for explicit request. await this.blankPage.bringToFront()
// 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")
}
this.log("Login complete") this.log("Login complete")
} }
@ -376,10 +402,11 @@ export default class MessagesPuppeteer {
* Get info about a chat. * Get info about a chat.
* *
* @param {string} chatID - The chat ID whose info to get. * @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. * @return {Promise<ChatInfo>} - Info about the chat.
*/ */
async getChatInfo(chatID) { async getChatInfo(chatID, forceView) {
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID)) 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. * @return {Promise<[MessageData]>} - The messages visible in the chat.
*/ */
async getMessages(chatID) { async getMessages(chatID) {
return await this.taskQueue.push(async () => this._getMessagesUnsafe(chatID)) return await this.taskQueue.push(() => this._getMessagesUnsafe(chatID))
} }
setLastMessageIDs(ids) { setLastMessageIDs(ids) {
@ -443,11 +470,46 @@ export default class MessagesPuppeteer {
() => window.__mautrixController.removeMsgListObserver()) () => 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) { _listItemSelector(id) {
return `#_chat_list_body div[data-chatid="${id}"]` return `#_chat_list_body div[data-chatid="${id}"]`
} }
async _switchChat(chatID) { async _switchChat(chatID, forceView = false) {
// TODO Allow passing in an element directly // TODO Allow passing in an element directly
this.log(`Switching to chat ${chatID}`) this.log(`Switching to chat ${chatID}`)
const chatListItem = await this.page.$(this._listItemSelector(chatID)) const chatListItem = await this.page.$(this._listItemSelector(chatID))
@ -463,19 +525,40 @@ export default class MessagesPuppeteer {
} }
if (await this.page.evaluate(isCorrectChatVisible, chatName)) { if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
if (!forceView) {
this.log("Already viewing chat, no need to switch") 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 { } else {
this.log("Ensuring msg list observer is removed") this.log("Ensuring msg list observer is removed")
const hadMsgListObserver = await this.page.evaluate( const hadMsgListObserver = await this.page.evaluate(
() => window.__mautrixController.removeMsgListObserver()) () => window.__mautrixController.removeMsgListObserver())
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer") this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
await chatListItem.click() 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}"`) this.log(`Waiting for chat header title to be "${chatName}"`)
await this.page.waitForFunction( await this.page.waitForFunction(
isCorrectChatVisible, isCorrectChatVisible,
{polling: "mutation"}, {polling: "mutation", timeout: 1000},
chatName) 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 // Always show the chat details sidebar, as this makes life easier
this.log("Waiting for detail area to be auto-hidden upon entering chat") this.log("Waiting for detail area to be auto-hidden upon entering chat")
@ -483,10 +566,22 @@ export default class MessagesPuppeteer {
detailArea => detailArea.childElementCount == 0, detailArea => detailArea.childElementCount == 0,
{}, {},
await this.page.$("#_chat_detail_area")) await this.page.$("#_chat_detail_area"))
this.log("Clicking chat header to show detail area") this.log("Clicking chat header to show detail area")
await this.page.click("#_chat_header_area > .mdRGT04Link") await this.page.click("#_chat_header_area > .mdRGT04Link")
this.log("Waiting for detail area") this.log("Waiting for detail area")
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") 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) { if (hadMsgListObserver) {
this.log("Restoring msg list observer") 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), const chatListInfo = await this.page.$eval(this._listItemSelector(chatID),
(element, chatID) => window.__mautrixController.parseChatListItem(element, chatID), (element, chatID) => window.__mautrixController.parseChatListItem(element, chatID),
chatID) chatID)
@ -521,7 +616,7 @@ export default class MessagesPuppeteer {
if (!isDirect) { if (!isDirect) {
this.log("Found multi-user chat, so viewing it to get participants") this.log("Found multi-user chat, so viewing it to get participants")
// TODO This will mark the chat as "read"! // 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") const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
// TODO Is a group not actually created until a message is sent(?) // 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. // 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)) element => window.__mautrixController.parseParticipantList(element))
} else { } else {
this.log(`Found direct chat with ${chatID}`) 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") //const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name //await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{ participants = [{
@ -551,21 +650,27 @@ export default class MessagesPuppeteer {
// Always present, just made visible via classes // Always present, just made visible via classes
async _sendMessageUnsafe(chatID, text) { 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 // TODO Initiate the promise in the content script
await this.page.evaluate( await this.page.evaluate(
() => window.__mautrixController.promiseOwnMessage(5000, "time")) () => window.__mautrixController.promiseOwnMessage(5000, "time"))
const input = await this.page.$("#_chat_room_input") const input = await this.page.$("#_chat_room_input")
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.click()
await input.type(text) await input.evaluate((e, text) => e.innerText = text, text)
await input.press("Enter") await input.press("Enter")
})
return await this._waitForSentMessage(chatID) return await this._waitForSentMessage(chatID)
} }
async _sendFileUnsafe(chatID, filePath) { async _sendFileUnsafe(chatID, filePath) {
await this._switchChat(chatID) this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true)
await this.page.evaluate( await this.page.evaluate(
() => window.__mautrixController.promiseOwnMessage( () => window.__mautrixController.promiseOwnMessage(
10000, // Use longer timeout for file uploads 10000, // Use longer timeout for file uploads
@ -573,6 +678,7 @@ export default class MessagesPuppeteer {
"#_chat_message_fail_menu")) "#_chat_message_fail_menu"))
try { try {
this._interactWithPage(async () => {
this.log(`About to ask for file chooser in ${chatID}`) this.log(`About to ask for file chooser in ${chatID}`)
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
this.page.waitForFileChooser(), this.page.waitForFileChooser(),
@ -580,6 +686,7 @@ export default class MessagesPuppeteer {
]) ])
this.log(`About to upload ${filePath}`) this.log(`About to upload ${filePath}`)
await fileChooser.accept([filePath]) await fileChooser.accept([filePath])
})
} catch (e) { } catch (e) {
this.log(`Failed to upload file to ${chatID}`) this.log(`Failed to upload file to ${chatID}`)
return -1 return -1
@ -604,9 +711,11 @@ export default class MessagesPuppeteer {
} }
} }
async _receiveMessages(chatID, messages) { _receiveMessages(chatID, messages, skipProcessing = false) {
if (this.client) { if (this.client) {
messages = await this._processMessages(chatID, messages) if (!skipProcessing) {
messages = this._processMessages(chatID, messages)
}
for (const message of messages) { for (const message of messages) {
this.client.sendMessage(message).catch(err => 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, "to client:", err))
@ -626,11 +735,13 @@ export default class MessagesPuppeteer {
// TODO Handle unloaded messages. Maybe scroll up // TODO Handle unloaded messages. Maybe scroll up
// TODO This will mark the chat as "read"! // TODO This will mark the chat as "read"!
await this._switchChat(chatID) 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), mostRecentMessage => window.__mautrixController.parseMessageList(mostRecentMessage),
this.mostRecentMessages.get(chatID)) this.mostRecentMessages.get(chatID))
// Doing this before restoring the observer since it updates minID // Doing this before restoring the observer since it updates minID
const filteredMessages = await this._processMessages(chatID, messages) messages = this._processMessages(chatID, messages)
if (hadMsgListObserver) { if (hadMsgListObserver) {
this.log("Restoring msg list observer") 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") 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 // TODO Probably don't need minID filtering if Puppeteer context handles it now
const minID = this.mostRecentMessages.get(chatID) || 0 const minID = this.mostRecentMessages.get(chatID) || 0
const filteredMessages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id)) 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) this.mostRecentMessages.set(chatID, newLastID)
const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}` const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}`
this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filteredMessages.length} newer than ${minID} (${range})`) this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filteredMessages.length} newer than ${minID} (${range})`)
for (const message of filteredMessages) { for (const message of filteredMessages) {
message.chat_id = chatID 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.updatedChats.delete(chatID)
this.log("Processing change to", chatID) this.log("Processing change to", chatID)
const messages = await this._getMessagesUnsafe(chatID) // TODO Also process name/icon changes
if (messages.length === 0) {
this.log("No new messages found in", chatID) 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 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) { if (this.client) {
for (const message of messages) { for (const message of messages) {
await this.client.sendMessage(message).catch(err => 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 { } else {
this.log("No client connected, not sending messages") this.log("No client connected, not sending messages")
@ -685,10 +838,10 @@ export default class MessagesPuppeteer {
} }
_receiveChatListChanges(changes) { _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) { for (const item of changes) {
if (!this.updatedChats.has(item)) { if (!this.updatedChats.has(item.id)) {
this.updatedChats.add(item) this.updatedChats.add(item.id)
this.taskQueue.push(() => this._processChatListChangeUnsafe(item)) this.taskQueue.push(() => this._processChatListChangeUnsafe(item))
.catch(err => this.error("Error handling chat list changes:", err)) .catch(err => this.error("Error handling chat list changes:", err))
} }
@ -724,18 +877,8 @@ export default class MessagesPuppeteer {
async _sendEmailCredentials() { async _sendEmailCredentials() {
this.log("Inputting login credentials") this.log("Inputting login credentials")
await this._enterText(await this.page.$("#line_login_email"), this.login_email)
// Triple-click input fields to select all existing text and replace it on type await this._enterText(await this.page.$("#line_login_pwd"), this.login_password)
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.page.click("button#login_btn") await this.page.click("button#login_btn")
} }
@ -781,6 +924,7 @@ export default class MessagesPuppeteer {
_onLoggedOut() { _onLoggedOut() {
this.log("Got logged out!") this.log("Got logged out!")
this.stopObserving() this.stopObserving()
this.page.bringToFront()
if (this.client) { if (this.client) {
this.client.sendLoggedOut().catch(err => this.client.sendLoggedOut().catch(err =>
this.error("Failed to send logout notice to client:", err)) this.error("Failed to send logout notice to client:", err))