From 846e4884e72f115570a1d12967eb9eb6eff8b972 Mon Sep 17 00:00:00 2001
From: Tulir Asokan <tulir@maunium.net>
Date: Sat, 3 Aug 2019 01:43:07 +0300
Subject: [PATCH] Add bidirectional reaction bridging

---
 ROADMAP.md                                    |   3 +-
 .../1a1ea46dc3e1_add_reaction_table.py        |  37 +++++
 mautrix_facebook/db/__init__.py               |   3 +-
 mautrix_facebook/db/message.py                |   3 +-
 mautrix_facebook/db/reaction.py               |  63 ++++++++
 mautrix_facebook/matrix.py                    |  24 +++-
 mautrix_facebook/portal.py                    | 136 +++++++++++++++---
 mautrix_facebook/user.py                      | 120 +++++++++-------
 8 files changed, 316 insertions(+), 73 deletions(-)
 create mode 100644 alembic/versions/1a1ea46dc3e1_add_reaction_table.py
 create mode 100644 mautrix_facebook/db/reaction.py

diff --git a/ROADMAP.md b/ROADMAP.md
index a4bbc41..ace6436 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -14,6 +14,7 @@
     * [ ] Replies
     * [ ] Mentions
   * [x] Message redactions
+  * [x] Message reactions
   * [x] Presence
   * [x] Typing notifications
   * [x] Read receipts
@@ -40,7 +41,7 @@
     * [ ] Mentions
     * [ ] Polls
   * [x] Message unsend
-  * [ ] Message reactions (not yet implemented in Matrix)
+  * [x] Message reactions
   * [ ] Message history
   * [x] Presence
   * [ ] Typing notifications
