Эх сурвалжийг харах

Add support for Instagram->Matrix reactions

Tulir Asokan 4 жил өмнө
parent
commit
a71e6316a9

+ 1 - 1
ROADMAP.md

@@ -23,7 +23,7 @@
       * [ ] Voice messages
       * [ ] Voice messages
       * [ ] Locations
       * [ ] Locations
   * [x] Message unsend
   * [x] Message unsend
-  * [ ] Message reactions
+  * [x] Message reactions
   * [x] Message history
   * [x] Message history
   * [ ] Presence
   * [ ] Presence
   * [ ] Typing notifications
   * [ ] Typing notifications

+ 1 - 1
mauigpapi/types/__init__.py

@@ -12,7 +12,7 @@ from .thread_item import (ThreadItemType, ThreadItemActionLog, ViewMode, Creativ
                           CreateModeAttribution, ImageVersion, ImageVersions, VisualMedia, Caption,
                           CreateModeAttribution, ImageVersion, ImageVersions, VisualMedia, Caption,
                           RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
                           RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
                           AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
                           AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
-                          AnimatedMediaItem, ThreadItem, VoiceMediaData)
+                          AnimatedMediaItem, ThreadItem, VoiceMediaData, Reaction, Reactions)
 from .thread import Thread, ThreadUser, ThreadItem, ThreadUserLastSeenAt, ThreadTheme
 from .thread import Thread, ThreadUser, ThreadItem, ThreadUserLastSeenAt, ThreadTheme
 from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
 from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,

+ 1 - 17
mauigpapi/types/mqtt.py

@@ -56,6 +56,7 @@ class CommandResponsePayload(SerializableAttrs['CommandResponsePayload']):
     item_id: Optional[str] = None
     item_id: Optional[str] = None
     timestamp: Optional[str] = None
     timestamp: Optional[str] = None
     thread_id: Optional[str] = None
     thread_id: Optional[str] = None
+    message: Optional[str] = None
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
@@ -84,22 +85,6 @@ class IrisPayload(SerializableAttrs['IrisPayload']):
     sampled: Optional[bool] = None
     sampled: Optional[bool] = None
 
 
 
 
-@dataclass
-class Reaction(SerializableAttrs['Reaction']):
-    sender_id: int
-    timestamp: int
-    client_context: int
-    emoji: str = "❤️"
-    super_react_type: Optional[str] = None
-
-
-@dataclass
-class Reactions(SerializableAttrs['Reactions']):
-    likes_count: int = 0
-    likes: List[Reaction] = attr.ib(factory=lambda: [])
-    emojis: List[Reaction] = attr.ib(factory=lambda: [])
-
-
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
 class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     path: str
     path: str
@@ -109,7 +94,6 @@ class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     admin_user_ids: Optional[int] = None
     admin_user_ids: Optional[int] = None
     approval_required_for_new_members: Optional[bool] = None
     approval_required_for_new_members: Optional[bool] = None
     participants: Optional[Dict[str, str]] = None
     participants: Optional[Dict[str, str]] = None
-    reactions: Optional[Reactions] = None
     thread_id: Optional[str] = None
     thread_id: Optional[str] = None
 
 
 
 

+ 19 - 2
mauigpapi/types/thread_item.py

@@ -13,8 +13,9 @@
 #
 #
 # You should have received a copy of the GNU Affero General Public License
 # 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/>.
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import List, Any, Dict, Optional
+from typing import List, Any, Optional
 
 
+import attr
 from attr import dataclass
 from attr import dataclass
 from mautrix.types import SerializableAttrs, SerializableEnum
 from mautrix.types import SerializableAttrs, SerializableEnum
 
 
@@ -272,6 +273,22 @@ class AnimatedMediaItem(SerializableAttrs['AnimatedMediaItem']):
     images: AnimatedMediaImages
     images: AnimatedMediaImages
 
 
 
 
+@dataclass
+class Reaction(SerializableAttrs['Reaction']):
+    sender_id: int
+    timestamp: int
+    client_context: int
+    emoji: str = "❤️"
+    super_react_type: Optional[str] = None
+
+
+@dataclass
+class Reactions(SerializableAttrs['Reactions']):
+    likes_count: int = 0
+    likes: List[Reaction] = attr.ib(factory=lambda: [])
+    emojis: List[Reaction] = attr.ib(factory=lambda: [])
+
+
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class ThreadItem(SerializableAttrs['ThreadItem']):
 class ThreadItem(SerializableAttrs['ThreadItem']):
     item_id: Optional[str] = None
     item_id: Optional[str] = None
