Use read receipts to know when to sync media

Instead of having to view a LINE chat when a media message is sent, send
a placeholder message that gets replaced with the actual media when it's
viewed in Matrix.
This commit is contained in:
Andrew Ferrazzutti 2021-06-17 00:42:06 -04:00
parent a3e7caac27
commit 1ae30bcf1b
8 changed files with 65 additions and 41 deletions

View File

@ -36,10 +36,11 @@ class Message:
q = "INSERT INTO message (mxid, mx_room, mid, chat_id) VALUES ($1, $2, $3, $4)" q = "INSERT INTO message (mxid, mx_room, mid, chat_id) VALUES ($1, $2, $3, $4)"
await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id) await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id)
async def update(self) -> None: async def update_ids(self, new_mxid: EventID, new_mid: int) -> None:
q = ("UPDATE message SET mid=$3, chat_id=$4 " q = ("UPDATE message SET mxid=$1, mid=$2 "
"WHERE mxid=$1 AND mx_room=$2") "WHERE mxid=$3 AND mx_room=$4 AND chat_id=$5")
await self.db.execute(q, self.mxid, self.mx_room, self.mid, self.chat_id) await self.db.execute(q, new_mxid, new_mid,
self.mxid, self.mx_room, self.chat_id)
@classmethod @classmethod
async def get_max_mid(cls, room_id: RoomID) -> int: async def get_max_mid(cls, room_id: RoomID) -> int:

View File

@ -66,8 +66,12 @@ class MatrixHandler(BaseMatrixHandler):
async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID, async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
data: SingleReceiptEventContent) -> None: data: SingleReceiptEventContent) -> None:
# When reading a bridged message, view its chat in LINE, to make it send a read receipt. # When reading a bridged message, view its chat in LINE, to make it send a read receipt.
# TODO Use *null* mids for last messages in a chat!!
# Only visit a LINE chat when its LAST bridge message has been read, # 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! # because LINE lacks per-message read receipts--it's all or nothing!
if await DBMessage.is_last_by_mxid(event_id, portal.mxid): # TODO Also view if message is non-last but for media, so it can be loaded.
# Viewing a chat by updating it whole-hog, lest a ninja arrives #if await DBMessage.is_last_by_mxid(event_id, portal.mxid):
await user.sync_portal(portal)
# Viewing a chat by updating it whole-hog, lest a ninja arrives
await user.sync_portal(portal)

View File

@ -245,12 +245,17 @@ class Portal(DBPortal, BasePortal):
if is_preseen: if is_preseen:
msg = await DBMessage.get_next_noid_msg(self.mxid) msg = await DBMessage.get_next_noid_msg(self.mxid)
if msg: if msg:
self.log.debug(f"Found ID {evt.id} of preseen message in chat {self.mxid} {msg.mxid}") self.log.debug(f"Found ID {evt.id} of preseen message in chat {self.mxid}: {msg.mxid}")
msg.mid = evt.id prev_event_id = msg.mxid
event_id = msg.mxid
else: else:
self.log.error(f"Could not find an existing event for a message with no ID in chat {self.mxid}") self.log.error(f"Could not find an existing event for a message with no ID in chat {self.mxid}")
return return
else:
prev_event_id = None
if is_preseen and evt.html:
# No need to update a previewed text message, as their previews are accurate
event_id = prev_event_id
elif evt.image and evt.image.url: elif evt.image and evt.image.url:
if not evt.image.is_sticker or self.config["bridge.receive_stickers"]: if not evt.image.is_sticker or self.config["bridge.receive_stickers"]:
media_info = await self._handle_remote_media( media_info = await self._handle_remote_media(
@ -268,9 +273,11 @@ class Portal(DBPortal, BasePortal):
else: else:
media_info = None media_info = None
send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info send_sticker = self.config["bridge.use_sticker_events"] and evt.image.is_sticker and not self.encrypted and media_info
if send_sticker: # TODO Element Web messes up text->sticker edits!!
event_id = await intent.send_sticker( # File a case on it
self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp) if send_sticker and not prev_event_id:
#relates_to = RelatesTo(rel_type=RelationType.REPLACE, event_id=prev_event_id) if prev_event_id else None
event_id = await intent.send_sticker(self.mxid, media_info.mxc, image_info, "<sticker>", timestamp=evt.timestamp)
else: else:
if media_info: if media_info:
content = MediaMessageEventContent( content = MediaMessageEventContent(
@ -282,8 +289,11 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
body=f"<{'sticker' if evt.image.is_sticker else 'image'}>") body=f"<{'sticker' if evt.image.is_sticker else 'image'}>")
if prev_event_id:
content.set_edit(prev_event_id)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp) event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
elif evt.html and not evt.html.isspace(): elif evt.html and not evt.html.isspace():
chunks = [] chunks = []
def handle_data(data): def handle_data(data):
@ -347,6 +357,8 @@ class Portal(DBPortal, BasePortal):
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.NOTICE, msgtype=MessageType.NOTICE,
body="<Unbridgeable message>") body="<Unbridgeable message>")
if prev_event_id:
content.set_edit(prev_event_id)
event_id = await self._send_message(intent, content, timestamp=evt.timestamp) event_id = await self._send_message(intent, content, timestamp=evt.timestamp)
if evt.is_outgoing and evt.receipt_count: if evt.is_outgoing and evt.receipt_count:
@ -361,7 +373,7 @@ class Portal(DBPortal, BasePortal):
except UniqueViolationError as e: except UniqueViolationError as e:
self.log.debug(f"Failed to handle remote message {evt.id or 'with no ID'} -> {event_id}: {e}") self.log.debug(f"Failed to handle remote message {evt.id or 'with no ID'} -> {event_id}: {e}")
else: else:
await msg.update() await msg.update_ids(new_mxid=event_id, new_mid=evt.id)
self.log.debug(f"Handled preseen remote message {evt.id} -> {event_id}") self.log.debug(f"Handled preseen remote message {evt.id} -> {event_id}")
async def handle_remote_receipt(self, receipt: Receipt) -> None: async def handle_remote_receipt(self, receipt: Receipt) -> None:

View File

@ -33,6 +33,7 @@ class RPCClient:
log: TraceLogger = logging.getLogger("mau.rpc") log: TraceLogger = logging.getLogger("mau.rpc")
user_id: UserID user_id: UserID
ephemeral_events: bool
_reader: Optional[asyncio.StreamReader] _reader: Optional[asyncio.StreamReader]
_writer: Optional[asyncio.StreamWriter] _writer: Optional[asyncio.StreamWriter]
_req_id: int _req_id: int
@ -40,11 +41,12 @@ class RPCClient:
_response_waiters: Dict[int, asyncio.Future] _response_waiters: Dict[int, asyncio.Future]
_event_handlers: Dict[str, List[EventHandler]] _event_handlers: Dict[str, List[EventHandler]]
def __init__(self, user_id: UserID, own_id: str) -> None: def __init__(self, user_id: UserID, own_id: str, ephemeral_events: bool) -> None:
self.log = self.log.getChild(user_id) self.log = self.log.getChild(user_id)
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.user_id = user_id self.user_id = user_id
self.own_id = own_id self.own_id = own_id
self.ephemeral_events = ephemeral_events
self._req_id = 0 self._req_id = 0
self._min_broadcast_id = 0 self._min_broadcast_id = 0
self._event_handlers = {} self._event_handlers = {}
@ -70,7 +72,8 @@ class RPCClient:
self.loop.create_task(self._command_loop()) self.loop.create_task(self._command_loop())
await self.request("register", await self.request("register",
user_id=self.user_id, user_id=self.user_id,
own_id = self.own_id) own_id = self.own_id,
ephemeral_events=self.ephemeral_events)
async def disconnect(self) -> None: async def disconnect(self) -> None:
self._writer.write_eof() self._writer.write_eof()

View File

@ -103,7 +103,7 @@ class User(DBUser, BaseUser):
async def connect(self) -> None: async def connect(self) -> None:
self.loop.create_task(self.connect_double_puppet()) self.loop.create_task(self.connect_double_puppet())
self.client = Client(self.mxid, self.own_id) self.client = Client(self.mxid, self.own_id, self.config["appservice.ephemeral_events"])
self.log.debug("Starting client") self.log.debug("Starting client")
await self.send_bridge_notice("Starting up...") await self.send_bridge_notice("Starting up...")
state = await self.client.start() state = await self.client.start()

View File