diff --git a/alembic/versions/1a1ea46dc3e1_add_reaction_table.py b/alembic/versions/1a1ea46dc3e1_add_reaction_table.py
new file mode 100644
index 0000000..c647352
--- /dev/null
+++ b/alembic/versions/1a1ea46dc3e1_add_reaction_table.py
@@ -0,0 +1,37 @@
+"""Add reaction table
+
+Revision ID: 1a1ea46dc3e1
+Revises: 26341fb32054
+Create Date: 2019-08-03 00:40:21.380240
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '1a1ea46dc3e1'
+down_revision = '26341fb32054'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('reaction',
+    sa.Column('mxid', sa.String(length=255), nullable=False),
+    sa.Column('mx_room', sa.String(length=255), nullable=False),
+    sa.Column('fb_msgid', sa.String(length=127), nullable=False),
+    sa.Column('fb_receiver', sa.String(length=127), nullable=False),
+    sa.Column('fb_sender', sa.String(length=127), nullable=False),
+    sa.Column('reaction', sa.String(length=1), nullable=False),
+    sa.PrimaryKeyConstraint('fb_msgid', 'fb_receiver', 'fb_sender'),
+    sa.UniqueConstraint('mxid', 'mx_room', name='_mx_react_id_room')
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('reaction')
+    # ### end Alembic commands ###
diff --git a/mautrix_facebook/db/__init__.py b/mautrix_facebook/db/__init__.py
index b7f7ad7..7026c52 100644
--- a/mautrix_facebook/db/__init__.py
+++ b/mautrix_facebook/db/__init__.py
@@ -1,13 +1,14 @@
 from mautrix.bridge.db import RoomState, UserProfile
 
 from .message import Message
+from .reaction import Reaction
 from .portal import Portal
 from .puppet import Puppet
 from .user import User
 
 
 def init(db_engine) -> None:
-    for table in Portal, Message, User, Puppet, UserProfile, RoomState:
+    for table in Portal, Message, Reaction, User, Puppet, UserProfile, RoomState:
         table.db = db_engine
         table.t = table.__table__
         table.c = table.t.c
diff --git a/mautrix_facebook/db/message.py b/mautrix_facebook/db/message.py
index 57c04c3..181d828 100644
--- a/mautrix_facebook/db/message.py
+++ b/mautrix_facebook/db/message.py
@@ -20,7 +20,6 @@ from sqlalchemy.engine.result import RowProxy
 from sqlalchemy.sql.expression import ClauseElement
 
 from mautrix.types import RoomID, EventID
-
 from mautrix.bridge.db.base import Base
 
 
@@ -45,7 +44,7 @@ class Message(Base):
         return cls._select_all(cls.c.fbid == fbid, cls.c.fb_receiver == fb_receiver)
 
     @classmethod
-    def get_by_fbid(cls, fbid: str, fb_receiver: str, index: int) -> Optional['Message']:
+    def get_by_fbid(cls, fbid: str, fb_receiver: str, index: int = 0) -> Optional['Message']:
         return cls._select_one_or_none(and_(cls.c.fbid == fbid, cls.c.fb_receiver == fb_receiver,
                                             cls.c.index == index))
 
diff --git a/mautrix_facebook/db/reaction.py b/mautrix_facebook/db/reaction.py
new file mode 100644
index 0000000..b266450
--- /dev/null
+++ b/mautrix_facebook/db/reaction.py
@@ -0,0 +1,63 @@
+# 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 Optional, Iterable
+
+from sqlalchemy import Column, String, UniqueConstraint, and_
+from sqlalchemy.engine.result import RowProxy
+from sqlalchemy.sql.expression import ClauseElement
+
+from mautrix.types import RoomID, EventID
+from mautrix.bridge.db.base import Base
+
+
+class Reaction(Base):
+    __tablename__ = "reaction"
+
+    mxid: EventID = Column(String(255), nullable=False)
+    mx_room: RoomID = Column(String(255), nullable=False)
+    fb_msgid: str = Column(String(127), primary_key=True)
+    fb_receiver: str = Column(String(127), primary_key=True)
+    fb_sender: str = Column(String(127), primary_key=True)
+    reaction: str = Column(String(1), nullable=False)
+
+    __table_args__ = (UniqueConstraint("mxid", "mx_room", name="_mx_react_id_room"),)
+
+    @classmethod
+    def scan(cls, row: RowProxy) -> 'Reaction':
+        mxid, mx_room, fb_msgid, fb_receiver, fb_sender, reaction = row
+        return cls(mxid=mxid, mx_room=mx_room, fb_msgid=fb_msgid, fb_receiver=fb_receiver,
+                   fb_sender=fb_sender, reaction=reaction)
+
+    @classmethod
+    def get_by_fbid(cls, fb_msgid: str, fb_receiver: str, fb_sender: str) -> Optional['Reaction']:
+        return cls._select_one_or_none(and_(cls.c.fb_msgid == fb_msgid,
+                                            cls.c.fb_receiver == fb_receiver,
+                                            cls.c.fb_sender == fb_sender))
+
+    @classmethod
+    def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Reaction']:
+        return cls._select_one_or_none(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room))
+
+    @property
+    def _edit_identity(self) -> ClauseElement:
+        return and_(self.c.fb_msgid == self.fb_msgid, self.c.fb_receiver == self.fb_receiver,
+                    self.c.fb_sender == self.fb_sender)
+
+    def insert(self) -> None:
+        with self.db.begin() as conn:
+            conn.execute(self.t.insert().values(
+                mxid=self.mxid, mx_room=self.mx_room, fb_msgid=self.fb_msgid,
+                fb_receiver=self.fb_receiver, fb_sender=self.fb_sender, reaction=self.reaction))
diff --git a/mautrix_facebook/matrix.py b/mautrix_facebook/matrix.py
index b2c94fa..1102976 100644
--- a/mautrix_facebook/matrix.py
+++ b/mautrix_facebook/matrix.py
@@ -17,7 +17,8 @@ from typing import List, TYPE_CHECKING
 
 from fbchat.models import ThreadType
 from mautrix.types import (EventID, RoomID, UserID, Event, EventType, MessageEvent, StateEvent,
-                           RedactionEvent, PresenceEventContent, ReceiptEvent, PresenceState)
+                           RedactionEvent, PresenceEventContent, ReceiptEvent, PresenceState,
+                           ReactionEvent, ReactionEventContent, RelationType)
 from mautrix.errors import MatrixError
 from mautrix.bridge import BaseMatrixHandler
 
@@ -139,6 +140,24 @@ class MatrixHandler(BaseMatrixHandler):
 
         await portal.handle_matrix_redaction(user, event_id)
 
+    @classmethod
+    async def handle_reaction(cls, room_id: RoomID, user_id: UserID, event_id: EventID,
+                              content: ReactionEventContent) -> None:
+        if content.relates_to.rel_type != RelationType.ANNOTATION:
+            cls.log.debug(f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected "
+                          f"relation type {content.relates_to.rel_type}")
+            return
+        user = u.User.get_by_mxid(user_id)
+        if not user:
+            return
+
+        portal = po.Portal.get_by_mxid(room_id)
+        if not portal:
+            return
+
+        await portal.handle_matrix_reaction(user, event_id, content.relates_to.event_id,
+                                            content.relates_to.key)
+
     async def handle_presence(self, user_id: UserID, info: PresenceEventContent) -> None:
         if not self.config["bridge.presence"]:
             return
@@ -183,6 +202,9 @@ class MatrixHandler(BaseMatrixHandler):
         if evt.type == EventType.ROOM_REDACTION:
             evt: RedactionEvent
             await self.handle_redaction(evt.room_id, evt.sender, evt.redacts)
+        elif evt.type == EventType.REACTION:
+            evt: ReactionEvent
+            await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
         elif evt.type == EventType.PRESENCE:
             await self.handle_presence(evt.sender, evt.content)
         elif evt.type == EventType.TYPING:
diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py
index 0f0b441..cf79842 100644
--- a/mautrix_facebook/portal.py
+++ b/mautrix_facebook/portal.py
@@ -25,7 +25,7 @@ import magic
 from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup, Page as FBPage,
                            Message as FBMessage, Sticker as FBSticker, AudioAttachment,
                            VideoAttachment, FileAttachment, ImageAttachment, LocationAttachment,
-                           ShareAttachment, TypingStatus)
+                           ShareAttachment, TypingStatus, MessageReaction)
 from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID,
                            ImageInfo, MessageType, LocationMessageEventContent, LocationInfo,
                            ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format,
@@ -34,7 +34,7 @@ from mautrix.appservice import AppService, IntentAPI
 from mautrix.errors import MForbidden, IntentError, MatrixError
 
 from .config import Config
-from .db import Portal as DBPortal, Message as DBMessage
+from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
 from . import puppet as p, user as u
 
 if TYPE_CHECKING:
@@ -76,7 +76,7 @@ class Portal:
     _main_intent: Optional[IntentAPI]
     _create_room_lock: asyncio.Lock
     _last_bridged_mxid: Optional[EventID]
-    _dedup: Deque[Tuple[str, str]]
+    _dedup: Deque[str]
     _avatar_uri: Optional[ContentURI]
     _send_locks: Dict[str, asyncio.Lock]
     _noop_lock: FakeLock = FakeLock()
@@ -376,14 +376,56 @@ class Portal:
     async def handle_matrix_redaction(self, sender: 'u.User', event_id: EventID) -> None:
         if not self.mxid:
             return
+
         message = DBMessage.get_by_mxid(event_id, self.mxid)
-        if not message:
+        if message:
+            try:
+                message.delete()
+                await sender.unsend(message.fbid)
+            except Exception:
+                self.log.exception("Unsend failed")
             return
-        try:
-            message.delete()
-            await sender.unsend(message.fbid)
-        except Exception:
-            self.log.exception("Unsend failed")
+
+        reaction = DBReaction.get_by_mxid(event_id, self.mxid)
+        if reaction:
+            try:
+                reaction.delete()
+                await sender.reactToMessage(reaction.fb_msgid, reaction=None)
+            except Exception:
+                self.log.exception("Removing reaction failed")
+
+    _matrix_to_facebook_reaction = {
+        "❤": MessageReaction.HEART,
+        "❤️": MessageReaction.HEART,
+        "😍": MessageReaction.LOVE,
+        "😆": MessageReaction.SMILE,
+        "😮": MessageReaction.WOW,
+        "😢": MessageReaction.SAD,
+        "😠": MessageReaction.ANGRY,
+        "👍": MessageReaction.YES,
+        "👎": MessageReaction.NO
+    }
+
+    async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID,
+                                     reacting_to: EventID, raw_reaction: str) -> None:
+        async with self.require_send_lock(sender.uid):
+            try:
+                reaction = self._matrix_to_facebook_reaction[raw_reaction]
+            except KeyError:
+                return
+
+            message = DBMessage.get_by_mxid(reacting_to, self.mxid)
+            if not message:
+                self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
+                return
+
+            existing = DBReaction.get_by_fbid(message.fbid, self.fb_receiver, sender.uid)
+            if existing and existing.reaction == reaction.value:
+                return
+
+            await sender.reactToMessage(message.fbid, reaction)
+            await self._upsert_reaction(existing, self.main_intent, event_id, message, sender,
+                                        reaction.value)
 
     async def handle_matrix_leave(self, user: 'u.User') -> None:
         if self.is_direct:
@@ -404,6 +446,16 @@ class Portal:
     # endregion
     # region Facebook event handling
 
+    async def _bridge_own_message_pm(self, source: 'u.User', sender: 'p.Puppet', mid: str) -> bool:
+        if self.is_direct and sender.fbid == source.uid and not sender.is_real_user:
+            if self.invite_own_puppet_to_pm:
+                await self.main_intent.invite_user(self.mxid, sender.mxid)
+            elif self.az.state_store.get_membership(self.mxid, sender.mxid) != Membership.JOIN:
+                self.log.warn(f"Ignoring own {mid} in private chat because own puppet is not in"
+                              " room.")
+                return False
+        return True
+
     async def handle_facebook_message(self, source: 'u.User', sender: 'p.Puppet',
                                       message: FBMessage) -> None:
         async with self.optional_send_lock(sender.fbid):
@@ -416,13 +468,8 @@ class Portal:
             if not mxid:
                 # Failed to create
                 return
-        if self.is_direct and sender.fbid == source.uid and not sender.is_real_user:
-            if self.invite_own_puppet_to_pm:
-                await self.main_intent.invite_user(self.mxid, sender.mxid)
-            elif self.az.state_store.get_membership(self.mxid, sender.mxid) != Membership.JOIN:
-                self.log.warn(f"Ignoring own message {message.uid} in private chat because own"
-                              " puppet is not in room.")
-                return
+        if not await self._bridge_own_message_pm(source, sender, f"message {message.uid}"):
+            return
         intent = sender.intent_for(self)
         event_ids = []
         if message.sticker:
@@ -465,7 +512,8 @@ class Portal:
         # elif isinstance(attachment, VideoAttachment):
         # TODO
         elif isinstance(attachment, FileAttachment):
-            mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent, attachment.name)
+            mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent,
+                                                            attachment.name)
             event_id = await intent.send_file(self.mxid, mxc,
                                               info=FileInfo(size=size, mimetype=mime),
                                               file_name=attachment.name)
@@ -568,6 +616,60 @@ class Portal:
                   fbid=message_id, fb_receiver=self.fb_receiver,
                   index=0).insert()
 
+    async def handle_facebook_reaction_add(self, source: 'u.User', sender: 'p.Puppet',
+                                           message_id: str, reaction: str) -> None:
+        dedup_id = f"react_{message_id}_{sender}_{reaction}"
+        async with self.optional_send_lock(sender.fbid):
+            if dedup_id in self._dedup:
+                return
+            self._dedup.appendleft(dedup_id)
+
+        existing = DBReaction.get_by_fbid(message_id, self.fb_receiver, sender.fbid)
+        if existing and existing.reaction == reaction:
+            return
+
+        if not await self._bridge_own_message_pm(source, sender, f"reaction to {message_id}"):
+            return
+
+        intent = sender.intent_for(self)
+
+        message = DBMessage.get_by_fbid(message_id, self.fb_receiver)
+        if not message:
+            self.log.debug(f"Ignoring reaction to unknown message {message}")
+            return
+
+        mxid = await intent.react(message.mx_room, message.mxid, reaction)
+        self.log.debug(f"Reacted to {message.mxid}, got {mxid}")
+
+        await self._upsert_reaction(existing, intent, mxid, message, sender, reaction)
+
+    async def _upsert_reaction(self, existing: DBReaction, intent: IntentAPI, mxid: EventID,
+                               message: DBMessage, sender: Union['u.User', 'p.Puppet'],
+                               reaction: str) -> None:
+        if existing:
+            self.log.debug(f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}"
+                           f" (message: {message.mxid})")
+            await intent.redact(existing.mx_room, existing.mxid)
+            existing.edit(reaction=reaction, mxid=mxid, mx_room=message.mx_room)
+        else:
+            self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})")
+            DBReaction(mxid=mxid, mx_room=message.mx_room, fb_msgid=message.fbid,
+                       fb_receiver=self.fb_receiver, fb_sender=sender.fbid,
+                       reaction=reaction).insert()
+
+    async def handle_facebook_reaction_remove(self, source: 'u.User', sender: 'p.Puppet',
+                                              message_id: str) -> None:
+        if not self.mxid:
+            return
+        reaction = DBReaction.get_by_fbid(message_id, self.fb_receiver, sender.fbid)
+        if reaction:
+            self.log.debug(f"redacting {reaction.mxid}")
+            try:
+                await sender.intent_for(self).redact(reaction.mx_room, reaction.mxid)
+            except MForbidden:
+                await self.main_intent.redact(reaction.mx_room, reaction.mxid)
+            reaction.delete()
+
     # endregion
     # region Getters
 
diff --git a/mautrix_facebook/user.py b/mautrix_facebook/user.py
index 5cc20f2..3aee460 100644
--- a/mautrix_facebook/user.py
+++ b/mautrix_facebook/user.py
@@ -19,7 +19,7 @@ import asyncio
 import logging
 
 from fbchat import Client
-from fbchat.models import Message, ThreadType, User as FBUser, ActiveStatus
+from fbchat.models import Message, ThreadType, User as FBUser, ActiveStatus, MessageReaction
 from mautrix.types import UserID, PresenceState
 from mautrix.appservice import AppService
 
@@ -66,6 +66,10 @@ class User(Client):
 
     # region Sessions
 
+    @property
+    def fbid(self) -> str:
+        return self.uid
+
     @property
     def db_instance(self) -> DBUser:
         if not self._db_instance:
@@ -176,7 +180,7 @@ class User(Client):
         self.log.warn("Unexpected on2FACode call")
         # raise RuntimeError("No ongoing login command")
 
-    async def onLoggedIn(self, email=None) -> None:
+    async def onLoggedIn(self, email: str = None) -> None:
         """
         Called when the client is successfully logged in
 