@@ -285,9 +302,9 @@ class ThreadItem(SerializableAttrs['ThreadItem']):
     show_forward_attribution: Optional[bool] = None
     show_forward_attribution: Optional[bool] = None
     action_log: Optional[ThreadItemActionLog] = None
     action_log: Optional[ThreadItemActionLog] = None
 
 
-    # These have only been observed over MQTT and not confirmed in direct_inbox
     media: Optional[RegularMediaItem] = None
     media: Optional[RegularMediaItem] = None
     voice_media: Optional[VoiceMediaItem] = None
     voice_media: Optional[VoiceMediaItem] = None
     animated_media: Optional[AnimatedMediaItem] = None
     animated_media: Optional[AnimatedMediaItem] = None
     visual_media: Optional[VisualMedia] = None
     visual_media: Optional[VisualMedia] = None
     media_share: Optional[MediaShareItem] = None
     media_share: Optional[MediaShareItem] = None
+    reactions: Optional[Reactions] = None

+ 13 - 1
mautrix_instagram/db/reaction.py

@@ -13,7 +13,7 @@
 #
 #
 # You should have received a copy of the GNU Affero General Public License
 # 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/>.
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Optional, ClassVar, TYPE_CHECKING
+from typing import Optional, ClassVar, List, TYPE_CHECKING
 
 
 from attr import dataclass
 from attr import dataclass
 
 
@@ -68,3 +68,15 @@ class Reaction:
         if not row:
         if not row:
             return None
             return None
         return cls(**row)
         return cls(**row)
+
+    @classmethod
+    async def count(cls, ig_item_id: str, ig_receiver: int) -> int:
+        q = "SELECT COUNT(*) FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2"
+        return await cls.db.fetchval(q, ig_item_id, ig_receiver)
+
+    @classmethod
+    async def get_all_by_item_id(cls, ig_item_id: str, ig_receiver: int) -> List['Reaction']:
+        q = ("SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
+             "FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2")
+        rows = await cls.db.fetch(q, ig_item_id, ig_receiver)
+        return [cls(**row) for row in rows]

+ 33 - 7
mautrix_instagram/portal.py

@@ -24,7 +24,7 @@ import magic
 from yarl import URL
 from yarl import URL
 
 
 from mauigpapi.types import (Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType,
 from mauigpapi.types import (Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType,
-                             ReactionStatus)
+                             ReactionStatus, Reaction)
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.bridge import BasePortal, NotificationDisabler
 from mautrix.bridge import BasePortal, NotificationDisabler
 from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, ImageInfo,
 from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, ImageInfo,
@@ -297,8 +297,8 @@ class Portal(DBPortal, BasePortal):
         content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text)
         content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text)
         return await self._send_message(intent, content, timestamp=item.timestamp // 1000)
         return await self._send_message(intent, content, timestamp=item.timestamp // 1000)
 
 
-    async def handle_instagram_item(self, source: 'u.User', sender: 'p.Puppet', item: ThreadItem
-                                    ) -> None:
+    async def handle_instagram_item(self, source: 'u.User', sender: 'p.Puppet', item: ThreadItem,
+                                    is_backfill: bool = False) -> None:
         if item.client_context in self._reqid_dedup:
         if item.client_context in self._reqid_dedup:
             self.log.debug(f"Ignoring message {item.item_id} by {item.user_id}"
             self.log.debug(f"Ignoring message {item.item_id} by {item.user_id}"
                            " as it was sent by us (client_context in dedup queue)")
                            " as it was sent by us (client_context in dedup queue)")
@@ -324,12 +324,15 @@ class Portal(DBPortal, BasePortal):
                 event_id = await self._handle_instagram_media(source, intent, item)
                 event_id = await self._handle_instagram_media(source, intent, item)
             elif item.text:
             elif item.text:
                 event_id = await self._handle_instagram_text(intent, item)
                 event_id = await self._handle_instagram_text(intent, item)
-            # TODO handle attachments and reactions
+            # TODO handle other attachments
             if event_id:
             if event_id:
-                await DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
-                                receiver=self.receiver, sender=sender.pk).insert()
+                msg = DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
+                                receiver=self.receiver, sender=sender.pk)
+                await msg.insert()
                 await self._send_delivery_receipt(event_id)
                 await self._send_delivery_receipt(event_id)
                 self.log.debug(f"Handled Instagram message {item.item_id} -> {event_id}")
                 self.log.debug(f"Handled Instagram message {item.item_id} -> {event_id}")
