Use MSC2409 to send outgoing read receipts

When a message is viewed in Matrix, make Puppeteer view its LINE chat
This commit is contained in:
Andrew Ferrazzutti 2021-06-11 02:53:30 -04:00
parent 85dc7a842e
commit 33ca6223c5
7 changed files with 115 additions and 37 deletions

View File

@ -59,6 +59,18 @@ class Message:
return await cls.db.fetchval("SELECT COUNT(*) FROM message " return await cls.db.fetchval("SELECT COUNT(*) FROM message "
"WHERE mid IS NULL AND mx_room=$1", room_id) "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 "

View File

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

View File

@ -17,9 +17,11 @@ from typing import TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent, from mautrix.types import (Event, ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, RedactionEvent,
ReceiptEvent, SingleReceiptEventContent,
EventID, RoomID, UserID) EventID, RoomID, UserID)
from . import portal as po, puppet as pu, user as u from . import portal as po, puppet as pu, user as u
from .db import Message as DBMessage
if TYPE_CHECKING: if TYPE_CHECKING:
from .__main__ import MessagesBridge from .__main__ import MessagesBridge
@ -35,8 +37,9 @@ class MatrixHandler(BaseMatrixHandler):
super().__init__(bridge=bridge) super().__init__(bridge=bridge)
def filter_matrix_event(self, evt: Event) -> bool: def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (ReactionEvent, MessageEvent, StateEvent, EncryptedEvent, if isinstance(evt, ReceiptEvent):
RedactionEvent)): return False
if not isinstance(evt, (MessageEvent, StateEvent, EncryptedEvent)):
return True return True
return (evt.sender == self.az.bot_mxid return (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None) or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
@ -59,3 +62,12 @@ 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.
# 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)

View File

@ -45,8 +45,8 @@ class Client(RPCClient):
resp = await self.request("get_chats") resp = await self.request("get_chats")
return [ChatListInfo.deserialize(data) for data in resp] return [ChatListInfo.deserialize(data) for data in resp]
async def get_chat(self, chat_id: str) -> ChatInfo: async def get_chat(self, chat_id: str, force_view: bool = False) -> ChatInfo:
return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id)) return ChatInfo.deserialize(await self.request("get_chat", chat_id=chat_id, force_view=force_view))
async def get_messages(self, chat_id: str) -> List[Message]: async def get_messages(self, chat_id: str) -> List[Message]:
resp = await self.request("get_messages", chat_id=chat_id) resp = await self.request("get_messages", chat_id=chat_id)

View File

@ -144,6 +144,14 @@ 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 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:

View File

@ -259,7 +259,7 @@ export default class Client {
pause: () => this.puppet.stopObserving(), pause: () => this.puppet.stopObserving(),
resume: () => this.puppet.startObserving(), resume: () => this.puppet.startObserving(),
get_chats: () => this.puppet.getRecentChats(), get_chats: () => this.puppet.getRecentChats(),
get_chat: req => this.puppet.getChatInfo(req.chat_id), get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view),
get_messages: req => this.puppet.getMessages(req.chat_id), get_messages: req => this.puppet.getMessages(req.chat_id),
read_image: req => this.puppet.readImage(req.image_url), read_image: req => this.puppet.readImage(req.image_url),
is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }), is_connected: async () => ({ is_connected: !await this.puppet.isDisconnected() }),

View File