@@ -235,7 +239,8 @@ class User(Client):
         await portal.handle_facebook_message(self, puppet, message_object)
 
     async def onColorChange(self, mid=None, author_id=None, new_color=None, thread_id=None,
-                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None):
+                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None
+                            ) -> None:
         """
         Called when the client is listening, and somebody changes a thread's color
 
@@ -257,7 +262,8 @@ class User(Client):
         )
 
     async def onEmojiChange(self, mid=None, author_id=None, new_emoji=None, thread_id=None,
-                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None):
+                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None
+                            ) -> None:
         """
         Called when the client is listening, and somebody changes a thread's emoji
 
@@ -278,7 +284,8 @@ class User(Client):
         )
 
     async def onTitleChange(self, mid=None, author_id=None, new_title=None, thread_id=None,
-                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None):
+                            thread_type=ThreadType.USER, ts=None, metadata=None, msg=None
+                            ) -> None:
         """
         Called when the client is listening, and somebody changes the title of a thread
 
@@ -301,8 +308,9 @@ class User(Client):
             return
         await portal.handle_facebook_name(self, sender, new_title, mid)
 
-    async def onImageChange(self, mid=None, author_id=None, new_image=None, thread_id=None,
-                            thread_type=ThreadType.GROUP, ts=None, msg=None):
+    async def onImageChange(self, mid: str = None, author_id: str = None, new_image: str = None,
+                            thread_id: str = None, thread_type: ThreadType = ThreadType.GROUP,
+                            ts: int = None, msg: Any = None) -> None:
         """
         Called when the client is listening, and somebody changes the image of a thread
 
