diff --git a/ROADMAP.md b/ROADMAP.md index bed7333963665c2c825a3ae183e5d8c3a425f8bc..be7a7022c246986cb827ff29acd0fba2b193c4a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,10 @@ * [ ] Title * [ ] Avatar * [x] Initial chat metadata - * [x] User metadata (displayname/avatar) + * [ ] User metadata + * [x] Name + * [ ] Per-chat nickname + * [x] Avatar * Misc * [x] Multi-user support * [x] Shared group chat portals diff --git a/alembic/versions/3c5af010538a_add_fb_receiver_to_message.py b/alembic/versions/3c5af010538a_add_fb_receiver_to_message.py new file mode 100644 index 0000000000000000000000000000000000000000..b0adec98111e8e69b547fbfc5470b6e78535d4da --- /dev/null +++ b/alembic/versions/3c5af010538a_add_fb_receiver_to_message.py @@ -0,0 +1,38 @@ +"""Add fb_receiver to Message + +Revision ID: 3c5af010538a +Revises: c36b294b1f5f +Create Date: 2019-05-01 19:51:32.891102 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '3c5af010538a' +down_revision = 'c36b294b1f5f' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_table('message') + op.create_table('message', + sa.Column('mxid', sa.String(length=255), nullable=True), + sa.Column('mx_room', sa.String(length=255), nullable=True), + sa.Column('fbid', sa.String(length=127), nullable=False), + sa.Column('fb_receiver', sa.String(length=127), nullable=False), + sa.Column('index', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('fbid', 'fb_receiver', 'index'), + sa.UniqueConstraint('mxid', 'mx_room', name='_mx_id_room')) + + +def downgrade(): + op.drop_table('message') + op.create_table('message', + sa.Column('mxid', sa.String(length=255), nullable=True), + sa.Column('mx_room', sa.String(length=255), nullable=True), + sa.Column('fbid', sa.String(length=127), nullable=False), + sa.Column('index', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('fbid', 'index'), + sa.UniqueConstraint('mxid', 'mx_room', name='_mx_id_room')) diff --git a/mautrix_facebook/db/message.py b/mautrix_facebook/db/message.py index e66b75ac9aba565461f8dc27bb03007bdc082bf6..e36ce0922e2306fedf2c1873dff9eb4fa2e81156 100644 --- a/mautrix_facebook/db/message.py +++ b/mautrix_facebook/db/message.py @@ -29,56 +29,47 @@ class Message(Base): mxid: EventID = Column(String(255)) mx_room: RoomID = Column(String(255)) fbid: str = Column(String(127), primary_key=True) + fb_receiver: str = Column(String(127), primary_key=True) index: int = Column(SmallInteger, primary_key=True, default=0) __table_args__ = (UniqueConstraint("mxid", "mx_room", name="_mx_id_room"),) @classmethod def scan(cls, row: RowProxy) -> 'Message': - mxid, mx_room, fbid, index = row - return cls(mxid=mxid, mx_room=mx_room, fbid=fbid, index=index) + mxid, mx_room, fbid, fb_receiver, index = row + return cls(mxid=mxid, mx_room=mx_room, fbid=fbid, fb_receiver=fb_receiver, index=index) @classmethod - def get_all_by_fbid(cls, fbid: str) -> Iterable['Message']: - return cls._select_all(cls.c.fbid == fbid) + def get_all_by_fbid(cls, fbid: str, fb_receiver: str) -> Iterable['Message']: + return cls._select_all(cls.c.fbid == fbid, cls.c.fb_receiver == fb_receiver) @classmethod - def get_by_fbid(cls, fbid: str, index: int) -> Optional['Message']: - return cls._select_one_or_none(and_(cls.c.fbid == fbid, cls.c.index == index)) + def get_by_fbid(cls, fbid: str, fb_receiver: str, index: int) -> Optional['Message']: + return cls._select_one_or_none(and_(cls.c.fbid == fbid, cls.c.fb_receiver == fb_receiver, + cls.c.index == index)) @classmethod def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']: return cls._select_one_or_none(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)) - @classmethod - def update_by_fbid(cls, s_fbid: str, s_index: int, - **values) -> None: - with cls.db.begin() as conn: - conn.execute(cls.t.update() - .where(and_(cls.c.fbid == s_fbid, cls.c.index == s_index)) - .values(**values)) - - @classmethod - def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None: - with cls.db.begin() as conn: - conn.execute(cls.t.update() - .where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room)) - .values(**values)) - @property def _edit_identity(self): - return and_(self.c.fbid == self.fbid, self.c.index == self.index) + return and_(self.c.fbid == self.fbid, self.c.fb_receiver == self.fb_receiver, + self.c.index == self.index) @classmethod - def bulk_create(cls, fbid: str, event_ids: List[EventID], mx_room: RoomID) -> None: + def bulk_create(cls, fbid: str, fb_receiver: str, event_ids: List[EventID], mx_room: RoomID + ) -> None: if not event_ids: return with cls.db.begin() as conn: conn.execute(cls.t.insert(), - [dict(mxid=event_id, mx_room=mx_room, fbid=fbid, index=i) + [dict(mxid=event_id, mx_room=mx_room, fbid=fbid, fb_receiver=fb_receiver, + index=i) for i, event_id in enumerate(event_ids)]) def insert(self) -> None: with self.db.begin() as conn: conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room, - fbid=self.fbid, index=self.index)) + fb_receiver=self.fb_receiver, fbid=self.fbid, + index=self.index)) diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py index c1b03d5d0e97de5c7b3221677d4033a6cac5816f..1bc7a663d40392b30a4664a93543c3cd945f952e 100644 --- a/mautrix_facebook/portal.py +++ b/mautrix_facebook/portal.py @@ -15,6 +15,7 @@ # along with this program. If not, see <https://www.gnu.org/licenses/>. from typing import Dict, Deque, Optional, Tuple, Union, TYPE_CHECKING from collections import deque +from contextlib import asynccontextmanager import asyncio import logging @@ -69,6 +70,7 @@ class Portal: _last_bridged_mxid: EventID _dedup: Deque[Tuple[str, str]] _avatar_uri: Optional[ContentURI] + _send_locks: Dict[str, asyncio.Lock] def __init__(self, fbid: str, fb_receiver: str, fb_type: ThreadType, mxid: Optional[RoomID] = None, name: str = "", photo_id: str = "", @@ -87,6 +89,7 @@ class Portal: self._create_room_lock = asyncio.Lock() self._dedup = deque(maxlen=100) self._avatar_uri = None + self._send_locks = {} self.log = self.log.getChild(self.fbid_log) @@ -249,21 +252,47 @@ class Portal: # endregion # region Matrix event handling + @asynccontextmanager + async def require_send_lock(self, user_id: str) -> None: + try: + lock = self._send_locks[user_id] + except KeyError: + lock = asyncio.Lock() + self._send_locks[user_id] = lock + async with lock: + yield + + @asynccontextmanager + async def optional_send_lock(self, user_id: str) -> None: + try: + lock = self._send_locks[user_id] + except KeyError: + yield + return + async with lock: + yield + async def handle_matrix_message(self, sender: 'u.User', message: MessageEventContent, event_id: EventID) -> None: - if message.msgtype == MessageType.TEXT or message.msgtype == MessageType.NOTICE: - fbid = await self._handle_matrix_text(sender, message) - elif message.msgtype == MessageType.IMAGE: - fbid = await self._handle_matrix_image(sender, message) - elif message.msgtype == MessageType.LOCATION: - fbid = await self._handle_matrix_location(sender, message) - else: - self.log.warn(f"Unsupported msgtype in {message}") - return - if not fbid: - return - DBMessage(mxid=event_id, mx_room=self.mxid, fbid=fbid, index=0).insert() - self._last_bridged_mxid = event_id + # TODO this probably isn't nice for bridging images, it really only needs to lock the + # actual message send call and dedup queue append. + async with self.require_send_lock(sender.uid): + if message.msgtype == MessageType.TEXT or message.msgtype == MessageType.NOTICE: + fbid = await self._handle_matrix_text(sender, message) + elif message.msgtype == MessageType.IMAGE: + fbid = await self._handle_matrix_image(sender, message) + elif message.msgtype == MessageType.LOCATION: + fbid = await self._handle_matrix_location(sender, message) + else: + self.log.warn(f"Unsupported msgtype in {message}") + return + if not fbid: + return + self._dedup.appendleft(fbid) + DBMessage(mxid=event_id, mx_room=self.mxid, + fbid=fbid, fb_receiver=self.fb_receiver, + index=0).insert() + self._last_bridged_mxid = event_id async def _handle_matrix_text(self, sender: 'u.User', message: TextMessageEventContent) -> str: return await sender.send(FBMessage(text=message.body), self.fbid, self.fb_type) @@ -296,10 +325,11 @@ class Portal: async def handle_facebook_message(self, source: 'u.User', sender: 'p.Puppet', message: FBMessage) -> None: - if message.uid in self._dedup: - await source.markAsDelivered(self.fbid, message.uid) - return - self._dedup.appendleft(message.uid) + async with self.optional_send_lock(sender.fbid): + if message.uid in self._dedup: + await source.markAsDelivered(self.fbid, message.uid) + return + self._dedup.appendleft(message.uid) if not self.mxid: await self.create_matrix_room(source) if message.sticker: @@ -313,7 +343,7 @@ class Portal: else: self.log.debug(f"Unhandled Messenger message: {message}") event_ids = [] - DBMessage.bulk_create(fbid=message.uid, mx_room=self.mxid, + DBMessage.bulk_create(fbid=message.uid, fb_receiver=self.fb_receiver, mx_room=self.mxid, event_ids=[event_id for event_id in event_ids if event_id]) await source.markAsDelivered(self.fbid, message.uid) @@ -331,7 +361,7 @@ class Portal: text=sticker.label) async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass - ) -> EventID: + ) -> Optional[EventID]: if isinstance(attachment, AudioAttachment): mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent, attachment.filename) @@ -390,7 +420,7 @@ class Portal: ) -> None: if not self.mxid: return - for message in DBMessage.get_all_by_fbid(message_id): + for message in DBMessage.get_all_by_fbid(message_id, self.fb_receiver): try: await sender.intent.redact(message.mx_room, message.mxid) except MForbidden: diff --git a/mautrix_facebook/user.py b/mautrix_facebook/user.py index 18981b519743198a255f4f1e3e86872097458237..a134b5a0b1ea046f980c61de3ae18e8d85c59aea 100644 --- a/mautrix_facebook/user.py +++ b/mautrix_facebook/user.py @@ -49,7 +49,7 @@ class User(Client): is_whitelisted: bool is_admin: bool _is_logged_in: Optional[bool] - _session_data: SimpleCookie + _session_data: Optional[SimpleCookie] _db_instance: Optional[DBUser] def __init__(self, mxid: UserID, session: Optional[SimpleCookie] = None, @@ -78,9 +78,10 @@ class User(Client): self._db_instance = DBUser(mxid=self.mxid) return self._db_instance - def save(self) -> None: + def save(self, _update_session_data: bool = True) -> None: self.log.debug("Saving session") - self._session_data = self.getSession() + if _update_session_data: + self._session_data = self.getSession() self.db_instance.edit(session=self._session_data, fbid=self.uid) @classmethod @@ -131,6 +132,12 @@ class User(Client): # endregion + async def logout(self) -> bool: + ok = await super().logout() + self._session_data = None + self.save(_update_session_data=False) + return ok + async def post_login(self) -> None: self.log.info("Running post-login actions") await self.sync_threads() diff --git a/requirements.txt b/requirements.txt index aa8f1ab89efdee5b30881c2cd43b03a7b041b1de..f377a99b9ecfdd083d4c92fd6b58670f1e820bc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ SQLAlchemy alembic ruamel.yaml commonmark +python-magic git+https://github.com/tulir/fbchat-asyncio@master#egg=fbchat-asyncio git+https://github.com/tulir/mautrix-python@matrix-restructure#egg=mautrix diff --git a/setup.py b/setup.py index 1b6b466e1288f22743bb34ad427dd7983dd3c652..77bd21e0d93e67bccf5aade7f1d03bc2bab83ee0 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ setuptools.setup( "mautrix>=0.4.0.dev31,<0.5.0", "ruamel.yaml>=0.15.94,<0.16", "commonmark>=0.8,<0.9", + "python-magic>=0.4,<0.5", "fbchat-asyncio>=0.1.1,<0.2.0", "SQLAlchemy>=1.2,<2", "alembic>=1,<2",