@ -90,6 +90,10 @@ export default class MessagesPuppeteer {
} else { } else {
this.page = await this.browser.newPage() this.page = await this.browser.newPage()
} }
this.blankPage = await this.browser.newPage()
await this.page.bringToFront()
this.log("Opening", MessagesPuppeteer.url) this.log("Opening", MessagesPuppeteer.url)
await this.page.setBypassCSP(true) // Needed to load content scripts await this.page.setBypassCSP(true) // Needed to load content scripts
await this._preparePage(true) await this._preparePage(true)
@ -120,6 +124,7 @@ export default class MessagesPuppeteer {
} }
async _preparePage(navigateTo) { async _preparePage(navigateTo) {
await this.page.bringToFront()
if (navigateTo) { if (navigateTo) {
await this.page.goto(MessagesPuppeteer.url) await this.page.goto(MessagesPuppeteer.url)
} else { } else {
@ -129,6 +134,18 @@ 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()
}
}
/** /**
* 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.
*/ */
@ -137,6 +154,7 @@ export default class MessagesPuppeteer {
return return
} }
this.loginRunning = true this.loginRunning = true
await this.page.bringToFront()
const loginContentArea = await this.page.waitForSelector("#login_content") const loginContentArea = await this.page.waitForSelector("#login_content")
@ -255,6 +273,7 @@ export default class MessagesPuppeteer {
} }
this.loginRunning = false this.loginRunning = false
await this.blankPage.bringToFront()
// Don't start observing yet, instead wait for explicit request. // Don't start observing yet, instead wait for explicit request.
// But at least view the most recent chat. // But at least view the most recent chat.
try { try {
@ -377,10 +396,11 @@ export default class MessagesPuppeteer {
* Get info about a chat. * Get info about a chat.
* *
* @param {string} chatID - The chat ID whose info to get. * @param {string} chatID - The chat ID whose info to get.
* @param {boolean} forceView - Whether the LINE tab should always be viewed, even if the chat is already active.
* @return {Promise<ChatInfo>} - Info about the chat. * @return {Promise<ChatInfo>} - Info about the chat.
*/ */
async getChatInfo(chatID) { async getChatInfo(chatID, forceView) {
return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID)) return await this.taskQueue.push(() => this._getChatInfoUnsafe(chatID, forceView))
} }
/** /**
@ -448,7 +468,7 @@ export default class MessagesPuppeteer {
return `#_chat_list_body div[data-chatid="${id}"]` return `#_chat_list_body div[data-chatid="${id}"]`
} }
async _switchChat(chatID) { async _switchChat(chatID, forceView = false) {
// TODO Allow passing in an element directly // TODO Allow passing in an element directly
this.log(`Switching to chat ${chatID}`) this.log(`Switching to chat ${chatID}`)
const chatListItem = await this.page.$(this._listItemSelector(chatID)) const chatListItem = await this.page.$(this._listItemSelector(chatID))
@ -464,30 +484,41 @@ export default class MessagesPuppeteer {
} }
if (await this.page.evaluate(isCorrectChatVisible, chatName)) { 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 { } else {
this.log("Ensuring msg list observer is removed") this.log("Ensuring msg list observer is removed")
const hadMsgListObserver = await this.page.evaluate( const hadMsgListObserver = await this.page.evaluate(
() => window.__mautrixController.removeMsgListObserver()) () => window.__mautrixController.removeMsgListObserver())
this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer") this.log(hadMsgListObserver ? "Observer was already removed" : "Removed observer")
await chatListItem.click() await this._interactWithPage(async () => {
this.log(`Waiting for chat header title to be "${chatName}"`) this.log(`Clicking chat list item`)
await this.page.waitForFunction( chatListItem.click()
isCorrectChatVisible, this.log(`Waiting for chat header title to be "${chatName}"`)
{polling: "mutation"}, await this.page.waitForFunction(
chatName) isCorrectChatVisible,
{polling: "mutation"},
chatName)
// 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")
await this.page.click("#_chat_header_area > .mdRGT04Link") this.log("Clicking chat header to show detail area")
this.log("Waiting for detail area") await this.page.click("#_chat_header_area > .mdRGT04Link")
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") this.log("Waiting for detail area")
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
})
if (hadMsgListObserver) { if (hadMsgListObserver) {
this.log("Restoring msg list observer") 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), 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)
@ -522,7 +553,7 @@ export default class MessagesPuppeteer {
if (!isDirect) { if (!isDirect) {
this.log("Found multi-user chat, so viewing it to get participants") this.log("Found multi-user chat, so viewing it to get participants")
// TODO This will mark the chat as "read"! // TODO This will mark the chat as "read"!
await this._switchChat(chatID) await this._switchChat(chatID, forceView)
const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul") const participantList = await this.page.$("#_chat_detail_area > .mdRGT02Info ul.mdRGT13Ul")
// TODO Is a group not actually created until a message is sent(?) // TODO Is a group not actually created until a message is sent(?)
// If so, maybe don't create a portal until there is a message. // If so, maybe don't create a portal until there is a message.
@ -530,6 +561,10 @@ export default class MessagesPuppeteer {
element => window.__mautrixController.parseParticipantList(element)) element => window.__mautrixController.parseParticipantList(element))
} else { } else {
this.log(`Found direct chat with ${chatID}`) this.log(`Found direct chat with ${chatID}`)
if (forceView) {
this.log("Viewing chat on request")
await this._switchChat(chatID, forceView)
}
//const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") //const chatDetailArea = await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
//await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name //await chatDetailArea.$(".MdTxtDesc02") || // 1:1 chat with custom title - get participant's real name
participants = [{ participants = [{
@ -559,9 +594,11 @@ export default class MessagesPuppeteer {
() => 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 input.click() await this._interactWithPage(async () => {
await input.type(text) await input.click()
await input.press("Enter") await input.type(text)
await input.press("Enter")
})
return await this._waitForSentMessage(chatID) return await this._waitForSentMessage(chatID)
} }
@ -575,13 +612,15 @@ export default class MessagesPuppeteer {
"#_chat_message_fail_menu")) "#_chat_message_fail_menu"))
try { try {
this.log(`About to ask for file chooser in ${chatID}`) this._interactWithPage(async () => {
const [fileChooser] = await Promise.all([ this.log(`About to ask for file chooser in ${chatID}`)
this.page.waitForFileChooser(), const [fileChooser] = await Promise.all([
this.page.click("#_chat_room_plus_btn") this.page.waitForFileChooser(),
]) this.page.click("#_chat_room_plus_btn")
this.log(`About to upload ${filePath}`) ])
await fileChooser.accept([filePath]) this.log(`About to upload ${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
@ -826,6 +865,7 @@ export default class MessagesPuppeteer {
_onLoggedOut() { _onLoggedOut() {
this.log("Got logged out!") this.log("Got logged out!")
this.stopObserving() this.stopObserving()
this.page.bringToFront()
if (this.client) { if (this.client) {
this.client.sendLoggedOut().catch(err => this.client.sendLoggedOut().catch(err =>
this.error("Failed to send logout notice to client:", err)) this.error("Failed to send logout notice to client:", err))