@@ -326,7 +334,7 @@ class User(Client):
 
     async def onNicknameChange(self, mid=None, author_id=None, changed_for=None, new_nickname=None,
                                thread_id=None, thread_type=ThreadType.USER, ts=None, metadata=None,
-                               msg=None):
+                               msg=None) -> None:
         """
         Called when the client is listening, and somebody changes the nickname of a person
 
@@ -348,7 +356,7 @@ class User(Client):
         )
 
     async def onAdminAdded(self, mid=None, added_id=None, author_id=None, thread_id=None,
-                           thread_type=ThreadType.GROUP, ts=None, msg=None):
+                           thread_type=ThreadType.GROUP, ts=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody adds an admin to a group thread
 
@@ -356,6 +364,7 @@ class User(Client):
         :param added_id: The ID of the admin who got added
         :param author_id: The ID of the person who added the admins
         :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
+        :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
         :param ts: A timestamp of the action
         :param msg: A full set of the data recieved
         """
@@ -376,7 +385,8 @@ class User(Client):
         self.log.info("{} removed admin: {} in {}".format(author_id, removed_id, thread_id))
 
     async def onApprovalModeChange(self, mid=None, approval_mode=None, author_id=None,
-                                   thread_id=None, thread_type=ThreadType.GROUP, ts=None, msg=None):
+                                   thread_id=None, thread_type=ThreadType.GROUP, ts=None,
+                                   msg=None) -> None:
         """
         Called when the client is listening, and somebody changes approval mode in a group thread
 
@@ -413,7 +423,8 @@ class User(Client):
         await portal.handle_facebook_seen(self, puppet)
 
     async def onMessageDelivered(self, msg_ids=None, delivered_for=None, thread_id=None,
-                                 thread_type=ThreadType.USER, ts=None, metadata=None, msg=None):
+                                 thread_type=ThreadType.USER, ts=None, metadata=None, msg=None
+                                 ) -> None:
         """
         Called when the client is listening, and somebody marks messages as delivered
 
