diff --git a/example-config.yaml b/example-config.yaml index fce1ac91..1aa2a508 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -63,9 +63,6 @@ bridge: native_replies: True # If native replies are disabled, should the custom replies contain a link to the message being # replied to? - # - # Warning: Using this on a client with native replies will not look good: the message will have - # a native quote AND a non-native quote. link_in_reply: False # Show message editing as a reply to the original message. # If this is false, message edits are not shown at all, as Matrix does not support editing yet. diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py index 06f16494..0fd002e0 100644 --- a/mautrix_appservice/intent_api.py +++ b/mautrix_appservice/intent_api.py @@ -344,24 +344,25 @@ async def mark_read(self, room_id, event_id): return await self.client.request("POST", f"/rooms/{room_id}/receipt/m.read/{event_id}", content={}) - def send_notice(self, room_id, text, html=None): - return self.send_text(room_id, text, html, "m.notice") + def send_notice(self, room_id, text, html=None, relates_to=None): + return self.send_text(room_id, text, html, "m.notice", relates_to) - def send_emote(self, room_id, text, html=None): - return self.send_text(room_id, text, html, "m.emote") + def send_emote(self, room_id, text, html=None, relates_to=None): + return self.send_text(room_id, text, html, "m.emote", relates_to) - def send_image(self, room_id, url, info=None, text=None): - return self.send_file(room_id, url, info or {}, text, "m.image") + def send_image(self, room_id, url, info=None, text=None, relates_to=None): + return self.send_file(room_id, url, info or {}, text, "m.image", relates_to) - def send_file(self, room_id, url, info=None, text=None, file_type="m.file"): + def send_file(self, room_id, url, info=None, text=None, file_type="m.file", relates_to=None): return self.send_message(room_id, { "msgtype": file_type, "url": url, "body": text or "Uploaded file", "info": info or {}, + "m.relates_to": relates_to or None, }) - def send_text(self, room_id, text, html=None, msgtype="m.text"): + def send_text(self, room_id, text, html=None, msgtype="m.text", relates_to=None): if html: if not text: text = html @@ -370,11 +371,13 @@ def send_text(self, room_id, text, html=None, msgtype="m.text"): "msgtype": msgtype, "format": "org.matrix.custom.html", "formatted_body": html or text, + "m.relates_to": relates_to or None, }) else: return self.send_message(room_id, { "body": text, "msgtype": msgtype, + "m.relates_to": relates_to or None, }) def send_message(self, room_id, body): diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py index 21236b4f..9590b5ec 100644 --- a/mautrix_telegram/formatter.py +++ b/mautrix_telegram/formatter.py @@ -47,19 +47,12 @@ # region Matrix to Telegram -class MessageEntityReply(MessageEntityUnknown): - def __init__(self, offset=0, length=0, msg_id=0): - super().__init__(offset, length) - self.msg_id = msg_id - class MatrixParser(HTMLParser): mention_regex = re.compile("https://matrix.to/#/(@.+)") - reply_regex = re.compile(r"https://matrix.to/#/(!.+?)/(\$.+)") - def __init__(self, tg_space=None): + def __init__(self): super().__init__() - self._tg_space = tg_space self.text = "" self.entities = [] self._building_entities = {} @@ -67,7 +60,6 @@ def __init__(self, tg_space=None): self._open_tags = deque() self._open_tags_meta = deque() self._previous_ended_line = True - self._building_reply = False def handle_starttag(self, tag, attrs): self._open_tags.appendleft(tag) @@ -97,7 +89,6 @@ def handle_starttag(self, tag, attrs): except KeyError: return mention = self.mention_regex.search(url) - reply = self.reply_regex.search(url) if mention: mxid = mention.group(1) user = p.Puppet.get_by_mxid(mxid, create=False) @@ -111,20 +102,6 @@ def handle_starttag(self, tag, attrs): else: entity_type = MessageEntityMentionName args["user_id"] = user.tgid - elif reply and self._tg_space and (len(self.entities) == 0 - and len(self._building_entities) == 0): - room_id = reply.group(1) - message_id = reply.group(2) - message = DBMessage.query.filter(DBMessage.mxid == message_id, - DBMessage.mx_room == room_id, - DBMessage.tg_space == self._tg_space - ).one_or_none() - if not message: - return - entity_type = MessageEntityReply - args["msg_id"] = message.tgid - self._building_reply = True - url = None elif url.startswith("mailto:"): url = url[len("mailto:"):] entity_type = MessageEntityEmail @@ -152,8 +129,6 @@ def _list_depth(self): def handle_data(self, text): text = unescape(text) - if self._building_reply: - return previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else "" list_format_offset = 0 if previous_tag == "a": @@ -193,8 +168,6 @@ def handle_endtag(self, tag): self._open_tags_meta.popleft() except IndexError: pass - if tag == "a": - self._building_reply = False if (tag == "ul" or tag == "ol") and self.text.endswith("\n"): self.text = self.text[:-1] entity = self._building_entities.pop(tag, None) @@ -202,22 +175,54 @@ def handle_endtag(self, tag): self.entities.append(entity) -def matrix_to_telegram(html, tg_space=None): +def matrix_to_telegram(html): try: - parser = MatrixParser(tg_space) + parser = MatrixParser() parser.feed(html) return parser.text, parser.entities except Exception: log.exception("Failed to convert Matrix format:\nhtml=%s", html) +def matrix_reply_to_telegram(content, tg_space, room_id=None): + try: + reply = content["m.relates_to"]["m.in_reply_to"] + room_id = room_id or reply["room_id"] + event_id = reply["event_id"] + message = DBMessage.query.filter(DBMessage.mxid == event_id and + DBMessage.tg_space == tg_space and + DBMessage.mx_room == room_id).one_or_none() + if message: + return message.tgid + except KeyError: + pass + return None + + # endregion # region Telegram to Matrix +def telegram_reply_to_matrix(evt, source): + if evt.reply_to_msg_id: + space = (evt.to_id.channel_id + if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel) + else source.tgid) + msg = DBMessage.query.get((evt.reply_to_msg_id, space)) + if msg: + return { + "m.in_reply_to": { + "event_id": msg.mxid, + "room_id": msg.mx_room, + } + } + return {} + + async def telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False, main_intent=None, reply_text="Reply"): text = evt.message html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None + relates_to = {} if evt.fwd_from: if not html: @@ -248,7 +253,10 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li msg = DBMessage.query.get((evt.reply_to_msg_id, space)) if msg: if native_replies: - quote = f"Quote
" + relates_to["m.in_reply_to"] = { + "event_id": msg.mxid, + "room_id": msg.mx_room, + } if reply_text == "Edit": html = "Edit: " + (html or escape(text)) else: @@ -268,10 +276,10 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li quote = f"{reply_to_msg} to {reply_to_user}
{body}
" except (ValueError, KeyError, MatrixRequestError): quote = "{reply_text} to unknown user (Failed to fetch message):
" - if html: - html = quote + html - else: - html = quote + escape(text) + if html: + html = quote + html + else: + html = quote + escape(text) if isinstance(evt, Message) and evt.post and evt.post_author: if not html: @@ -282,7 +290,7 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li if html: html = html.replace("\n", "
") - return text, html + return text, html, relates_to def telegram_to_matrix(text, entities): diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index cf4275aa..273176c4 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -457,21 +457,20 @@ async def leave_matrix(self, user, source): async def handle_matrix_message(self, sender, message, event_id): type = message["msgtype"] + space = self.tgid if self.peer_type == "channel" else sender.tgid + reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid) if type in {"m.text", "m.emote"}: if "format" in message and message["format"] == "org.matrix.custom.html": - space = self.tgid if self.peer_type == "channel" else sender.tgid message, entities = formatter.matrix_to_telegram(message["formatted_body"], space) if type == "m.emote": message = "/me " + message - reply_to = None - if len(entities) > 0 and isinstance(entities[0], formatter.MessageEntityReply): - reply_to = entities.pop(0).msg_id response = await sender.client.send_message(self.peer, message, entities=entities, reply_to=reply_to) else: if type == "m.emote": message["body"] = "/me " + message["body"] - response = await sender.client.send_message(self.peer, message["body"]) + response = await sender.client.send_message(self.peer, message["body"], + reply_to=reply_to) elif type in {"m.image", "m.file", "m.audio", "m.video"}: file = await self.main_intent.download_file(message["url"]) @@ -485,15 +484,14 @@ async def handle_matrix_message(self, sender, message, event_id): attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"])) response = await sender.client.send_file(self.peer, file, mime, caption, attributes, - file_name) + file_name, reply_to=reply_to) else: self.log.debug("Unhandled Matrix event: %s", message) return - tg_space = self.tgid if self.peer_type == "channel" else sender.tgid - self.is_duplicate(response, (event_id, tg_space)) + self.is_duplicate(response, (event_id, space)) self.db.add(DBMessage( tgid=response.id, - tg_space=tg_space, + tg_space=space, mx_room=self.mxid, mxid=event_id)) self.db.commit() @@ -674,7 +672,7 @@ async def handle_telegram_typing(self, user, event): if self.mxid: await user.intent.set_typing(self.mxid, is_typing=True) - async def handle_telegram_photo(self, source, intent, media): + async def handle_telegram_photo(self, source, intent, media, relates_to=None): largest_size = self._get_largest_photo_size(media.photo) file = await source.client.download_file_bytes(largest_size.location) mime_type = magic.from_buffer(file, mime=True) @@ -690,7 +688,7 @@ async def handle_telegram_photo(self, source, intent, media): name = media.caption await intent.set_typing(self.mxid, is_typing=False) return await intent.send_image(self.mxid, uploaded["content_uri"], info=info, - text=name) + text=name, relates_to=relates_to) def convert_webp(self, file, to="png"): try: @@ -702,7 +700,7 @@ def convert_webp(self, file, to="png"): self.log.exception(f"Failed to convert webp to {to}") return "image/webp", file - async def handle_telegram_document(self, source, intent, media): + async def handle_telegram_document(self, source, intent, media, relates_to=None): file = await source.client.download_file_bytes(media.document) mime_type = magic.from_buffer(file, mime=True) dont_change_mime = False @@ -733,9 +731,9 @@ async def handle_telegram_document(self, source, intent, media): type = "m.image" await intent.set_typing(self.mxid, is_typing=False) return await intent.send_file(self.mxid, uploaded["content_uri"], info=info, - text=name, file_type=type) + text=name, file_type=type, relates_to=relates_to) - def handle_telegram_location(self, source, intent, location): + def handle_telegram_location(self, source, intent, location, relates_to=None): long = location.long lat = location.lat long_char = "E" if long > 0 else "W" @@ -758,16 +756,18 @@ def handle_telegram_location(self, source, intent, location): "body": body, "format": "org.matrix.custom.html", "formatted_body": formatted_body, + "m.relates_to": relates_to or None, }) async def handle_telegram_text(self, source, intent, evt): self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}") - text, html = await formatter.telegram_event_to_matrix(evt, source, - config["bridge.native_replies"], - config["bridge.link_in_reply"], - self.main_intent) + text, html, relates_to = await formatter.telegram_event_to_matrix( + evt, source, + config["bridge.native_replies"], + config["bridge.link_in_reply"], + self.main_intent) await intent.set_typing(self.mxid, is_typing=False) - return await intent.send_text(self.mxid, text, html=html) + return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to) async def handle_telegram_edit(self, source, sender, evt): if not self.mxid: @@ -789,13 +789,14 @@ async def handle_telegram_edit(self, source, sender, evt): return evt.reply_to_msg_id = evt.id - text, html = await formatter.telegram_event_to_matrix(evt, source, - config["bridge.native_replies"], - config["bridge.link_in_reply"], - self.main_intent, reply_text="Edit") + text, html, relates_to = await formatter.telegram_event_to_matrix( + evt, source, + config["bridge.native_replies"], + config["bridge.link_in_reply"], + self.main_intent, reply_text="Edit") intent = sender.intent if sender else self.main_intent await intent.set_typing(self.mxid, is_typing=False) - response = await intent.send_text(self.mxid, text, html=html) + response = await intent.send_text(self.mxid, text, html=html, relates_to=relates_to) mxid = response["event_id"] @@ -828,12 +829,15 @@ async def handle_telegram_message(self, source, sender, evt): if evt.message: response = await self.handle_telegram_text(source, intent, evt) elif evt.media: + relates_to = formatter.telegram_reply_to_matrix(evt, source) if isinstance(evt.media, MessageMediaPhoto): - response = await self.handle_telegram_photo(source, intent, evt.media) + response = await self.handle_telegram_photo(source, intent, evt.media, relates_to) elif isinstance(evt.media, MessageMediaDocument): - response = await self.handle_telegram_document(source, intent, evt.media) + response = await self.handle_telegram_document(source, intent, evt.media, + relates_to) elif isinstance(evt.media, MessageMediaGeo): - response = await self.handle_telegram_location(source, intent, evt.media.geo) + response = await self.handle_telegram_location(source, intent, evt.media.geo, + relates_to) else: self.log.debug("Unhandled Telegram media: %s", evt.media) return