forked from fair/matrix-puppeteer-line
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 "
|
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 "
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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() }),
|
||||||
|
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in New Issue