@@ -432,7 +443,8 @@ class User(Client):
             )
         )
 
-    async def onMarkedSeen(self, threads=None, seen_ts=None, ts=None, metadata=None, msg=None):
+    async def onMarkedSeen(self, threads=None, seen_ts=None, ts=None, metadata=None, msg=None
+                           ) -> None:
         """
         Called when the client is listening, and the client has successfully marked threads as seen
 
@@ -469,8 +481,8 @@ class User(Client):
         puppet = pu.Puppet.get_by_fbid(author_id)
         await portal.handle_facebook_unsend(self, puppet, mid)
 
-    async def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None, ts=None,
-                            msg=None):
+    async def onPeopleAdded(self, mid=None, added_ids=None, author_id=None, thread_id=None,
+                            ts=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody adds people to a group thread
 
@@ -486,7 +498,7 @@ class User(Client):
         )
 
     async def onPersonRemoved(self, mid=None, removed_id=None, author_id=None, thread_id=None,
-                              ts=None, msg=None):
+                              ts=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody removes a person from a group thread
 
@@ -499,7 +511,7 @@ class User(Client):
         """
         self.log.info("{} removed: {} in {}".format(author_id, removed_id, thread_id))
 
-    async def onFriendRequest(self, from_id=None, msg=None):
+    async def onFriendRequest(self, from_id=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody sends a friend request
 
@@ -508,7 +520,7 @@ class User(Client):
         """
         self.log.info("Friend request from {}".format(from_id))
 
-    async def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None):
+    async def onInbox(self, unseen=None, unread=None, recent_unread=None, msg=None) -> None:
         """
         .. todo::
             Documenting this
@@ -521,7 +533,7 @@ class User(Client):
         self.log.info("Inbox event: {}, {}, {}".format(unseen, unread, recent_unread))
 
     async def onTyping(self, author_id=None, status=None, thread_id=None, thread_type=None,
-                       msg=None):
+                       msg=None) -> None:
         """
         Called when the client is listening, and somebody starts or stops typing into a chat
 
@@ -535,9 +547,9 @@ class User(Client):
         """
         self.log.info(f"User is typing: {author_id} {status} in {thread_id} {thread_type}")
 
