Compare commits

..

No commits in common. "968832545ed5bb51bccd423be720d9826055de1b" and "9d4f2dafac6c7ab0cfcecb1e9c27f8c07012d88b" have entirely different histories.

14 changed files with 157 additions and 486 deletions

6
.gitignore vendored
View File

@ -1,6 +1,4 @@
/.idea/ /.idea/
/.*project
/.settings/
/.venv /.venv
/env/ /env/
@ -15,8 +13,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,18 +29,20 @@ class Message:
mxid: EventID mxid: EventID
mx_room: RoomID mx_room: RoomID
mid: Optional[int] mid: 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 update_ids(self, new_mxid: EventID, new_mid: int) -> None: async def delete(self) -> None:
q = ("UPDATE message SET mxid=$1, mid=$2 " q = "DELETE FROM message WHERE mid=$1"
"WHERE mxid=$3 AND mx_room=$4 AND chat_id=$5") await self.db.execute(q, self.mid)
await self.db.execute(q, new_mxid, new_mid,
self.mxid, self.mx_room, self.chat_id) @classmethod
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:
@ -55,23 +57,6 @@ 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 "
@ -87,18 +72,3 @@ 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

@ -128,11 +128,4 @@ async def upgrade_strangers(conn: Connection) -> None:
FOREIGN KEY (fake_mid) FOREIGN KEY (fake_mid)
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,12 +57,6 @@ 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,11 +17,9 @@ 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
@ -37,9 +35,8 @@ 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 isinstance(evt, ReceiptEvent): if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent,
return False RedactionEvent)):
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)
@ -62,16 +59,3 @@ 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,11 +124,6 @@ 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:
@ -168,8 +163,6 @@ 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:
@ -182,18 +175,20 @@ class Portal(DBPortal, BasePortal):
if not msg and self.config["bridge.delivery_error_reports"]: if not msg and self.config["bridge.delivery_error_reports"]:
await self.main_intent.send_notice( await self.main_intent.send_notice(
self.mxid, self.mxid,
"Posting this message to LINE may have failed.", "Posting this message to LINE may have failed.",
relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id)) relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id))
async def handle_matrix_leave(self, user: 'u.User') -> None: async def handle_matrix_leave(self, user: 'u.User') -> None:
self.log.info(f"{user.mxid} left portal to {self.chat_id}, " self.log.info(f"{user.mxid} left portal to {self.chat_id}, "
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', puppet: Optional['p.Puppet'], mid: str, async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
invite: bool = True) -> Optional[IntentAPI]: invite: bool = True) -> Optional[IntentAPI]:
intent = puppet.intent if puppet else (await source.get_own_puppet()).intent # Use bridge bot as puppet for own user when puppet for own user is unavailable
if self.is_direct and (not puppet or puppet.mid == source.mid and not puppet.is_real_user): # 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):
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)
@ -218,55 +213,29 @@ 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
puppet = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None
intent = await self._bridge_own_message_pm(source, puppet, f"message {evt.id}") intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
if not intent: if not intent:
return return
else: else:
if self.is_direct: 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 # TODO Respond to name/avatar changes of users in a DM
intent = (await p.Puppet.get_by_mid(self.other_user)).intent if not self.is_direct:
elif evt.sender: if sender:
puppet = await p.Puppet.get_by_mid(evt.sender.id) await sender.update_info(evt.sender, source.client)
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 message {evt.id or 'with no ID'}") self.log.warning(f"Could not find ID of LINE user who sent event {evt.id}")
puppet = await p.Puppet.get_by_profile(evt.sender, source.client) sender = await p.Puppet.get_by_profile(evt.sender, source.client)
intent = puppet.intent intent = sender.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.id: if evt.image and evt.image.url:
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,
@ -283,11 +252,9 @@ 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
# TODO Element Web messes up text->sticker edits!! if send_sticker:
# File a case on it event_id = await intent.send_sticker(
if send_sticker and not prev_event_id: self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
#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(
@ -299,11 +266,8 @@ 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):
@ -367,24 +331,17 @@ 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)
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
if not msg: try:
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id) await msg.insert()
try: await self._send_delivery_receipt(event_id)
await msg.insert() self.log.debug(f"Handled remote message {evt.id} -> {event_id}")
#await self._send_delivery_receipt(event_id) except UniqueViolationError as e:
self.log.debug(f"Handled remote message {evt.id or 'with no ID'} -> {event_id}") self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {e}")
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: 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)
@ -572,12 +529,11 @@ class Portal(DBPortal, BasePortal):
continue continue
mid = p.Puppet.get_id_from_mxid(user_id) mid = p.Puppet.get_id_from_mxid(user_id)
is_own_puppet = p.Puppet.is_mid_for_own_puppet(mid) if mid and mid not in current_members:
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 is_own_puppet: elif forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(mid):
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")
@ -597,7 +553,6 @@ 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))
@ -605,7 +560,6 @@ 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:
@ -759,6 +713,9 @@ 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, ImageData, Message, Participant, Receipt, StartStatus from .types import ChatListInfo, ChatInfo, Message, Receipt, ImageData, StartStatus
class LoginCommand(TypedDict): class LoginCommand(TypedDict):
@ -41,15 +41,12 @@ 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, force_view: bool = False) -> ChatInfo: async def get_chat(self, chat_id: str) -> ChatInfo:
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id, force_view=force_view)) return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id))
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,7 +33,6 @@ 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
@ -41,12 +40,10 @@ 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, own_id: str, ephemeral_events: bool) -> None: def __init__(self, user_id: UserID) -> 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 = {}
@ -70,10 +67,7 @@ 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", await self.request("register", user_id=self.user_id)
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: Optional[int] id: int
chat_id: int chat_id: int
is_outgoing: bool is_outgoing: bool
sender: Optional[Participant] sender: Optional[Participant]