@ -164,7 +164,7 @@ export default class Client {
let started = false let started = false
if (this.puppet === null) { if (this.puppet === null) {
this.log("Opening new puppeteer for", this.userID) this.log("Opening new puppeteer for", this.userID)
this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this) this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this.sendPlaceholders, this)
this.manager.puppets.set(this.userID, this.puppet) this.manager.puppets.set(this.userID, this.puppet)
await this.puppet.start(!!req.debug) await this.puppet.start(!!req.debug)
started = true started = true
@ -195,7 +195,8 @@ export default class Client {
handleRegister = async (req) => { handleRegister = async (req) => {
this.userID = req.user_id this.userID = req.user_id
this.ownID = req.own_id this.ownID = req.own_id
this.log(`Registered socket ${this.connID} -> ${this.userID}`) this.sendPlaceholders = req.ephemeral_events
this.log(`Registered socket ${this.connID} -> ${this.userID}${!this.sendPlaceholders ? "" : " (with placeholder message support)"}`)
if (this.manager.clients.has(this.userID)) { if (this.manager.clients.has(this.userID)) {
const oldClient = this.manager.clients.get(this.userID) const oldClient = this.manager.clients.get(this.userID)
this.manager.clients.set(this.userID, this) this.manager.clients.set(this.userID, this)

View File

@ -740,19 +740,17 @@ class MautrixController {
for (const node of change.addedNodes) { for (const node of change.addedNodes) {
} }
*/ */
} else if (change.target.tagName == "LI") { } else if (change.target.tagName == "LI" && change.addedNodes.length == 1) {
if (change.target.classList.contains("ExSelected")) { if (change.target.classList.contains("ExSelected")) {
console.debug("Not using chat list mutation response for currently-active chat") console.debug("Not using chat list mutation response for currently-active chat")
continue continue
} }
for (const node of change.addedNodes) { const chat = this.parseChatListItem(change.addedNodes[0])
const chat = this.parseChatListItem(node) if (chat) {
if (chat) { console.log("Added chat list item:", chat)
console.log("Added chat list item:", chat) changedChats.add(chat)
changedChats.add(chat) } else {
} else { console.debug("Could not parse added node as a chat list item:", node)
console.debug("Could not parse added node as a chat list item:", node)
}
} }
} }
// change.removedNodes tells you which chats that had notifications are now read. // change.removedNodes tells you which chats that had notifications are now read.

View File

@ -36,13 +36,14 @@ export default class MessagesPuppeteer {
* @param {string} id * @param {string} id
* @param {?Client} [client] * @param {?Client} [client]
*/ */
constructor(id, ownID, client = null) { constructor(id, ownID, sendPlaceholders, client = null) {
let profilePath = path.join(MessagesPuppeteer.profileDir, id) let profilePath = path.join(MessagesPuppeteer.profileDir, id)
if (!profilePath.startsWith("/")) { if (!profilePath.startsWith("/")) {
profilePath = path.join(process.cwd(), profilePath) profilePath = path.join(process.cwd(), profilePath)
} }
this.id = id this.id = id
this.ownID = ownID this.ownID = ownID
this.sendPlaceholders = sendPlaceholders
this.profilePath = profilePath this.profilePath = profilePath
this.updatedChats = new Set() this.updatedChats = new Set()
this.sentMessageIDs = new Set() this.sentMessageIDs = new Set()
@ -764,24 +765,28 @@ export default class MessagesPuppeteer {
const diffNumNotifications = chatListInfo.notificationCount - prevNumNotifications const diffNumNotifications = chatListInfo.notificationCount - prevNumNotifications
if (chatListInfo.notificationCount == 0 && diffNumNotifications < 0) { if (chatListInfo.notificationCount == 0 && diffNumNotifications < 0) {
// Message was read from another LINE client, so there's no new info to bridge. this.log("Notifications dropped--must have read messages from another LINE client, skip")
// But if the diff == 0, it's an own message sent from LINE, and must bridge it!
this.numChatNotifications.set(chatID, 0) this.numChatNotifications.set(chatID, 0)
return return
} }
const mustSync = const mustSync =
// Can only use previews for DMs, because sender can't be found otherwise! // Can only use previews for DMs, because sender can't be found otherwise!
// TODO For non-DMs, send fake messages from bridgebot and delete them.
chatListInfo.id.charAt(0) != 'u' chatListInfo.id.charAt(0) != 'u'
|| diffNumNotifications > 1 // If >1, a notification was missed. Only way to get them is to view the chat.
// Sync when lastMsg is a canned message for a non-previewable message type. // If == 0, might be own message...or just a shuffled chat, or something else.
|| chatListInfo.lastMsg.endsWith(" sent a photo.") // To play it safe, just sync them. Should be no harm, as they're viewed already.
|| chatListInfo.lastMsg.endsWith(" sent a sticker.") || diffNumNotifications != 1
|| chatListInfo.lastMsg.endsWith(" sent a location.") // Without placeholders, some messages require visiting their chat to be synced.
// TODO More? || !this.sendPlaceholders
// TODO With MSC2409, only sync if >1 new messages arrived, && (
// or if message is unpreviewable. // Sync when lastMsg is a canned message for a non-previewable message type.
// Otherwise, send a dummy notice & sync when its read. chatListInfo.lastMsg.endsWith(" sent a photo.")
|| chatListInfo.lastMsg.endsWith(" sent a sticker.")
|| chatListInfo.lastMsg.endsWith(" sent a location.")
// TODO More?
)
let messages let messages
if (!mustSync) { if (!mustSync) {
@ -789,7 +794,7 @@ export default class MessagesPuppeteer {
chat_id: chatListInfo.id, chat_id: chatListInfo.id,
id: null, // because sidebar messages have no ID id: null, // because sidebar messages have no ID
timestamp: null, // because this message was sent right now timestamp: null, // because this message was sent right now
is_outgoing: chatListInfo.notificationCount == 0, is_outgoing: false, // because there's no reliable way to detect own messages...
sender: null, // because only DM messages are handled sender: null, // because only DM messages are handled
html: chatListInfo.lastMsg, html: chatListInfo.lastMsg,
}] }]