-    async def onGamePlayed(self, mid=None, author_id=None, game_id=None, game_name=None, score=None,
-                           leaderboard=None, thread_id=None, thread_type=None, ts=None,
-                           metadata=None, msg=None):
+    async def onGamePlayed(self, mid=None, author_id=None, game_id=None, game_name=None,
+                           score=None, leaderboard=None, thread_id=None, thread_type=None, ts=None,
+                           metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody plays a game
 
@@ -560,14 +572,15 @@ class User(Client):
             )
         )
 
-    async def onReactionAdded(self, mid=None, reaction=None, author_id=None, thread_id=None,
-                              thread_type=None, ts=None, msg=None):
+    async def onReactionAdded(self, mid: str = None, reaction: MessageReaction = None,
+                              author_id: str = None, thread_id: str = None,
+                              thread_type: ThreadType = None, ts: int = None, msg: Any = None
+                              ) -> None:
         """
         Called when the client is listening, and somebody reacts to a message
 
         :param mid: Message ID, that user reacted to
         :param reaction: Reaction
-        :param add_reaction: Whether user added or removed reaction
         :param author_id: The ID of the person who reacted to the message
         :param thread_id: Thread ID that the action was sent to. See :ref:`intro_threads`
         :param thread_type: Type of thread that the action was sent to. See :ref:`intro_threads`
@@ -576,14 +589,16 @@ class User(Client):
         :type reaction: models.MessageReaction
         :type thread_type: models.ThreadType
         """