View File

@ -69,14 +69,6 @@ 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
@ -103,7 +95,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.own_id, self.config["appservice.ephemeral_events"]) self.client = Client(self.mxid)
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()
@ -134,7 +126,6 @@ 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")
@ -153,20 +144,6 @@ 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 || "with no ID"} to client`) this.log(`Sending message ${message.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.ownID, this.sendPlaceholders, this) this.puppet = new MessagesPuppeteer(this.userID, 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,13 +194,11 @@ export default class Client {
handleRegister = async (req) => { handleRegister = async (req) => {
this.userID = req.user_id this.userID = req.user_id
this.ownID = req.own_id this.log("Registered socket", this.connID, "->", this.userID)
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)
@ -260,9 +258,8 @@ 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, req.force_view), get_chat: req => this.puppet.getChatInfo(req.chat_id),
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 {ChatListInfo[]} changes - The chats that changed. * @param {string[]} changes - The hrefs of the chats that changed.
* @return {Promise<void>} * @return {Promise<void>}
*/ */
window.__mautrixReceiveChanges = function (changes) {} window.__mautrixReceiveChanges = function (changes) {}
@ -102,7 +102,9 @@ class MautrixController {
} }
setOwnID(ownID) { setOwnID(ownID) {
this.ownID = ownID // Remove characters that will conflict with mxid grammar
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
} }
// TODO Commonize with Node context // TODO Commonize with Node context
@ -525,7 +527,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} url - The URL of the image. Mandatory. * @property {string} src - The URL of the image. Mandatory.
*/ */
/** /**
@ -607,8 +609,6 @@ 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,17 +624,13 @@ class MautrixController {
} }
getChatListItemLastMsg(element) { getChatListItemLastMsg(element) {
return element.querySelector(".mdCMN04Desc").innerHTML return element.querySelector(".mdCMN04Desc").innerText
} }
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.
* *
@ -649,7 +645,6 @@ 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),
} }
} }
@ -681,56 +676,13 @@ 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 changedChats = new Set() const changedChatIDs = 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
@ -740,24 +692,26 @@ class MautrixController {
for (const node of change.addedNodes) { for (const node of change.addedNodes) {
} }
*/ */
} else if (change.target.tagName == "LI" && change.addedNodes.length == 1) { } else if (change.target.tagName == "LI") {
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
} }
const chat = this.parseChatListItem(change.addedNodes[0]) for (const node of change.addedNodes) {
if (chat) { const chat = this.parseChatListItem(node)
console.log("Added chat list item:", chat) if (chat) {
changedChats.add(chat) console.log("Added chat list item:", chat)
} else { changedChatIDs.add(chat.id)
console.debug("Could not parse added node as a chat list item:", node) } 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. // change.removedNodes tells you which chats that had notifications are now read.
} }
if (changedChats.size > 0) { if (changedChatIDs.size > 0) {
console.debug("Dispatching chat list mutations:", changedChats) console.debug("Dispatching chat list mutations:", changedChatIDs)
window.__mautrixReceiveChanges(Array.from(changedChats)).then( window.__mautrixReceiveChanges(Array.from(changedChatIDs)).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: 840 } static viewport = { width: 960, height: 880 }
static url = undefined static url = undefined
static extensionDir = "extension_files" static extensionDir = "extension_files"
@ -36,19 +36,16 @@ export default class MessagesPuppeteer {
* @param {string} id * @param {string} id
* @param {?Client} [client] * @param {?Client} [client]
*/ */
constructor(id, ownID, sendPlaceholders, client = null) { constructor(id, 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
} }
@ -68,19 +65,18 @@ export default class MessagesPuppeteer {
async start() { async start() {
this.log("Launching browser") this.log("Launching browser")
const args = [ let extensionArgs = [
`--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) {
args = args.concat(`--no-sandbox`) extensionArgs = extensionArgs.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: args, args: extensionArgs,
headless: false, // Needed to load extensions headless: false, // Needed to load extensions
defaultViewport: MessagesPuppeteer.viewport, defaultViewport: MessagesPuppeteer.viewport,
devtools: MessagesPuppeteer.devtools, devtools: MessagesPuppeteer.devtools,
@ -93,10 +89,6 @@ 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)
@ -127,7 +119,6 @@ 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 {
@ -137,30 +128,6 @@ 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.
*/ */
@ -169,7 +136,6 @@ 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")
@ -253,7 +219,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.ownID) await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id)
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())
@ -288,7 +254,15 @@ export default class MessagesPuppeteer {
} }
this.loginRunning = false this.loginRunning = false
await this.blankPage.bringToFront() // 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")
}
this.log("Login complete") this.log("Login complete")
} }
@ -402,11 +376,10 @@ 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, forceView) { async getChatInfo(chatID) {
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID, forceView)) return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID))
} }
/** /**
@ -427,7 +400,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(() => this._getMessagesUnsafe(chatID)) return await this.taskQueue.push(async () => this._getMessagesUnsafe(chatID))
} }
setLastMessageIDs(ids) { setLastMessageIDs(ids) {
@ -470,46 +443,11 @@ 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, forceView = false) { async _switchChat(chatID) {
// 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))
@ -525,63 +463,30 @@ 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 this._interactWithPage(async () => { await chatListItem.click()
let numTries = 3 this.log(`Waiting for chat header title to be "${chatName}"`)
while (true) { await this.page.waitForFunction(
try { isCorrectChatVisible,
this.log("Clicking chat list item") {polling: "mutation"},
chatListItem.click() chatName)
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 // 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")
await this.page.waitForFunction( await this.page.waitForFunction(
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")
@ -594,7 +499,7 @@ export default class MessagesPuppeteer {
} }
} }
async _getChatInfoUnsafe(chatID, forceView) { async _getChatInfoUnsafe(chatID) {
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)
@ -616,7 +521,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, forceView) await this._switchChat(chatID)
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.
@ -624,10 +529,6 @@ 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 = [{
@ -650,27 +551,21 @@ 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) {
// Sync all messages in this chat first await this._switchChat(chatID)
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 () => { await input.click()
// Live-typing in the field can have its text mismatch what was requested!! await input.type(text)
// Probably because the input element is a div instead of a real text input...ugh! await input.press("Enter")
// 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) return await this._waitForSentMessage(chatID)
} }
async _sendFileUnsafe(chatID, filePath) { async _sendFileUnsafe(chatID, filePath) {
this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true) await this._switchChat(chatID)
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
@ -678,15 +573,13 @@ 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(), this.page.click("#_chat_room_plus_btn")
this.page.click("#_chat_room_plus_btn") ])
]) 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
@ -711,11 +604,9 @@ export default class MessagesPuppeteer {
} }
} }
_receiveMessages(chatID, messages, skipProcessing = false) { async _receiveMessages(chatID, messages) {
if (this.client) { if (this.client) {
if (!skipProcessing) { messages = await this._processMessages(chatID, messages)
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))
@ -735,13 +626,11 @@ 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)
// TODO Is it better to reset the notification count in _switchChat instead of here? const messages = await this.page.evaluate(
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
messages = this._processMessages(chatID, messages) const filteredMessages = await this._processMessages(chatID, messages)
if (hadMsgListObserver) { if (hadMsgListObserver) {
this.log("Restoring msg list observer") this.log("Restoring msg list observer")
@ -752,10 +641,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 messages return filteredMessages
} }
_processMessages(chatID, messages) { async _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))
@ -766,6 +655,7 @@ 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
} }
@ -775,62 +665,19 @@ export default class MessagesPuppeteer {
} }
} }
async _processChatListChangeUnsafe(chatListInfo) { async _processChatListChangeUnsafe(chatID) {
const chatID = chatListInfo.id
this.updatedChats.delete(chatID) this.updatedChats.delete(chatID)
this.log("Processing change to", chatID) this.log("Processing change to", chatID)
// TODO Also process name/icon changes const messages = await this._getMessagesUnsafe(chatID)
if (messages.length === 0) {
const prevNumNotifications = this.numChatNotifications.get(chatID) || 0 this.log("No new messages found in", chatID)
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 || "with no ID", "to client:", err)) this.error("Failed to send message", message.id, "to client:", err))
} }
} else { } else {
this.log("No client connected, not sending messages") this.log("No client connected, not sending messages")
@ -838,10 +685,10 @@ export default class MessagesPuppeteer {
} }
_receiveChatListChanges(changes) { _receiveChatListChanges(changes) {
this.log(`Received chat list changes: ${changes.map(item => item.id)}`) this.log("Received chat list changes:", changes)
for (const item of changes) { for (const item of changes) {
if (!this.updatedChats.has(item.id)) { if (!this.updatedChats.has(item)) {
this.updatedChats.add(item.id) this.updatedChats.add(item)
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))
} }
@ -877,8 +724,18 @@ 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)
await this._enterText(await this.page.$("#line_login_pwd"), this.login_password) // 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.page.click("button#login_btn") await this.page.click("button#login_btn")
} }
@ -924,7 +781,6 @@ 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))