From 33ca6223c5f023b26ce737787478ac9abd182bfe Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Fri, 11 Jun 2021 02:53:30 -0400 Subject: [PATCH] Use MSC2409 to send outgoing read receipts When a message is viewed in Matrix, make Puppeteer view its LINE chat --- matrix_puppeteer_line/db/message.py | 12 +++ matrix_puppeteer_line/example-config.yaml | 6 ++ matrix_puppeteer_line/matrix.py | 16 +++- matrix_puppeteer_line/rpc/client.py | 4 +- matrix_puppeteer_line/user.py | 8 ++ puppet/src/client.js | 2 +- puppet/src/puppet.js | 104 +++++++++++++++------- 7 files changed, 115 insertions(+), 37 deletions(-) diff --git a/matrix_puppeteer_line/db/message.py b/matrix_puppeteer_line/db/message.py index c6dc24b..cb24a9e 100644 --- a/matrix_puppeteer_line/db/message.py +++ b/matrix_puppeteer_line/db/message.py @@ -59,6 +59,18 @@ class Message: 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 " diff --git a/matrix_puppeteer_line/example-config.yaml b/matrix_puppeteer_line/example-config.yaml index 2f241f9..83a95d9 100644 --- a/matrix_puppeteer_line/example-config.yaml +++ b/matrix_puppeteer_line/example-config.yaml @@ -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 diff --git a/matrix_puppeteer_line/matrix.py b/matrix_puppeteer_line/matrix.py index a2553a8..8ce2809 100644 --- a/matrix_puppeteer_line/matrix.py +++ b/matrix_puppeteer_line/matrix.py @@ -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,12 @@ 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. + # 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! + 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) diff --git a/matrix_puppeteer_line/rpc/client.py b/matrix_puppeteer_line/rpc/client.py index 3e68865..90ca95a 100644 --- a/matrix_puppeteer_line/rpc/client.py +++ b/matrix_puppeteer_line/rpc/client.py @@ -45,8 +45,8 @@ class Client(RPCClient): 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) diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py index 1372ac1..7fd738e 100644 --- a/matrix_puppeteer_line/user.py +++ b/matrix_puppeteer_line/user.py @@ -144,6 +144,14 @@ 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 stop(self) -> None: # TODO Notices for shutdown messages if self._connection_check_task: diff --git a/puppet/src/client.js b/puppet/src/client.js index ddce619..0a0a94d 100644 --- a/puppet/src/client.js +++ b/puppet/src/client.js @@ -259,7 +259,7 @@ export default class Client { pause: () => this.puppet.stopObserving(), resume: () => this.puppet.startObserving(), 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() }), diff --git a/puppet/src/puppet.js b/puppet/src/puppet.js index f449bbe..2e0342b 100644 --- a/puppet/src/puppet.js +++ b/puppet/src/puppet.js @@ -90,6 +90,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) @@ -120,6 +124,7 @@ export default class MessagesPuppeteer { } async _preparePage(navigateTo) { + await this.page.bringToFront() if (navigateTo) { await this.page.goto(MessagesPuppeteer.url) } else { @@ -129,6 +134,18 @@ 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() + } + } + /** * Wait for the session to be logged in and monitor changes while it's not. */ @@ -137,6 +154,7 @@ export default class MessagesPuppeteer { return } this.loginRunning = true + await this.page.bringToFront() const loginContentArea = await this.page.waitForSelector("#login_content") @@ -255,6 +273,7 @@ export default class MessagesPuppeteer { } 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 { @@ -377,10 +396,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} - 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)) } /** @@ -448,7 +468,7 @@ export default class MessagesPuppeteer { 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)) @@ -464,30 +484,41 @@ 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 () => { + 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"}, + chatName) - // 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") + }) if (hadMsgListObserver) { this.log("Restoring msg list observer") @@ -500,7 +531,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) @@ -522,7 +553,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. @@ -530,6 +561,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 = [{ @@ -559,9 +594,11 @@ export default class MessagesPuppeteer { () => 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 () => { + await input.click() + await input.type(text) + await input.press("Enter") + }) return await this._waitForSentMessage(chatID) } @@ -575,13 +612,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 @@ -826,6 +865,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))