-        self.log.info(
-            "{} reacted to message {} with {} in {} ({})".format(
-                author_id, mid, reaction.name, thread_id, thread_type.name
-            )
-        )
+        self.log.debug(f"onReactionAdded({mid}, {reaction}, {author_id}, {thread_id}, "
+                       f"{thread_type})")
+        fb_receiver = self.uid if thread_type == ThreadType.USER else None
+        portal = po.Portal.get_by_fbid(thread_id, fb_receiver, thread_type)
+        puppet = pu.Puppet.get_by_fbid(author_id)
+        await portal.handle_facebook_reaction_add(self, puppet, mid, reaction.value)
 
-    async def onReactionRemoved(self, mid=None, author_id=None, thread_id=None, thread_type=None,
-                                ts=None, msg=None):
+    async def onReactionRemoved(self, mid: str = None, author_id: str = None,
+                                thread_id: str = None, thread_type: ThreadType = None,
+                                ts: int = None, msg: Any = None) -> None:
         """
         Called when the client is listening, and somebody removes reaction from a message
 
@@ -595,13 +610,14 @@ class User(Client):
         :param msg: A full set of the data recieved
         :type thread_type: models.ThreadType
         """
-        self.log.info(
-            "{} removed reaction from {} message in {} ({})".format(
-                author_id, mid, thread_id, thread_type
-            )
-        )
+        self.log.debug(f"onReactionRemoved({mid}, {author_id}, {thread_id}, {thread_type})")
+        fb_receiver = self.uid if thread_type == ThreadType.USER else None
+        portal = po.Portal.get_by_fbid(thread_id, fb_receiver, thread_type)
+        puppet = pu.Puppet.get_by_fbid(author_id)
+        await portal.handle_facebook_reaction_remove(self, puppet, mid)
 
-    async def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
+    async def onBlock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None
+                      ) -> None:
         """
         Called when the client is listening, and somebody blocks client
 
@@ -616,7 +632,8 @@ class User(Client):
             "{} blocked {} ({}) thread".format(author_id, thread_id, thread_type.name)
         )
 
-    async def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None):
+    async def onUnblock(self, author_id=None, thread_id=None, thread_type=None, ts=None, msg=None
+                        ) -> None:
         """
         Called when the client is listening, and somebody blocks client
 
