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:
parent
85dc7a842e
commit
33ca6223c5
|
@ -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 "
|
||||
|
|
|
@ -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,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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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() }),
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue