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 "
"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 "

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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() }),

View File

@ -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<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))
}
/**
@ -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))