@@ -632,7 +649,7 @@ class User(Client):
         )
 
     async def onLiveLocation(self, mid=None, location=None, author_id=None, thread_id=None,
-                             thread_type=None, ts=None, msg=None, ):
+                             thread_type=None, ts=None, msg=None) -> None:
         """
         Called when the client is listening and somebody sends live location info
 
@@ -653,7 +670,7 @@ class User(Client):
         )
 
     async def onCallStarted(self, mid=None, caller_id=None, is_video_call=None, thread_id=None,
-                            thread_type=None, ts=None, metadata=None, msg=None):
+                            thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         .. todo::
             Make this work with private calls
@@ -675,7 +692,8 @@ class User(Client):
         )
 
     async def onCallEnded(self, mid=None, caller_id=None, is_video_call=None, call_duration=None,
-                          thread_id=None, thread_type=None, ts=None, metadata=None, msg=None):
+                          thread_id=None, thread_type=None, ts=None, metadata=None, msg=None
+                          ) -> None:
         """
         .. todo::
             Make this work with private calls
@@ -698,7 +716,7 @@ class User(Client):
         )
 
     async def onUserJoinedCall(self, mid=None, joined_id=None, is_video_call=None, thread_id=None,
-                               thread_type=None, ts=None, metadata=None, msg=None):
+                               thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody joins a group call
 
@@ -717,7 +735,7 @@ class User(Client):
         )
 
     async def onPollCreated(self, mid=None, poll=None, author_id=None, thread_id=None,
-                            thread_type=None, ts=None, metadata=None, msg=None):
+                            thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody creates a group poll
 
@@ -740,7 +758,7 @@ class User(Client):
 
     async def onPollVoted(self, mid=None, poll=None, added_options=None, removed_options=None,
                           author_id=None, thread_id=None, thread_type=None, ts=None, metadata=None,
-                          msg=None):
+                          msg=None) -> None:
         """
         Called when the client is listening, and somebody votes in a group poll
 
@@ -762,7 +780,7 @@ class User(Client):
         )
 
     async def onPlanCreated(self, mid=None, plan=None, author_id=None, thread_id=None,
-                            thread_type=None, ts=None, metadata=None, msg=None):
+                            thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody creates a plan
 
@@ -803,7 +821,7 @@ class User(Client):
         )
 
     async def onPlanEdited(self, mid=None, plan=None, author_id=None, thread_id=None,
-                           thread_type=None, ts=None, metadata=None, msg=None):
+                           thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody edits a plan
 
@@ -825,7 +843,7 @@ class User(Client):
         )
 
     async def onPlanDeleted(self, mid=None, plan=None, author_id=None, thread_id=None,
-                            thread_type=None, ts=None, metadata=None, msg=None):
+                            thread_type=None, ts=None, metadata=None, msg=None) -> None:
         """
         Called when the client is listening, and somebody deletes a plan
 
@@ -848,7 +866,7 @@ class User(Client):
 
     async def onPlanParticipation(self, mid=None, plan=None, take_part=None, author_id=None,
                                   thread_id=None, thread_type=None, ts=None, metadata=None,
-                                  msg=None):
+                                  msg=None) -> None:
         """
         Called when the client is listening, and somebody takes part in a plan or not
 
@@ -878,7 +896,7 @@ class User(Client):
                 )
             )
 
-    async def onQprimer(self, ts=None, msg=None):
+    async def onQprimer(self, ts=None, msg=None) -> None:
         """
         Called when the client just started listening
 
@@ -913,7 +931,7 @@ class User(Client):
         """
         await self.onChatTimestamp(statuses, msg)
 
-    async def onUnknownMesssageType(self, msg=None):
+    async def onUnknownMesssageType(self, msg: Any = None) -> None:
         """
         Called when the client is listening, and some unknown data was recieved
 
@@ -921,7 +939,7 @@ class User(Client):
         """
         self.log.debug("Unknown message received: {}".format(msg))
 
-    async def onMessageError(self, exception=None, msg=None):
+    async def onMessageError(self, exception: Exception = None, msg: Any = None) -> None:
         """
         Called when an error was encountered while parsing recieved data
 
-- 
GitLab