Skip to content
Snippets Groups Projects
Commit eed5b4a7 authored by Tulir Asokan's avatar Tulir Asokan :cat2:
Browse files

Add Messenger -> Matrix formatting/reply/mention bridging

parent f2c621a8
No related branches found
No related tags found
No related merge requests found
...@@ -36,9 +36,9 @@ ...@@ -36,9 +36,9 @@
* [ ] Videos * [ ] Videos
* [x] Images * [x] Images
* [x] Locations * [x] Locations
* [ ] Formatting * [x] Formatting
* [ ] Replies * [x] Replies
* [ ] Mentions * [x] Mentions
* [ ] Polls * [ ] Polls
* [x] Message unsend * [x] Message unsend
* [x] Message reactions * [x] Message reactions
......
from .from_facebook import facebook_to_matrix
# 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
...@@ -28,11 +28,13 @@ from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup, ...@@ -28,11 +28,13 @@ from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup,
ShareAttachment, TypingStatus, MessageReaction) ShareAttachment, TypingStatus, MessageReaction)
from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID, from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID,
ImageInfo, MessageType, LocationMessageEventContent, LocationInfo, ImageInfo, MessageType, LocationMessageEventContent, LocationInfo,
ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format, ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format, RelatesTo,
TextMessageEventContent, MediaMessageEventContent, Membership) TextMessageEventContent, MediaMessageEventContent, Membership,
RelationType)
from mautrix.appservice import AppService, IntentAPI from mautrix.appservice import AppService, IntentAPI
from mautrix.errors import MForbidden, IntentError, MatrixError from mautrix.errors import MForbidden, IntentError, MatrixError
from .formatter import facebook_to_matrix
from .config import Config from .config import Config
from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
from . import puppet as p, user as u from . import puppet as p, user as u
...@@ -473,10 +475,11 @@ class Portal: ...@@ -473,10 +475,11 @@ class Portal:
intent = sender.intent_for(self) intent = sender.intent_for(self)
event_ids = [] event_ids = []
if message.sticker: 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: elif len(message.attachments) > 0:
event_ids = await asyncio.gather( 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]) for attachment in message.attachments])
event_ids = [event_id for event_id in event_ids if event_id] event_ids = [event_id for event_id in event_ids if event_id]
if not event_ids and message.text: if not event_ids and message.text:
...@@ -487,10 +490,28 @@ class Portal: ...@@ -487,10 +490,28 @@ class Portal:
event_ids=[event_id for event_id in event_ids if event_id]) event_ids=[event_id for event_id in event_ids if event_id])
await source.markAsDelivered(self.fbid, message.uid) 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: 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? # TODO handle animated stickers?
mxc, mime, size = await self._reupload_fb_photo(sticker.url, intent) mxc, mime, size = await self._reupload_fb_photo(sticker.url, intent)
return await intent.send_sticker(room_id=self.mxid, url=mxc, return await intent.send_sticker(room_id=self.mxid, url=mxc,
...@@ -498,17 +519,19 @@ class Portal: ...@@ -498,17 +519,19 @@ class Portal:
height=sticker.height, height=sticker.height,
mimetype=mime, mimetype=mime,
size=size), 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 async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass,
) -> Optional[EventID]: reply_to: str) -> Optional[EventID]:
if isinstance(attachment, AudioAttachment): if isinstance(attachment, AudioAttachment):
mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent, mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent,
attachment.filename) attachment.filename)
event_id = await intent.send_file(self.mxid, mxc, file_type=MessageType.AUDIO, event_id = await intent.send_file(self.mxid, mxc, file_type=MessageType.AUDIO,
info=AudioInfo(size=size, mimetype=mime, info=AudioInfo(size=size, mimetype=mime,
duration=attachment.duration), duration=attachment.duration),
file_name=attachment.filename, ) file_name=attachment.filename,
relates_to=self._get_facebook_reply(reply_to))
# elif isinstance(attachment, VideoAttachment): # elif isinstance(attachment, VideoAttachment):
# TODO # TODO
elif isinstance(attachment, FileAttachment): elif isinstance(attachment, FileAttachment):
...@@ -516,16 +539,19 @@ class Portal: ...@@ -516,16 +539,19 @@ class Portal:
attachment.name) attachment.name)
event_id = await intent.send_file(self.mxid, mxc, event_id = await intent.send_file(self.mxid, mxc,
info=FileInfo(size=size, mimetype=mime), 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): elif isinstance(attachment, ImageAttachment):
mxc, mime, size = await self._reupload_fb_photo(attachment.large_preview_url, intent) 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}", file_name=f"image.{attachment.original_extension}",
info=ImageInfo(size=size, mimetype=mime, relates_to=self._get_facebook_reply(reply_to))
width=attachment.large_preview_width,
height=attachment.large_preview_height))
elif isinstance(attachment, LocationAttachment): elif isinstance(attachment, LocationAttachment):
content = await self._convert_facebook_location(intent, attachment) 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) event_id = await intent.send_message(self.mxid, content)
else: else:
self.log.warn(f"Unsupported attachment type: {attachment}") self.log.warn(f"Unsupported attachment type: {attachment}")
......
...@@ -39,6 +39,7 @@ class User(Client): ...@@ -39,6 +39,7 @@ class User(Client):
loop: asyncio.AbstractEventLoop loop: asyncio.AbstractEventLoop
log: logging.Logger = logging.getLogger("mau.user") log: logging.Logger = logging.getLogger("mau.user")
by_mxid: Dict[UserID, 'User'] = {} by_mxid: Dict[UserID, 'User'] = {}
by_fbid: Dict[str, 'User'] = {}
command_status: Optional[Dict[str, Any]] command_status: Optional[Dict[str, Any]]
is_whitelisted: bool is_whitelisted: bool
...@@ -111,6 +112,19 @@ class User(Client): ...@@ -111,6 +112,19 @@ class User(Client):
return None 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: async def load_session(self) -> bool:
if self._is_logged_in: if self._is_logged_in:
return True return True
...@@ -139,6 +153,7 @@ class User(Client): ...@@ -139,6 +153,7 @@ class User(Client):
async def post_login(self) -> None: async def post_login(self) -> None:
self.log.info("Running post-login actions") self.log.info("Running post-login actions")
self.by_fbid[self.fbid] = self
await self.sync_threads() await self.sync_threads()
self.log.debug("Updating own puppet info") self.log.debug("Updating own puppet info")
own_info = (await self.fetchUserInfo(self.uid))[self.uid] own_info = (await self.fetchUserInfo(self.uid))[self.uid]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment