forked from fair/matrix-puppeteer-line
Compare commits
19 Commits
9d4f2dafac
...
968832545e
Author | SHA1 | Date |
---|---|---|
Cristian Le | 968832545e | |
Cristian Le | 91885245f1 | |
Cristian Le | d93457fcff | |
Cristian Le | eb573a91fd | |
Cristian Le | 9ab94be628 | |
Andrew Ferrazzutti | c124b4c49e | |
Andrew Ferrazzutti | 2fec597cba | |
Andrew Ferrazzutti | 157f18d27f | |
Andrew Ferrazzutti | 2c026939c3 | |
Andrew Ferrazzutti | f05a91e95d | |
Andrew Ferrazzutti | 40a48d12a2 | |
Andrew Ferrazzutti | 1ae30bcf1b | |
Andrew Ferrazzutti | a3e7caac27 | |
Andrew Ferrazzutti | ee5ccf9b2f | |
Andrew Ferrazzutti | 555b19c289 | |
Andrew Ferrazzutti | 0d154c826e | |
Andrew Ferrazzutti | 8fb0e2a101 | |
Andrew Ferrazzutti | 33ca6223c5 | |
Andrew Ferrazzutti | 85dc7a842e |
|
@ -1,4 +1,6 @@
|
|||
/.idea/
|
||||
/.*project
|
||||
/.settings/
|
||||
|
||||
/.venv
|
||||
/env/
|
||||
|
@ -13,8 +15,8 @@ __pycache__
|
|||
profiles
|
||||
puppet/extension_files
|
||||
|
||||
/config.yaml
|
||||
/registration.yaml
|
||||
/config*.yaml
|
||||
/registration*.yaml
|
||||
*.log*
|
||||
*.db
|
||||
*.pickle
|
||||
|
|
|
@ -29,20 +29,18 @@ class Message:
|
|||
|
||||
mxid: EventID
|
||||
mx_room: RoomID
|
||||
mid: int
|
||||
mid: Optional[int]
|
||||
chat_id: str
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = "INSERT INTO message (mxid, mx_room, mid, chat_id) VALUES ($1, $2, $3, $4)"
|
||||
await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id)
|
||||
|
||||
async def delete(self) -> None:
|
||||
q = "DELETE FROM message WHERE mid=$1"
|
||||
await self.db.execute(q, self.mid)
|
||||
|
||||
@classmethod
|
||||
async def delete_all(cls, room_id: RoomID) -> None:
|
||||
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
|
||||
async def update_ids(self, new_mxid: EventID, new_mid: int) -> None:
|
||||
q = ("UPDATE message SET mxid=$1, mid=$2 "
|
||||
"WHERE mxid=$3 AND mx_room=$4 AND chat_id=$5")
|
||||
await self.db.execute(q, new_mxid, new_mid,
|
||||
self.mxid, self.mx_room, self.chat_id)
|
||||
|
||||
@classmethod
|
||||
async def get_max_mid(cls, room_id: RoomID) -> int:
|
||||
|
@ -57,6 +55,23 @@ class Message:
|
|||
data[row["chat_id"]] = row["max_mid"]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
async def get_num_noid_msgs(cls, room_id: RoomID) -> int:
|
||||
return await cls.db.fetchval("SELECT COUNT(*) FROM message "
|
||||
"WHERE mid IS NULL AND mx_room=$1", room_id)
|
||||
|
||||
@classmethod
|
||||
async def is_last_by_mxid(cls, mxid: EventID, room_id: RoomID) -> bool:
|
||||
q = ("SELECT mxid "
|
||||
"FROM message INNER JOIN ( "
|
||||
" SELECT mx_room, MAX(mid) AS max_mid "
|
||||
" FROM message GROUP BY mx_room "
|
||||
") by_room "
|
||||
"ON mid=max_mid "
|
||||
"WHERE by_room.mx_room=$1")
|
||||
last_mxid = await cls.db.fetchval(q, room_id)
|
||||
return last_mxid == mxid
|
||||
|
||||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']:
|
||||
row = await cls.db.fetchrow("SELECT mxid, mx_room, mid, chat_id "
|
||||
|
@ -72,3 +87,18 @@ class Message:
|
|||
if not row:
|
||||
return None
|
||||
return cls(**row)
|
||||
|
||||
@classmethod
|
||||
async def get_next_noid_msg(cls, room_id: RoomID) -> Optional['Message']:
|
||||
row = await cls.db.fetchrow("SELECT mxid, mx_room, mid, chat_id FROM message "
|
||||
"WHERE mid IS NULL AND mx_room=$1", room_id)
|
||||
if not row:
|
||||
return None
|
||||
return cls(**row)
|
||||
|
||||
@classmethod
|
||||
async def delete_all_noid_msgs(cls, room_id: RoomID) -> None:
|
||||
status = await cls.db.execute("DELETE FROM message "
|
||||
"WHERE mid IS NULL AND mx_room=$1", room_id)
|
||||
# Skip leading "DELETE "
|
||||
return int(status[7:])
|
||||
|
|
|
@ -128,4 +128,11 @@ async def upgrade_strangers(conn: Connection) -> None:
|
|||
FOREIGN KEY (fake_mid)
|
||||
REFERENCES puppet (mid)
|
||||
ON DELETE CASCADE
|
||||
)""")
|
||||
)""")
|
||||
|
||||
|
||||
@upgrade_table.register(description="Track messages that lack an ID")
|
||||
async def upgrade_noid_msgs(conn: Connection) -> None:
|
||||
await conn.execute("ALTER TABLE message DROP CONSTRAINT IF EXISTS message_pkey")
|
||||
await conn.execute("ALTER TABLE message ALTER COLUMN mid DROP NOT NULL")
|
||||
await conn.execute("ALTER TABLE message ADD UNIQUE (mid)")
|
|
@ -57,6 +57,12 @@ appservice:
|
|||
as_token: "This value is generated when generating the registration"
|
||||
hs_token: "This value is generated when generating the registration"
|
||||
|
||||
# Whether or not to receive ephemeral events via appservice transactions.
|
||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
||||
# This is REQUIRED in order to bypass Puppeteer needing to "view" a LINE chat
|
||||
# (thus triggering a LINE read receipt on your behalf) to sync its messages.
|
||||
ephemeral_events: false
|
||||
|
||||
# Prometheus telemetry config. Requires prometheus-client to be installed.
|
||||
metrics:
|
||||
enabled: false
|
||||
|
|
|
@ -17,9 +17,11 @@ from typing import TYPE_CHECKING
|
|||
|
||||
from mautrix.bridge import BaseMatrixHandler
|
||||
from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent,
|
||||
ReceiptEvent, SingleReceiptEventContent,
|
||||
EventID, RoomID, UserID)
|
||||
|
||||
from . import portal as po, puppet as pu, user as u
|
||||
from .db import Message as DBMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .__main__ import MessagesBridge
|
||||
|
@ -35,8 +37,9 @@ class MatrixHandler(BaseMatrixHandler):
|
|||
super().__init__(bridge=bridge)
|
||||
|
||||
def filter_matrix_event(self, evt: Event) -> bool:
|
||||
if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent,
|
||||
RedactionEvent)):
|
||||
if isinstance(evt, ReceiptEvent):
|
||||
return False
|
||||
if not isinstance(evt, (MessageEvent, StateEvent, EncryptedEvent)):
|
||||
return True
|
||||
return (evt.sender == self.az.bot_mxid
|
||||
or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
|
||||
|
@ -59,3 +62,16 @@ class MatrixHandler(BaseMatrixHandler):
|
|||
return
|
||||
|
||||
await portal.handle_matrix_leave(user)
|
||||
|
||||
async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
|
||||
data: SingleReceiptEventContent) -> None:
|
||||
# When reading a bridged message, view its chat in LINE, to make it send a read receipt.
|
||||
|
||||
# TODO Use *null* mids for last messages in a chat!!
|
||||
# Only visit a LINE chat when its LAST bridge message has been read,
|
||||
# because LINE lacks per-message read receipts--it's all or nothing!
|
||||
# TODO Also view if message is non-last but for media, so it can be loaded.
|
||||
#if await DBMessage.is_last_by_mxid(event_id, portal.mxid):
|
||||
|
||||
# Viewing a chat by updating it whole-hog, lest a ninja arrives
|
||||
await user.sync_portal(portal)
|
||||
|
|
|
@ -124,6 +124,11 @@ class Portal(DBPortal, BasePortal):
|
|||
except Exception:
|
||||
self.log.exception("Failed to send delivery receipt for %s", event_id)
|
||||
|
||||
async def _cleanup_noid_msgs(self) -> None:
|
||||
num_noid_msgs = await DBMessage.delete_all_noid_msgs(self.mxid)
|
||||
if num_noid_msgs > 0:
|
||||
self.log.warn(f"Found {num_noid_msgs} messages in chat {self.chat_id} with no ID that could not be matched with a real ID")
|
||||
|
||||
async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent,
|
||||
event_id: EventID) -> None:
|
||||
if not sender.client:
|
||||
|
@ -163,6 +168,8 @@ class Portal(DBPortal, BasePortal):
|
|||
self.log.warning(f"Failed to upload media {event_id} to chat {self.chat_id}: {e}")
|
||||
message_id = -1
|
||||
remove(file_path)
|
||||
|
||||
await self._cleanup_noid_msgs()
|
||||
msg = None
|
||||
if message_id != -1:
|
||||
try:
|
||||
|
@ -175,20 +182,18 @@ class Portal(DBPortal, BasePortal):
|
|||
if not msg and self.config["bridge.delivery_error_reports"]:
|
||||
await self.main_intent.send_notice(
|
||||
self.mxid,
|
||||
"Posting this message to LINE may have failed.",
|
||||
relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id))
|
||||
"Posting this message to LINE may have failed.",
|
||||
relates_to=RelatesTo(rel_type=RelationType.REPLY, event_id=event_id))
|
||||
|
||||
async def handle_matrix_leave(self, user: 'u.User') -> None:
|
||||
self.log.info(f"{user.mxid} left portal to {self.chat_id}, "
|
||||
f"cleaning up and deleting...")
|
||||
await self.cleanup_and_delete()
|
||||
|
||||
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
|
||||
async def _bridge_own_message_pm(self, source: 'u.User', puppet: Optional['p.Puppet'], mid: str,
|
||||
invite: bool = True) -> Optional[IntentAPI]:
|
||||
# Use bridge bot as puppet for own user when puppet for own user is unavailable
|
||||
# TODO Use own LINE puppet instead, and create it if it's not available yet
|
||||
intent = sender.intent if sender else self.az.intent
|
||||
if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user):
|
||||
intent = puppet.intent if puppet else (await source.get_own_puppet()).intent
|
||||
if self.is_direct and (not puppet or puppet.mid == source.mid and not puppet.is_real_user):
|
||||
if self.invite_own_puppet_to_pm and invite:
|
||||
try:
|
||||
await intent.ensure_joined(self.mxid)
|
||||
|
@ -213,29 +218,55 @@ class Portal(DBPortal, BasePortal):
|
|||
|
||||
if evt.is_outgoing:
|
||||
if source.intent:
|
||||
sender = None
|
||||
intent = source.intent
|
||||
else:
|
||||
if not self.invite_own_puppet_to_pm:
|
||||
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
|
||||
return
|
||||
sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None
|
||||
intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
|
||||
puppet = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None
|
||||
intent = await self._bridge_own_message_pm(source, puppet, f"message {evt.id}")
|
||||
if not intent:
|
||||
return
|
||||
else:
|
||||
sender = await p.Puppet.get_by_mid(self.other_user if self.is_direct else evt.sender.id)
|
||||
# TODO Respond to name/avatar changes of users in a DM
|
||||
if not self.is_direct:
|
||||
if sender:
|
||||
await sender.update_info(evt.sender, source.client)
|
||||
if self.is_direct:
|
||||
# TODO Respond to name/avatar changes of users in a DM
|
||||
intent = (await p.Puppet.get_by_mid(self.other_user)).intent
|
||||
elif evt.sender:
|
||||
puppet = await p.Puppet.get_by_mid(evt.sender.id)
|
||||
if puppet:
|
||||
await puppet.update_info(evt.sender, source.client)
|
||||
else:
|
||||
self.log.warning(f"Could not find ID of LINE user who sent event {evt.id}")
|
||||
sender = await p.Puppet.get_by_profile(evt.sender, source.client)
|
||||
intent = sender.intent
|
||||
self.log.warning(f"Could not find ID of LINE user who sent message {evt.id or 'with no ID'}")
|
||||
puppet = await p.Puppet.get_by_profile(evt.sender, source.client)
|
||||
intent = puppet.intent
|
||||
else:
|
||||
self.log.info(f"Using bridgebot for unknown sender of message {evt.id or 'with no ID'}")
|
||||
intent = self.az.intent
|
||||
await intent.ensure_joined(self.mxid)
|
||||
|
||||
if evt.image and evt.image.url:
|
||||
if evt.id:
|
||||
msg = await DBMessage.get_next_noid_msg(self.mxid)
|
||||
if not msg:
|
||||
self.log.info(f"Handling new message {evt.id} in chat {self.mxid}")
|
||||
prev_event_id = None
|
||||
else:
|
||||
self.log.info(f"Handling preseen message {evt.id} in chat {self.mxid}: {msg.mxid}")
|
||||
if not self.is_direct:
|
||||
# Non-DM previews are always sent by bridgebot.
|
||||
# Must delete the bridgebot message and send a new message from the correct puppet.
|
||||
await self.az.intent.redact(self.mxid, msg.mxid, "Found actual sender")
|
||||
prev_event_id = None
|
||||
else:
|
||||
prev_event_id = msg.mxid
|
||||
else:
|
||||
self.log.info(f"Handling new message with no ID in chat {self.mxid}")
|
||||
msg = None
|
||||
prev_event_id = None
|
||||
|
||||
if prev_event_id and evt.html:
|
||||
# No need to update a previewed text message, as their previews are accurate
|
||||
event_id = prev_event_id
|
||||
elif evt.image and evt.image.url:
|
||||
if not evt.image.is_sticker or self.config["bridge.receive_stickers"]:
|
||||
media_info = await self._handle_remote_media(
|
||||
source, intent, evt.image.url,
|
||||
|
@ -252,9 +283,11 @@ class Portal(DBPortal, BasePortal):
|
|||
else:
|
||||
media_info = None
|
||||
send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info
|
||||
if send_sticker:
|
||||
event_id = await intent.send_sticker(
|
||||
self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
|
||||
# TODO Element Web messes up text->sticker edits!!
|
||||
# File a case on it
|
||||
if send_sticker and not prev_event_id:
|
||||
#relates_to = RelatesTo(rel_type=RelationType.REPLACE, event_id=prev_event_id) if prev_event_id else None
|
||||
event_id = await intent.send_sticker(self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
|
||||
else:
|
||||
if media_info:
|
||||
content = MediaMessageEventContent(
|
||||
|
@ -266,8 +299,11 @@ class Portal(DBPortal, BasePortal):
|
|||
content = TextMessageEventContent(
|
||||
msgtype=MessageType.NOTICE,
|
||||
body=f"<{'sticker' if evt.image.is_sticker else 'image'}>")
|
||||
if prev_event_id:
|
||||
content.set_edit(prev_event_id)
|
||||
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
|
||||
elif evt.html and not evt.html.isspace():
|
||||
|
||||
chunks = []
|
||||
|
||||
def handle_data(data):
|
||||
|
@ -331,17 +367,24 @@ class Portal(DBPortal, BasePortal):
|
|||
content = TextMessageEventContent(
|
||||
msgtype=MessageType.NOTICE,
|
||||
body="<Unbridgeable message>")
|
||||
if prev_event_id:
|
||||
content.set_edit(prev_event_id)
|
||||
event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
|
||||
|
||||
if evt.is_outgoing and evt.receipt_count:
|
||||
await self._handle_receipt(event_id, evt.receipt_count)
|
||||
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
|
||||
try:
|
||||
await msg.insert()
|
||||
await self._send_delivery_receipt(event_id)
|
||||
self.log.debug(f"Handled remote message {evt.id} -> {event_id}")
|
||||
except UniqueViolationError as e:
|
||||
self.log.debug(f"Failed to handle remote message {evt.id} -> {event_id}: {e}")
|
||||
|
||||
if not msg:
|
||||
msg = DBMessage(mxid=event_id, mx_room=self.mxid, mid=evt.id, chat_id=self.chat_id)
|
||||
try:
|
||||
await msg.insert()
|
||||
#await self._send_delivery_receipt(event_id)
|
||||
self.log.debug(f"Handled remote message {evt.id or 'with no ID'} -> {event_id}")
|
||||
except UniqueViolationError as e:
|
||||
self.log.debug(f"Failed to handle remote message {evt.id or 'with no ID'} -> {event_id}: {e}")
|
||||
else:
|
||||
await msg.update_ids(new_mxid=event_id, new_mid=evt.id)
|
||||
self.log.debug(f"Handled preseen remote message {evt.id} -> {event_id}")
|
||||
|
||||
async def handle_remote_receipt(self, receipt: Receipt) -> None:
|
||||
msg = await DBMessage.get_by_mid(receipt.id)
|
||||
|
@ -529,11 +572,12 @@ class Portal(DBPortal, BasePortal):
|
|||
continue
|
||||
|
||||
mid = p.Puppet.get_id_from_mxid(user_id)
|
||||
if mid and mid not in current_members:
|
||||
is_own_puppet = p.Puppet.is_mid_for_own_puppet(mid)
|
||||
if mid and mid not in current_members and not is_own_puppet:
|
||||
print(mid)
|
||||
await self.main_intent.kick_user(self.mxid, user_id,
|
||||
reason="User had left this chat")
|
||||
elif forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(mid):
|
||||
elif forbid_own_puppets and is_own_puppet:
|
||||
await self.main_intent.kick_user(self.mxid, user_id,
|
||||
reason="Kicking own puppet")
|
||||
|
||||
|
@ -553,6 +597,7 @@ class Portal(DBPortal, BasePortal):
|
|||
|
||||
if not messages:
|
||||
self.log.debug("Didn't get any entries from server")
|
||||
await self._cleanup_noid_msgs()
|
||||
return
|
||||
|
||||
self.log.debug("Got %d messages from server", len(messages))
|
||||
|
@ -560,6 +605,7 @@ class Portal(DBPortal, BasePortal):
|
|||
for evt in messages:
|
||||
await self.handle_remote_message(source, evt)
|
||||
self.log.info("Backfilled %d messages through %s", len(messages), source.mxid)
|
||||
await self._cleanup_noid_msgs()
|
||||
|
||||
@property
|
||||
def bridge_info_state_key(self) -> str:
|
||||
|
@ -713,9 +759,6 @@ class Portal(DBPortal, BasePortal):
|
|||
self._main_intent = self.az.intent
|
||||
|
||||
async def delete(self) -> None:
|
||||
if self.mxid:
|
||||
# TODO Handle this with db foreign keys instead
|
||||
await DBMessage.delete_all(self.mxid)
|
||||
self.by_chat_id.pop(self.chat_id, None)
|
||||
self.by_mxid.pop(self.mxid, None)
|
||||
await super().delete()
|
||||
|
|
|
@ -19,7 +19,7 @@ from base64 import b64decode
|
|||
import asyncio
|
||||
|
||||
from .rpc import RPCClient
|
||||
from .types import ChatListInfo, ChatInfo, Message, Receipt, ImageData, StartStatus
|
||||
from .types import ChatListInfo, ChatInfo, ImageData, Message, Participant, Receipt, StartStatus
|
||||
|
||||
|
||||
class LoginCommand(TypedDict):
|
||||
|
@ -41,12 +41,15 @@ class Client(RPCClient):
|
|||
async def resume(self) -> None:
|
||||
await self.request("resume")
|
||||
|
||||
async def get_own_profile(self) -> Participant:
|
||||
return Participant.deserialize(await self.request("get_own_profile"))
|
||||
|
||||
async def get_chats(self) -> List[ChatListInfo]:
|
||||
resp = await self.request("get_chats")
|
||||
return [ChatListInfo.deserialize(data) for data in resp]
|
||||
|
||||
async def get_chat(self, chat_id: str) -> ChatInfo:
|
||||
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id))
|
||||
async def get_chat(self, chat_id: str, force_view: bool = False) -> ChatInfo:
|
||||
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id, force_view=force_view))
|
||||
|
||||
async def get_messages(self, chat_id: str) -> List[Message]:
|
||||
resp = await self.request("get_messages", chat_id=chat_id)
|
||||
|
|
|
@ -33,6 +33,7 @@ class RPCClient:
|
|||
log: TraceLogger = logging.getLogger("mau.rpc")
|
||||
|
||||
user_id: UserID
|
||||
ephemeral_events: bool
|
||||
_reader: Optional[asyncio.StreamReader]
|
||||
_writer: Optional[asyncio.StreamWriter]
|
||||
_req_id: int
|
||||
|
@ -40,10 +41,12 @@ class RPCClient:
|
|||
_response_waiters: Dict[int, asyncio.Future]
|
||||
_event_handlers: Dict[str, List[EventHandler]]
|
||||
|
||||
def __init__(self, user_id: UserID) -> None:
|
||||
def __init__(self, user_id: UserID, own_id: str, ephemeral_events: bool) -> None:
|
||||
self.log = self.log.getChild(user_id)
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.user_id = user_id
|
||||
self.own_id = own_id
|
||||
self.ephemeral_events = ephemeral_events
|
||||
self._req_id = 0
|
||||
self._min_broadcast_id = 0
|
||||
self._event_handlers = {}
|
||||
|
@ -67,7 +70,10 @@ class RPCClient:
|
|||
self._writer = w
|
||||
self.loop.create_task(self._try_read_loop())
|
||||
self.loop.create_task(self._command_loop())
|
||||
await self.request("register", user_id=self.user_id)
|
||||
await self.request("register",
|
||||
user_id=self.user_id,
|
||||
own_id = self.own_id,
|
||||
ephemeral_events=self.ephemeral_events)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._writer.write_eof()
|
||||
|
|
|
@ -60,7 +60,7 @@ class MessageImage(SerializableAttrs['MessageImage']):
|
|||
|
||||
@dataclass
|
||||
class Message(SerializableAttrs['Message']):
|
||||
id: int
|
||||
id: Optional[int]
|
||||
chat_id: int
|
||||
is_outgoing: bool
|
||||
sender: Optional[Participant]
|
||||
|
|
|
@ -69,6 +69,14 @@ class User(DBUser, BaseUser):
|
|||
self.log.debug(f"Sending bridge notice: {text}")
|
||||
await self.az.intent.send_notice(self.notice_room, text)
|
||||
|
||||
@property
|
||||
def own_id(self) -> str:
|
||||
# Remove characters that will conflict with mxid grammar
|
||||
return f"_OWN_{self.mxid[1:].replace(':', '_ON_')}"
|
||||
|
||||
async def get_own_puppet(self) -> 'pu.Puppet':
|
||||
return await pu.Puppet.get_by_mid(self.own_id)
|
||||
|
||||
async def is_logged_in(self) -> bool:
|
||||
try:
|
||||
return self.client and (await self.client.start()).is_logged_in
|
||||
|
@ -95,7 +103,7 @@ class User(DBUser, BaseUser):
|
|||
|
||||
async def connect(self) -> None:
|
||||
self.loop.create_task(self.connect_double_puppet())
|
||||
self.client = Client(self.mxid)
|
||||
self.client = Client(self.mxid, self.own_id, self.config["appservice.ephemeral_events"])
|
||||
self.log.debug("Starting client")
|
||||
await self.send_bridge_notice("Starting up...")
|
||||
state = await self.client.start()
|
||||
|
@ -126,6 +134,7 @@ class User(DBUser, BaseUser):
|
|||
self._connection_check_task.cancel()
|
||||
self._connection_check_task = self.loop.create_task(self._check_connection_loop())
|
||||
await self.client.pause()
|
||||
await self.sync_own_profile()
|
||||
await self.client.set_last_message_ids(await DBMessage.get_max_mids())
|
||||
limit = self.config["bridge.initial_conversation_sync"]
|
||||
self.log.info("Syncing chats")
|
||||
|
@ -144,6 +153,20 @@ class User(DBUser, BaseUser):
|
|||
await self.send_bridge_notice("Synchronization complete")
|
||||
await self.client.resume()
|
||||
|
||||
async def sync_portal(self, portal: 'po.Portal') -> None:
|
||||
chat_id = portal.chat_id
|
||||
self.log.info(f"Viewing (and syncing) chat {chat_id}")
|
||||
await self.client.pause()
|
||||
chat = await self.client.get_chat(chat_id, True)
|
||||
await portal.update_matrix_room(self, chat)
|
||||
await self.client.resume()
|
||||
|
||||
async def sync_own_profile(self) -> None:
|
||||
self.log.info("Syncing own LINE profile info")
|
||||
own_profile = await self.client.get_own_profile()
|
||||
puppet = await self.get_own_puppet()
|
||||
await puppet.update_info(own_profile, self.client)
|
||||
|
||||
async def stop(self) -> None:
|
||||
# TODO Notices for shutdown messages
|
||||
if self._connection_check_task:
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
/node_modules
|
||||
/config.json
|
||||
/config*.json
|
||||
|
|
|
@ -99,7 +99,7 @@ export default class Client {
|
|||
}
|
||||
|
||||
sendMessage(message) {
|
||||
this.log(`Sending message ${message.id} to client`)
|
||||
this.log(`Sending message ${message.id || "with no ID"} to client`)
|
||||
return this._write({
|
||||
id: --this.notificationID,
|
||||
command: "message",
|
||||
|
@ -164,7 +164,7 @@ export default class Client {
|
|||
let started = false
|
||||
if (this.puppet === null) {
|
||||
this.log("Opening new puppeteer for", this.userID)
|
||||
this.puppet = new MessagesPuppeteer(this.userID, this)
|
||||
this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this.sendPlaceholders, this)
|
||||
this.manager.puppets.set(this.userID, this.puppet)
|
||||
await this.puppet.start(!!req.debug)
|
||||
started = true
|
||||
|
@ -194,11 +194,13 @@ export default class Client {
|
|||
|
||||
handleRegister = async (req) => {
|
||||
this.userID = req.user_id
|
||||
this.log("Registered socket", this.connID, "->", this.userID)
|
||||
this.ownID = req.own_id
|
||||
this.sendPlaceholders = req.ephemeral_events
|
||||
this.log(`Registered socket ${this.connID} -> ${this.userID}${!this.sendPlaceholders ? "" : " (with placeholder message support)"}`)
|
||||
if (this.manager.clients.has(this.userID)) {
|
||||
const oldClient = this.manager.clients.get(this.userID)
|
||||
this.manager.clients.set(this.userID, this)
|
||||
this.log("Terminating previous socket", oldClient.connID, "for", this.userID)
|
||||
this.log(`Terminating previous socket ${oldClient.connID} for ${this.userID}`)
|
||||
await oldClient.stop("Socket replaced by new connection")
|
||||
} else {
|
||||
this.manager.clients.set(this.userID, this)
|
||||
|
@ -258,8 +260,9 @@ export default class Client {
|
|||
set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids),
|
||||
pause: () => this.puppet.stopObserving(),
|
||||
resume: () => this.puppet.startObserving(),
|
||||
get_own_profile: () => this.puppet.getOwnProfile(),
|
||||
get_chats: () => this.puppet.getRecentChats(),
|
||||
get_chat: req => this.puppet.getChatInfo(req.chat_id),
|
||||
get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view),
|
||||
get_messages: req => this.puppet.getMessages(req.chat_id),
|
||||
read_image: req => this.puppet.readImage(req.image_url),
|
||||
is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }),
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
*/
|
||||
window.__chronoParseDate = function (text, ref, option) {}
|
||||
/**
|
||||
* @param {string[]} changes - The hrefs of the chats that changed.
|
||||
* @param {ChatListInfo[]} changes - The chats that changed.
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
window.__mautrixReceiveChanges = function (changes) {}
|
||||
|
@ -102,9 +102,7 @@ class MautrixController {
|
|||
}
|
||||
|
||||
setOwnID(ownID) {
|
||||
// Remove characters that will conflict with mxid grammar
|
||||
const suffix = ownID.slice(1).replace(":", "_ON_")
|
||||
this.ownID = `_OWN_${suffix}`
|
||||
this.ownID = ownID
|
||||
}
|
||||
|
||||
// TODO Commonize with Node context
|
||||
|
@ -527,7 +525,7 @@ class MautrixController {
|
|||
* @typedef PathImage
|
||||
* @type object
|
||||
* @property {?string} path - The virtual path of the image (behaves like an ID). Optional.
|
||||
* @property {string} src - The URL of the image. Mandatory.
|
||||
* @property {string} url - The URL of the image. Mandatory.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -609,6 +607,8 @@ class MautrixController {
|
|||
* May be prefixed by sender name.
|
||||
* @property {string} lastMsgDate - An imprecise date for the most recent message
|
||||
* (e.g. "7:16 PM", "Thu" or "Aug 4")
|
||||
* @property {number} notificationCount - The number of unread messages in the chat,
|
||||
* signified by the number in its notification badge.
|
||||
*/
|
||||
|
||||
getChatListItemID(element) {
|
||||
|
@ -624,13 +624,17 @@ class MautrixController {
|
|||
}
|
||||
|
||||
getChatListItemLastMsg(element) {
|
||||
return element.querySelector(".mdCMN04Desc").innerText
|
||||
return element.querySelector(".mdCMN04Desc").innerHTML
|
||||
}
|
||||
|
||||
getChatListItemLastMsgDate(element) {
|
||||
return element.querySelector("time").innerText
|
||||
}
|
||||
|
||||
getChatListItemNotificationCount(element) {
|
||||
return Number.parseInt(element.querySelector(".MdIcoBadge01:not(.MdNonDisp)")?.innerText) || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a conversation list item element.
|
||||
*
|
||||
|
@ -645,6 +649,7 @@ class MautrixController {
|
|||
icon: this.getChatListItemIcon(element),
|
||||
lastMsg: this.getChatListItemLastMsg(element),
|
||||
lastMsgDate: this.getChatListItemLastMsgDate(element),
|
||||
notificationCount: this.getChatListItemNotificationCount(element),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -676,13 +681,56 @@ class MautrixController {
|
|||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for updates to the active chat's message list to settle down.
|
||||
* Wait an additional bit of time every time an update is observed.
|
||||
* TODO Look (harder) for an explicit signal of when a chat is fully updated...
|
||||
*
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
waitForMessageListStability() {
|
||||
// Increase this if messages get missed on sync / chat change.
|
||||
// Decrease it if response times are too slow.
|
||||
const delayMillis = 500
|
||||
|
||||
let myResolve
|
||||
const promise = new Promise(resolve => {myResolve = resolve})
|
||||
|
||||
let observer
|
||||
const onTimeout = () => {
|
||||
console.log("Message list looks stable, continue")
|
||||
console.debug(`timeoutID = ${timeoutID}`)
|
||||
observer.disconnect()
|
||||
myResolve()
|
||||
}
|
||||
|
||||
let timeoutID
|
||||
const startTimer = () => {
|
||||
timeoutID = setTimeout(onTimeout, delayMillis)
|
||||
}
|
||||
|
||||
observer = new MutationObserver(changes => {
|
||||
clearTimeout(timeoutID)
|
||||
console.log("CHANGE to message list detected! Wait a bit longer...")
|
||||
console.debug(`timeoutID = ${timeoutID}`)
|
||||
console.debug(changes)
|
||||
startTimer()
|
||||
})
|
||||
observer.observe(
|
||||
document.querySelector("#_chat_message_area"),
|
||||
{childList: true, attributes: true, subtree: true})
|
||||
startTimer()
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {[MutationRecord]} mutations - The mutation records that occurred
|
||||
* @private
|
||||
*/
|
||||
_observeChatListMutations(mutations) {
|
||||
// TODO Observe *added/removed* chats, not just new messages
|
||||
const changedChatIDs = new Set()
|
||||
const changedChats = new Set()
|
||||
for (const change of mutations) {
|
||||
if (change.target.id == "_chat_list_body") {
|
||||
// TODO
|
||||
|
@ -692,26 +740,24 @@ class MautrixController {
|
|||
for (const node of change.addedNodes) {
|
||||
}
|
||||
*/
|
||||
} else if (change.target.tagName == "LI") {
|
||||
} else if (change.target.tagName == "LI" && change.addedNodes.length == 1) {
|
||||
if (change.target.classList.contains("ExSelected")) {
|
||||
console.debug("Not using chat list mutation response for currently-active chat")
|
||||
continue
|
||||
}
|
||||
for (const node of change.addedNodes) {
|
||||
const chat = this.parseChatListItem(node)
|
||||
if (chat) {
|
||||
console.log("Added chat list item:", chat)
|
||||
changedChatIDs.add(chat.id)
|
||||
} else {
|
||||
console.debug("Could not parse added node as a chat list item:", node)
|
||||
}
|
||||
const chat = this.parseChatListItem(change.addedNodes[0])
|
||||
if (chat) {
|
||||
console.log("Added chat list item:", chat)
|
||||
changedChats.add(chat)
|
||||
} else {
|
||||
console.debug("Could not parse added node as a chat list item:", node)
|
||||
}
|
||||
}
|
||||
// change.removedNodes tells you which chats that had notifications are now read.
|
||||
}
|
||||
if (changedChatIDs.size > 0) {
|
||||
console.debug("Dispatching chat list mutations:", changedChatIDs)
|
||||
window.__mautrixReceiveChanges(Array.from(changedChatIDs)).then(
|
||||
if (changedChats.size > 0) {
|
||||
console.debug("Dispatching chat list mutations:", changedChats)
|
||||
window.__mautrixReceiveChanges(Array.from(changedChats)).then(
|
||||
() => console.debug("Chat list mutations dispatched"),
|
||||
err => console.error("Error dispatching chat list mutations:", err))
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export default class MessagesPuppeteer {
|
|||
static executablePath = undefined
|
||||
static devtools = false
|
||||
static noSandbox = false
|
||||
static viewport = { width: 960, height: 880 }
|
||||
static viewport = { width: 960, height: 840 }
|
||||
static url = undefined
|
||||
static extensionDir = "extension_files"
|
||||
|
||||
|
@ -36,16 +36,19 @@ export default class MessagesPuppeteer {
|
|||
* @param {string} id
|
||||
* @param {?Client} [client]
|
||||
*/
|
||||
constructor(id, client = null) {
|
||||
constructor(id, ownID, sendPlaceholders, client = null) {
|
||||
let profilePath = path.join(MessagesPuppeteer.profileDir, id)
|
||||
if (!profilePath.startsWith("/")) {
|
||||
profilePath = path.join(process.cwd(), profilePath)
|
||||
}
|
||||
this.id = id
|
||||
this.ownID = ownID
|
||||
this.sendPlaceholders = sendPlaceholders
|
||||
this.profilePath = profilePath
|
||||
this.updatedChats = new Set()
|
||||
this.sentMessageIDs = new Set()
|
||||
this.mostRecentMessages = new Map()
|
||||
this.numChatNotifications = new Map()
|
||||
this.taskQueue = new TaskQueue(this.id)
|
||||
this.client = client
|
||||
}
|
||||
|
@ -65,18 +68,19 @@ export default class MessagesPuppeteer {
|
|||
async start() {
|
||||
this.log("Launching browser")
|
||||
|
||||
let extensionArgs = [
|
||||
const args = [
|
||||
`--disable-extensions-except=${MessagesPuppeteer.extensionDir}`,
|
||||
`--load-extension=${MessagesPuppeteer.extensionDir}`
|
||||
`--load-extension=${MessagesPuppeteer.extensionDir}`,
|
||||
`--window-size=${MessagesPuppeteer.viewport.width},${MessagesPuppeteer.viewport.height+120}`,
|
||||
]
|
||||
if (MessagesPuppeteer.noSandbox) {
|
||||
extensionArgs = extensionArgs.concat(`--no-sandbox`)
|
||||
args = args.concat(`--no-sandbox`)
|
||||
}
|
||||
|
||||
this.browser = await puppeteer.launch({
|
||||
executablePath: MessagesPuppeteer.executablePath,
|
||||
userDataDir: this.profilePath,
|
||||
args: extensionArgs,
|
||||
args: args,
|
||||
headless: false, // Needed to load extensions
|
||||
defaultViewport: MessagesPuppeteer.viewport,
|
||||
devtools: MessagesPuppeteer.devtools,
|
||||
|
@ -89,6 +93,10 @@ export default class MessagesPuppeteer {
|
|||
} else {
|
||||
this.page = await this.browser.newPage()
|
||||
}
|
||||
|
||||
this.blankPage = await this.browser.newPage()
|
||||
await this.page.bringToFront()
|
||||
|
||||
this.log("Opening", MessagesPuppeteer.url)
|
||||
await this.page.setBypassCSP(true) // Needed to load content scripts
|
||||
await this._preparePage(true)
|
||||
|
@ -119,6 +127,7 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
|
||||
async _preparePage(navigateTo) {
|
||||
await this.page.bringToFront()
|
||||
if (navigateTo) {
|
||||
await this.page.goto(MessagesPuppeteer.url)
|
||||
} else {
|
||||
|
@ -128,6 +137,30 @@ export default class MessagesPuppeteer {
|
|||
await this.page.addScriptTag({ path: "./src/contentscript.js", type: "module" })
|
||||
}
|
||||
|
||||
async _interactWithPage(promiser) {
|
||||
await this.page.bringToFront()
|
||||
try {
|
||||
await promiser()
|
||||
} catch (e) {
|
||||
this.error(`Error while interacting with page: ${e}`)
|
||||
throw e
|
||||
} finally {
|
||||
await this.blankPage.bringToFront()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the contents of a text input field to the given text.
|
||||
* Works by triple-clicking the input field to select all existing text, to replace it on type.
|
||||
*
|
||||
* @param {ElementHandle} inputElement - The input element to type into.
|
||||
* @param {string} text - The text to input.
|
||||
*/
|
||||
async _enterText(inputElement, text) {
|
||||
await inputElement.click({clickCount: 3})
|
||||
await inputElement.type(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the session to be logged in and monitor changes while it's not.
|
||||
*/
|
||||
|
@ -136,6 +169,7 @@ export default class MessagesPuppeteer {
|
|||
return
|
||||
}
|
||||
this.loginRunning = true
|
||||
await this.page.bringToFront()
|
||||
|
||||
const loginContentArea = await this.page.waitForSelector("#login_content")
|
||||
|
||||
|
@ -219,7 +253,7 @@ export default class MessagesPuppeteer {
|
|||
|
||||
this.log("Removing observers")
|
||||
// TODO __mautrixController is undefined when cancelling, why?
|
||||
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id)
|
||||
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.ownID)
|
||||
await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver())
|
||||
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
|
||||
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
|
||||
|
@ -254,15 +288,7 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
|
||||
this.loginRunning = false
|
||||
// Don't start observing yet, instead wait for explicit request.
|
||||
// But at least view the most recent chat.
|
||||
try {
|
||||
const mostRecentChatID = await this.page.$eval("#_chat_list_body li",
|
||||
element => window.__mautrixController.getChatListItemID(element.firstElementChild))
|
||||
await this._switchChat(mostRecentChatID)
|
||||
} catch (e) {
|
||||
this.log("No chats available to focus on")
|
||||
}
|
||||
await this.blankPage.bringToFront()
|
||||
this.log("Login complete")
|
||||
}
|
||||
|
||||
|
@ -376,10 +402,11 @@ export default class MessagesPuppeteer {
|
|||
* Get info about a chat.
|
||||
*
|
||||
* @param {string} chatID - The chat ID whose info to get.
|
||||
* @param {boolean} forceView - Whether the LINE tab should always be viewed, even if the chat is already active.
|
||||
* @return {Promise<ChatInfo>} - Info about the chat.
|
||||
*/
|
||||
async getChatInfo(chatID) {
|
||||
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID))
|
||||
async getChatInfo(chatID, forceView) {
|
||||
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID, forceView))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -400,7 +427,7 @@ export default class MessagesPuppeteer {
|
|||
* @return {Promise<[MessageData]>} - The messages visible in the chat.
|
||||
*/
|
||||
async getMessages(chatID) {
|
||||
return await this.taskQueue.push(async () => this._getMessagesUnsafe(chatID))
|
||||
return await this.taskQueue.push(() => this._getMessagesUnsafe(chatID))
|
||||
}
|
||||
|
||||
setLastMessageIDs(ids) {
|
||||
|
@ -443,11 +470,46 @@ export default class MessagesPuppeteer {
|
|||
() => window.__mautrixController.removeMsgListObserver())
|
||||
}
|
||||
|
||||
async getOwnProfile() {
|
||||
return await this.taskQueue.push(() => this._getOwnProfileUnsafe())
|
||||
}
|
||||
|
||||
async _getOwnProfileUnsafe() {
|
||||
// NOTE Will send a read receipt if a chat was in view!
|
||||
// Best to use this on startup when no chat is viewed.
|
||||
let ownProfile
|
||||
await this._interactWithPage(async () => {
|
||||
this.log("Opening settings view")
|
||||
await this.page.click("button.mdGHD01SettingBtn")
|
||||
await this.page.waitForSelector("#context_menu li#settings", {visible: true}).then(e => e.click())
|
||||
await this.page.waitForSelector("#settings_contents", {visible: true})
|
||||
|
||||
this.log("Getting own profile info")
|
||||
ownProfile = {
|
||||
id: this.ownID,
|
||||
name: await this.page.$eval("#settings_basic_name_input", e => e.innerText),
|
||||
avatar: {
|
||||
path: null,
|
||||
url: await this.page.$eval(".mdCMN09ImgInput", e => {
|
||||
const imgStr = e.style?.getPropertyValue("background-image")
|
||||
const matches = imgStr.match(/url\("(blob:.*)"\)/)
|
||||
return matches?.length == 2 ? matches[1] : null
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const backSelector = "#label_setting button"
|
||||
await this.page.click(backSelector)
|
||||
await this.page.waitForSelector(backSelector, {visible: false})
|
||||
})
|
||||
return ownProfile
|
||||
}
|
||||
|
||||
_listItemSelector(id) {
|
||||
return `#_chat_list_body div[data-chatid="${id}"]`
|
||||
}
|
||||
|
||||
async _switchChat(chatID) {
|
||||
async _switchChat(chatID, forceView = false) {
|
||||
// TODO Allow passing in an element directly
|
||||
this.log(`Switching to chat ${chatID}`)
|
||||
const chatListItem = await this.page.$(this._listItemSelector(chatID))
|
||||
|
@ -463,30 +525,63 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
|
||||
if (await this.page.evaluate(isCorrectChatVisible, chatName)) {
|
||||
this.log("Already viewing chat, no need to switch")
|
||||
if (!forceView) {
|
||||
this.log("Already viewing chat, no need to switch")
|
||||
} else {
|
||||
await this._interactWithPage(async () => {
|
||||
this.log("Already viewing chat, but got request to view it")
|
||||
this.page.waitForTimeout(500)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.log("Ensuring msg list observer is removed")
|
||||
const hadMsgListObserver = await this.page.evaluate(
|
||||
() => window.__mautrixController.removeMsgListObserver())
|
||||
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
|
||||
|
||||
await chatListItem.click()
|
||||
this.log(`Waiting for chat header title to be "${chatName}"`)
|
||||
await this.page.waitForFunction(
|
||||
isCorrectChatVisible,
|
||||
{polling: "mutation"},
|
||||
chatName)
|
||||
await this._interactWithPage(async () => {
|
||||
let numTries = 3
|
||||
while (true) {
|
||||
try {
|
||||
this.log("Clicking chat list item")
|
||||
chatListItem.click()
|
||||
this.log(`Waiting for chat header title to be "${chatName}"`)
|
||||
await this.page.waitForFunction(
|
||||
isCorrectChatVisible,
|
||||
{polling: "mutation", timeout: 1000},
|
||||
chatName)
|
||||
break
|
||||
} catch (e) {
|
||||
if (--numTries == 0) {
|
||||
throw e
|
||||
} else {
|
||||
this.log("Clicking chat list item didn't work...try again")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always show the chat details sidebar, as this makes life easier
|
||||
this.log("Waiting for detail area to be auto-hidden upon entering chat")
|
||||
await this.page.waitForFunction(
|
||||
detailArea => detailArea.childElementCount == 0,
|
||||
{},
|
||||
await this.page.$("#_chat_detail_area"))
|
||||
this.log("Clicking chat header to show detail area")
|
||||
await this.page.click("#_chat_header_area > .mdRGT04Link")
|
||||
this.log("Waiting for detail area")
|
||||
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
|
||||
// Always show the chat details sidebar, as this makes life easier
|
||||
this.log("Waiting for detail area to be auto-hidden upon entering chat")
|
||||
await this.page.waitForFunction(
|
||||
detailArea => detailArea.childElementCount == 0,
|
||||
{},
|
||||
await this.page.$("#_chat_detail_area"))
|
||||
|
||||
this.log("Clicking chat header to show detail area")
|
||||
await this.page.click("#_chat_header_area > .mdRGT04Link")
|
||||
this.log("Waiting for detail area")
|
||||
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
|
||||
})
|
||||
|
||||
this.log("Waiting for any item to appear in chat")
|
||||
try {
|
||||
await this.page.waitForSelector("#_chat_room_msg_list div", {timeout: 2000})
|
||||
|
||||
this.log("Waiting for chat to stabilize")
|
||||
await this.page.evaluate(() => window.__mautrixController.waitForMessageListStability())
|
||||
} catch (e) {
|
||||
this.log("No messages in chat found. Maybe no messages were ever sent yet?")
|
||||
}
|
||||
|
||||
if (hadMsgListObserver) {
|
||||
this.log("Restoring msg list observer")
|
||||
|
@ -499,7 +594,7 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
}
|
||||
|
||||
async _getChatInfoUnsafe(chatID) {
|
||||
async _getChatInfoUnsafe(chatID, forceView) {
|
||||
const chatListInfo = await this.page.$eval(this._listItemSelector(chatID),
|
||||
(element, chatID) => window.__mautrixController.parseChatListItem(element, chatID),
|
||||
chatID)
|
||||
|
@ -521,7 +616,7 @@ export default class MessagesPuppeteer {
|
|||
if (!isDirect) {
|
||||
this.log("Found multi-user chat, so viewing it to get participants")
|
||||
// TODO This will mark the chat as "read"!
|
||||
await this._switchChat(chatID)
|
||||
await this._switchChat(chatID, forceView)
|
||||
const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
|
||||
// TODO Is a group not actually created until a message is sent(?)
|
||||
// If so, maybe don't create a portal until there is a message.
|
||||
|
@ -529,6 +624,10 @@ export default class MessagesPuppeteer {
|
|||
element => window.__mautrixController.parseParticipantList(element))
|
||||
} else {
|
||||
this.log(`Found direct chat with ${chatID}`)
|
||||
if (forceView) {
|
||||
this.log("Viewing chat on request")
|
||||
await this._switchChat(chatID, forceView)
|
||||
}
|
||||
//const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
|
||||
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
|
||||
participants = [{
|
||||
|
@ -551,21 +650,27 @@ export default class MessagesPuppeteer {
|
|||
// Always present, just made visible via classes
|
||||
|
||||
async _sendMessageUnsafe(chatID, text) {
|
||||
await this._switchChat(chatID)
|
||||
// Sync all messages in this chat first
|
||||
this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true)
|
||||
// TODO Initiate the promise in the content script
|
||||
await this.page.evaluate(
|
||||
() => window.__mautrixController.promiseOwnMessage(5000, "time"))
|
||||
|
||||
const input = await this.page.$("#_chat_room_input")
|
||||
await input.click()
|
||||
await input.type(text)
|
||||
await input.press("Enter")
|
||||
await this._interactWithPage(async () => {
|
||||
// Live-typing in the field can have its text mismatch what was requested!!
|
||||
// Probably because the input element is a div instead of a real text input...ugh!
|
||||
// Setting its innerText directly works fine though...
|
||||
await input.click()
|
||||
await input.evaluate((e, text) => e.innerText = text, text)
|
||||
await input.press("Enter")
|
||||
})
|
||||
|
||||
return await this._waitForSentMessage(chatID)
|
||||
}
|
||||
|
||||
async _sendFileUnsafe(chatID, filePath) {
|
||||
await this._switchChat(chatID)
|
||||
this._receiveMessages(chatID, await this._getMessagesUnsafe(chatID), true)
|
||||
await this.page.evaluate(
|
||||
() => window.__mautrixController.promiseOwnMessage(
|
||||
10000, // Use longer timeout for file uploads
|
||||
|
@ -573,13 +678,15 @@ export default class MessagesPuppeteer {
|
|||
"#_chat_message_fail_menu"))
|
||||
|
||||
try {
|
||||
this.log(`About to ask for file chooser in ${chatID}`)
|
||||
const [fileChooser] = await Promise.all([
|
||||
this.page.waitForFileChooser(),
|
||||
this.page.click("#_chat_room_plus_btn")
|
||||
])
|
||||
this.log(`About to upload ${filePath}`)
|
||||
await fileChooser.accept([filePath])
|
||||
this._interactWithPage(async () => {
|
||||
this.log(`About to ask for file chooser in ${chatID}`)
|
||||
const [fileChooser] = await Promise.all([
|
||||
this.page.waitForFileChooser(),
|
||||
this.page.click("#_chat_room_plus_btn")
|
||||
])
|
||||
this.log(`About to upload ${filePath}`)
|
||||
await fileChooser.accept([filePath])
|
||||
})
|
||||
} catch (e) {
|
||||
this.log(`Failed to upload file to ${chatID}`)
|
||||
return -1
|
||||
|
@ -604,9 +711,11 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
}
|
||||
|
||||
async _receiveMessages(chatID, messages) {
|
||||
_receiveMessages(chatID, messages, skipProcessing = false) {
|
||||
if (this.client) {
|
||||
messages = await this._processMessages(chatID, messages)
|
||||
if (!skipProcessing) {
|
||||
messages = this._processMessages(chatID, messages)
|
||||
}
|
||||
for (const message of messages) {
|
||||
this.client.sendMessage(message).catch(err =>
|
||||
this.error("Failed to send message", message.id, "to client:", err))
|
||||
|
@ -626,11 +735,13 @@ export default class MessagesPuppeteer {
|
|||
// TODO Handle unloaded messages. Maybe scroll up
|
||||
// TODO This will mark the chat as "read"!
|
||||
await this._switchChat(chatID)
|
||||
const messages = await this.page.evaluate(
|
||||
// TODO Is it better to reset the notification count in _switchChat instead of here?
|
||||
this.numChatNotifications.set(chatID, 0)
|
||||
let messages = await this.page.evaluate(
|
||||
mostRecentMessage => window.__mautrixController.parseMessageList(mostRecentMessage),
|
||||
this.mostRecentMessages.get(chatID))
|
||||
// Doing this before restoring the observer since it updates minID
|
||||
const filteredMessages = await this._processMessages(chatID, messages)
|
||||
messages = this._processMessages(chatID, messages)
|
||||
|
||||
if (hadMsgListObserver) {
|
||||
this.log("Restoring msg list observer")
|
||||
|
@ -641,10 +752,10 @@ export default class MessagesPuppeteer {
|
|||
this.log("Not restoring msg list observer, as there never was one")
|
||||
}
|
||||
|
||||
return filteredMessages
|
||||
return messages
|
||||
}
|
||||
|
||||
async _processMessages(chatID, messages) {
|
||||
_processMessages(chatID, messages) {
|
||||
// TODO Probably don't need minID filtering if Puppeteer context handles it now
|
||||
const minID = this.mostRecentMessages.get(chatID) || 0
|
||||
const filteredMessages = messages.filter(msg => msg.id > minID && !this.sentMessageIDs.has(msg.id))
|
||||
|
@ -655,7 +766,6 @@ export default class MessagesPuppeteer {
|
|||
this.mostRecentMessages.set(chatID, newLastID)
|
||||
const range = newFirstID === newLastID ? newFirstID : `${newFirstID}-${newLastID}`
|
||||
this.log(`Loaded ${messages.length} messages in ${chatID}, got ${filteredMessages.length} newer than ${minID} (${range})`)
|
||||
|
||||
for (const message of filteredMessages) {
|
||||
message.chat_id = chatID
|
||||
}
|
||||
|
@ -665,19 +775,62 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
}
|
||||
|
||||
async _processChatListChangeUnsafe(chatID) {
|
||||
async _processChatListChangeUnsafe(chatListInfo) {
|
||||
const chatID = chatListInfo.id
|
||||
this.updatedChats.delete(chatID)
|
||||
this.log("Processing change to", chatID)
|
||||
const messages = await this._getMessagesUnsafe(chatID)
|
||||
if (messages.length === 0) {
|
||||
this.log("No new messages found in", chatID)
|
||||
// TODO Also process name/icon changes
|
||||
|
||||
const prevNumNotifications = this.numChatNotifications.get(chatID) || 0
|
||||
const diffNumNotifications = chatListInfo.notificationCount - prevNumNotifications
|
||||
|
||||
if (chatListInfo.notificationCount == 0 && diffNumNotifications < 0) {
|
||||
this.log("Notifications dropped--must have read messages from another LINE client, skip")
|
||||
this.numChatNotifications.set(chatID, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const mustSync =
|
||||
// If >1, a notification was missed. Only way to get them is to view the chat.
|
||||
// If == 0, might be own message...or just a shuffled chat, or something else.
|
||||
// To play it safe, just sync them. Should be no harm, as they're viewed already.
|
||||
diffNumNotifications != 1
|
||||
// Without placeholders, some messages require visiting their chat to be synced.
|
||||
|| !this.sendPlaceholders
|
||||
&& (
|
||||
// Can only use previews for DMs, because sender can't be found otherwise!
|
||||
chatListInfo.id.charAt(0) != 'u'
|
||||
// Sync when lastMsg is a canned message for a non-previewable message type.
|
||||
|| chatListInfo.lastMsg.endsWith(" sent a photo.")
|
||||
|| chatListInfo.lastMsg.endsWith(" sent a sticker.")
|
||||
|| chatListInfo.lastMsg.endsWith(" sent a location.")
|
||||
// TODO More?
|
||||
)
|
||||
|
||||
let messages
|
||||
if (!mustSync) {
|
||||
messages = [{
|
||||
chat_id: chatListInfo.id,
|
||||
id: null, // because sidebar messages have no ID
|
||||
timestamp: null, // because this message was sent right now
|
||||
is_outgoing: false, // because there's no reliable way to detect own messages...
|
||||
sender: null, // because there's no way to tell who sent a message
|
||||
html: chatListInfo.lastMsg,
|
||||
}]
|
||||
this.numChatNotifications.set(chatID, chatListInfo.notificationCount)
|
||||
} else {
|
||||
messages = await this._getMessagesUnsafe(chatListInfo.id)
|
||||
this.numChatNotifications.set(chatID, 0)
|
||||
if (messages.length === 0) {
|
||||
this.log("No new messages found in", chatListInfo.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.client) {
|
||||
for (const message of messages) {
|
||||
await this.client.sendMessage(message).catch(err =>
|
||||
this.error("Failed to send message", message.id, "to client:", err))
|
||||
this.error("Failed to send message", message.id || "with no ID", "to client:", err))
|
||||
}
|
||||
} else {
|
||||
this.log("No client connected, not sending messages")
|
||||
|
@ -685,10 +838,10 @@ export default class MessagesPuppeteer {
|
|||
}
|
||||
|
||||
_receiveChatListChanges(changes) {
|
||||
this.log("Received chat list changes:", changes)
|
||||
this.log(`Received chat list changes: ${changes.map(item => item.id)}`)
|
||||
for (const item of changes) {
|
||||
if (!this.updatedChats.has(item)) {
|
||||
this.updatedChats.add(item)
|
||||
if (!this.updatedChats.has(item.id)) {
|
||||
this.updatedChats.add(item.id)
|
||||
this.taskQueue.push(() => this._processChatListChangeUnsafe(item))
|
||||
.catch(err => this.error("Error handling chat list changes:", err))
|
||||
}
|
||||
|
@ -724,18 +877,8 @@ export default class MessagesPuppeteer {
|
|||
|
||||
async _sendEmailCredentials() {
|
||||
this.log("Inputting login credentials")
|
||||
|
||||
// Triple-click input fields to select all existing text and replace it on type
|
||||
let input
|
||||
|
||||
input = await this.page.$("#line_login_email")
|
||||
await input.click({clickCount: 3})
|
||||
await input.type(this.login_email)
|
||||
|
||||
input = await this.page.$("#line_login_pwd")
|
||||
await input.click({clickCount: 3})
|
||||
await input.type(this.login_password)
|
||||
|
||||
await this._enterText(await this.page.$("#line_login_email"), this.login_email)
|
||||
await this._enterText(await this.page.$("#line_login_pwd"), this.login_password)
|
||||
await this.page.click("button#login_btn")
|
||||
}
|
||||
|
||||
|
@ -781,6 +924,7 @@ export default class MessagesPuppeteer {
|
|||
_onLoggedOut() {
|
||||
this.log("Got logged out!")
|
||||
this.stopObserving()
|
||||
this.page.bringToFront()
|
||||
if (this.client) {
|
||||
this.client.sendLoggedOut().catch(err =>
|
||||
this.error("Failed to send logout notice to client:", err))
|
||||
|
|
Loading…
Reference in New Issue