diff --git a/ROADMAP.md b/ROADMAP.md index ace643678cea6b70b664f622954ca75c97cdad1d..79936ff4ea5d3e89e450ec6bcd1d1e9c14c5b376 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,9 +36,9 @@ * [ ] Videos * [x] Images * [x] Locations - * [ ] Formatting - * [ ] Replies - * [ ] Mentions + * [x] Formatting + * [x] Replies + * [x] Mentions * [ ] Polls * [x] Message unsend * [x] Message reactions diff --git a/mautrix_facebook/formatter/__init__.py b/mautrix_facebook/formatter/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f890fb73dd0f1021fa7053fcd975086cbac726ce --- /dev/null +++ b/mautrix_facebook/formatter/__init__.py @@ -0,0 +1 @@ +from .from_facebook import facebook_to_matrix diff --git a/mautrix_facebook/formatter/from_facebook.py b/mautrix_facebook/formatter/from_facebook.py new file mode 100644 index 0000000000000000000000000000000000000000..a4984e6e4b0bb45a0bbf0027f185146c93379cbb --- /dev/null +++ b/mautrix_facebook/formatter/from_facebook.py @@ -0,0 +1,112 @@ +# mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge +# Copyright (C) 2019 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +from typing import Tuple, Match +from html import escape +import re + +from fbchat.models import Message + +from mautrix.types import TextMessageEventContent, Format, MessageType + +from .. import puppet as pu, user as u + +_START = r"^|\s" +_END = r"$|\s" +_TEXT_NO_SURROUNDING_SPACE = r"(?:[^\s].*?[^\s])|[^\s]" +COMMON_REGEX = re.compile(rf"({_START})([_~*])({_TEXT_NO_SURROUNDING_SPACE})\2({_END})") +INLINE_CODE_REGEX = re.compile(rf"({_START})(`)(.+?)`({_END})") +CODE_BLOCK_REGEX = re.compile(r"(```.+```)") +MENTION_REGEX = re.compile(r"@([0-9]{15})\u2063(.+)\u2063") + +tags = { + "_": "em", + "*": "strong", + "~": "del", + "`": "code" +} + + +def _code_block_replacer(code: str) -> str: + if "\n" in code: + lang, code = code.split("\n", 1) + lang = lang.strip() + if lang: + return f"<pre><code class=\"language-{lang}\">{code}</code></pre>" + return f"<pre><code>{code}</code></pre>" + + +def _mention_replacer(match: Match) -> str: + fbid = match.group(1) + + user = u.User.get_by_fbid(fbid) + if user: + return f"<a href=\"https://matrix.to/#/{user.mxid}\">{match.group(2)}</a>" + + puppet = pu.Puppet.get_by_fbid(fbid, create=False) + if puppet: + return f"<a href=\"https://matrix.to/#/{puppet.mxid}\">{match.group(2)}</a>" + + +def _handle_match(html: str, match: Match, nested: bool) -> Tuple[str, int]: + start, end = match.start(), match.end() + prefix, sigil, text, suffix = match.groups() + if nested: + text = _convert_formatting(text) + tag = tags[sigil] + # We don't want to include the whitespace suffix length, as that could be used as the + # whitespace prefix right after this formatting block. + pos = start + len(prefix) + (2 * len(tag) + 5) + len(text) + html = (f"{html[:start]}{prefix}" + f"<{tag}>{text}</{tag}>" + f"{suffix}{html[end:]}") + return html, pos + + +def _convert_formatting(html: str) -> str: + pos = 0 + while pos < len(html): + i_match = INLINE_CODE_REGEX.search(html, pos) + c_match = COMMON_REGEX.search(html, pos) + if i_match and c_match: + match = min(i_match, c_match, key=lambda match: match.start()) + else: + match = i_match or c_match + + if match: + html, pos = _handle_match(html, match, nested=match != i_match) + else: + break + return html + + +def facebook_to_matrix(message: Message) -> TextMessageEventContent: + content = TextMessageEventContent(msgtype=MessageType.TEXT, body=message.text) + text = message.text + for m in reversed(message.mentions): + original = text[m.offset:m.offset + m.length] + if len(original) > 0 and original[0] == "@": + original = original[1:] + text = f"{text[:m.offset]}@{m.thread_id}\u2063{original}\u2063{text[m.offset + m.length:]}" + html = escape(text) + html = "".join(_code_block_replacer(part[3:-3]) if part[:3] == "```" == part[-3:] + else _convert_formatting(part) + for part in CODE_BLOCK_REGEX.split(html)) + + html = MENTION_REGEX.sub(_mention_replacer, html) + if html != escape(content.body): + content.format = Format.HTML + content.formatted_body = html + return content diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py index cf798425f0b0d74fea29899a96a74656fa7ce2cc..dcdd414852ebb86cadb4c66e53431fdda81d6f16 100644 --- a/mautrix_facebook/portal.py +++ b/mautrix_facebook/portal.py @@ -28,11 +28,13 @@ from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup, ShareAttachment, TypingStatus, MessageReaction) from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID, ImageInfo, MessageType, LocationMessageEventContent, LocationInfo, - ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format, - TextMessageEventContent, MediaMessageEventContent, Membership) + ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format, RelatesTo, + TextMessageEventContent, MediaMessageEventContent, Membership, + RelationType) from mautrix.appservice import AppService, IntentAPI from mautrix.errors import MForbidden, IntentError, MatrixError +from .formatter import facebook_to_matrix from .config import Config from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction from . import puppet as p, user as u @@ -473,10 +475,11 @@ class Portal: intent = sender.intent_for(self) event_ids = [] if message.sticker: - event_ids = [await self._handle_facebook_sticker(intent, message.sticker)] + event_ids = [await self._handle_facebook_sticker(intent, message.sticker, + message.reply_to_id)] elif len(message.attachments) > 0: event_ids = await asyncio.gather( - *[self._handle_facebook_attachment(intent, attachment) + *[self._handle_facebook_attachment(intent, attachment, message.reply_to_id) for attachment in message.attachments]) event_ids = [event_id for event_id in event_ids if event_id] if not event_ids and message.text: @@ -487,10 +490,28 @@ class Portal: event_ids=[event_id for event_id in event_ids if event_id]) await source.markAsDelivered(self.fbid, message.uid) + async def _add_facebook_reply(self, content: TextMessageEventContent, reply: str) -> None: + if reply: + message = DBMessage.get_by_fbid(reply, self.fb_receiver) + if message: + evt = await self.main_intent.get_event(message.mx_room, message.mxid) + if evt: + content.set_reply(evt) + + def _get_facebook_reply(self, reply: str) -> Optional[RelatesTo]: + if reply: + message = DBMessage.get_by_fbid(reply, self.fb_receiver) + if message: + return RelatesTo(rel_type=RelationType.REFERENCE, event_id=message.mxid) + return None + async def _handle_facebook_text(self, intent: IntentAPI, message: FBMessage) -> EventID: - return await intent.send_text(self.mxid, message.text) + content = facebook_to_matrix(message) + await self._add_facebook_reply(content, message.reply_to_id) + return await intent.send_message(self.mxid, content) - async def _handle_facebook_sticker(self, intent: IntentAPI, sticker: FBSticker) -> EventID: + async def _handle_facebook_sticker(self, intent: IntentAPI, sticker: FBSticker, + reply_to: str) -> EventID: # TODO handle animated stickers? mxc, mime, size = await self._reupload_fb_photo(sticker.url, intent) return await intent.send_sticker(room_id=self.mxid, url=mxc, @@ -498,17 +519,19 @@ class Portal: height=sticker.height, mimetype=mime, size=size), - text=sticker.label) + text=sticker.label, + relates_to=self._get_facebook_reply(reply_to)) - async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass - ) -> Optional[EventID]: + async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass, + reply_to: str) -> Optional[EventID]: if isinstance(attachment, AudioAttachment): mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent, attachment.filename) event_id = await intent.send_file(self.mxid, mxc, file_type=MessageType.AUDIO, info=AudioInfo(size=size, mimetype=mime, duration=attachment.duration), - file_name=attachment.filename, ) + file_name=attachment.filename, + relates_to=self._get_facebook_reply(reply_to)) # elif isinstance(attachment, VideoAttachment): # TODO elif isinstance(attachment, FileAttachment): @@ -516,16 +539,19 @@ class Portal: attachment.name) event_id = await intent.send_file(self.mxid, mxc, info=FileInfo(size=size, mimetype=mime), - file_name=attachment.name) + file_name=attachment.name, + relates_to=self._get_facebook_reply(reply_to)) elif isinstance(attachment, ImageAttachment): mxc, mime, size = await self._reupload_fb_photo(attachment.large_preview_url, intent) - event_id = await intent.send_image(self.mxid, mxc, + info = ImageInfo(size=size, mimetype=mime, + width=attachment.large_preview_width, + height=attachment.large_preview_height) + event_id = await intent.send_image(self.mxid, mxc, info=info, file_name=f"image.{attachment.original_extension}", - info=ImageInfo(size=size, mimetype=mime, - width=attachment.large_preview_width, - height=attachment.large_preview_height)) + relates_to=self._get_facebook_reply(reply_to)) elif isinstance(attachment, LocationAttachment): content = await self._convert_facebook_location(intent, attachment) + content.relates_to = self._get_facebook_reply(reply_to) event_id = await intent.send_message(self.mxid, content) else: self.log.warn(f"Unsupported attachment type: {attachment}") diff --git a/mautrix_facebook/user.py b/mautrix_facebook/user.py index 3aee460a30adcf83e846a80d79b5c86858bef917..0412fe6198018991625fb489035b9c17b7bb5483 100644 --- a/mautrix_facebook/user.py +++ b/mautrix_facebook/user.py @@ -39,6 +39,7 @@ class User(Client): loop: asyncio.AbstractEventLoop log: logging.Logger = logging.getLogger("mau.user") by_mxid: Dict[UserID, 'User'] = {} + by_fbid: Dict[str, 'User'] = {} command_status: Optional[Dict[str, Any]] is_whitelisted: bool @@ -111,6 +112,19 @@ class User(Client): return None + @classmethod + def get_by_fbid(cls, fbid: str) -> Optional['User']: + try: + return cls.by_fbid[fbid] + except KeyError: + pass + + db_user = DBUser.get_by_fbid(fbid) + if db_user: + return cls.from_db(db_user) + + return None + async def load_session(self) -> bool: if self._is_logged_in: return True @@ -139,6 +153,7 @@ class User(Client): async def post_login(self) -> None: self.log.info("Running post-login actions") + self.by_fbid[self.fbid] = self await self.sync_threads() self.log.debug("Updating own puppet info") own_info = (await self.fetchUserInfo(self.uid))[self.uid]