+                if is_backfill and item.reactions:
+                    await self._handle_instagram_reactions(msg, item.reactions.emojis)
             else:
             else:
                 self.log.debug(f"Unhandled Instagram message {item.item_id}")
                 self.log.debug(f"Unhandled Instagram message {item.item_id}")
 
 
@@ -345,6 +348,29 @@ class Portal(DBPortal, BasePortal):
             await self.main_intent.redact(self.mxid, message.mxid)
             await self.main_intent.redact(self.mxid, message.mxid)
         self.log.debug(f"Redacted {message.mxid} after Instagram unsend")
         self.log.debug(f"Redacted {message.mxid} after Instagram unsend")
 
 
+    async def _handle_instagram_reactions(self, message: DBMessage, reactions: List[Reaction]
+                                          ) -> None:
+        old_reactions: Dict[int, DBReaction]
+        old_reactions = {reaction.ig_sender: reaction for reaction
+                         in await DBReaction.get_all_by_item_id(message.item_id, self.receiver)}
+        for new_reaction in reactions:
+            old_reaction = old_reactions.get(new_reaction.sender_id)
+            if old_reaction and old_reaction.reaction == new_reaction.emoji:
+                continue
+            puppet = await p.Puppet.get_by_pk(new_reaction.sender_id)
+            intent = puppet.intent_for(self)
+            reaction_event_id = await intent.react(self.mxid, message.mxid, new_reaction.emoji)
+            await self._upsert_reaction(old_reaction, intent, reaction_event_id, message,
+                                        puppet, new_reaction.emoji)
+
+    async def handle_instagram_update(self, item: ThreadItem) -> None:
+        message = await DBMessage.get_by_item_id(item.item_id, self.receiver)
+        if not message:
+            return
+        async with self._reaction_lock:
+            await self._handle_instagram_reactions(message, (item.reactions.emojis
+                                                             if item.reactions else []))
+
     # endregion
     # endregion
     # region Updating portal info
     # region Updating portal info
 
 
@@ -409,7 +435,7 @@ class Portal(DBPortal, BasePortal):
         async with NotificationDisabler(self.mxid, source):
         async with NotificationDisabler(self.mxid, source):
             for entry in reversed(entries):
             for entry in reversed(entries):
                 sender = await p.Puppet.get_by_pk(int(entry.user_id))
                 sender = await p.Puppet.get_by_pk(int(entry.user_id))
-                await self.handle_instagram_item(source, sender, entry)
+                await self.handle_instagram_item(source, sender, entry, is_backfill=True)
         for intent in self._backfill_leave:
         for intent in self._backfill_leave:
             self.log.trace("Leaving room with %s post-backfill", intent.mxid)
             self.log.trace("Leaving room with %s post-backfill", intent.mxid)
             await intent.leave_room(self.mxid)
             await intent.leave_room(self.mxid)

+ 2 - 0
mautrix_instagram/user.py

@@ -251,6 +251,8 @@ class User(DBUser, BaseUser):
         elif evt.message.op == Operation.REMOVE:
         elif evt.message.op == Operation.REMOVE:
             # Removes don't have a sender, only the message sender can unsend messages anyway
             # Removes don't have a sender, only the message sender can unsend messages anyway
             await portal.handle_instagram_remove(evt.message.item_id)
             await portal.handle_instagram_remove(evt.message.item_id)
+        elif evt.message.op == Operation.REPLACE:
+            await portal.handle_instagram_update(evt.message)
 
 
     # @async_time(METRIC_RECEIPT)
     # @async_time(METRIC_RECEIPT)
     # async def handle_receipt(self, evt: ConversationReadEntry) -> None:
     # async def handle_receipt(self, evt: ConversationReadEntry) -> None: