Browse Source

Add support for Instagram->Matrix reactions

Tulir Asokan 4 năm trước cách đây
mục cha
commit
a71e6316a9

+ 1 - 1
ROADMAP.md

@@ -23,7 +23,7 @@
       * [ ] Voice messages
       * [ ] Locations
   * [x] Message unsend
-  * [ ] Message reactions
+  * [x] Message reactions
   * [x] Message history
   * [ ] Presence
   * [ ] 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,
                           RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
                           AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
-                          AnimatedMediaItem, ThreadItem, VoiceMediaData)
+                          AnimatedMediaItem, ThreadItem, VoiceMediaData, Reaction, Reactions)
 from .thread import Thread, ThreadUser, ThreadItem, ThreadUserLastSeenAt, ThreadTheme
 from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
                    CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,

+ 1 - 17
mauigpapi/types/mqtt.py

@@ -56,6 +56,7 @@ class CommandResponsePayload(SerializableAttrs['CommandResponsePayload']):
     item_id: Optional[str] = None
     timestamp: Optional[str] = None
     thread_id: Optional[str] = None
+    message: Optional[str] = None
 
 
 @dataclass(kw_only=True)
@@ -84,22 +85,6 @@ class IrisPayload(SerializableAttrs['IrisPayload']):
     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)
 class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     path: str
@@ -109,7 +94,6 @@ class MessageSyncMessage(ThreadItem, SerializableAttrs['MessageSyncMessage']):
     admin_user_ids: Optional[int] = None
     approval_required_for_new_members: Optional[bool] = None
     participants: Optional[Dict[str, str]] = None
-    reactions: Optional[Reactions] = 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
 # 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 mautrix.types import SerializableAttrs, SerializableEnum
 
@@ -272,6 +273,22 @@ class AnimatedMediaItem(SerializableAttrs['AnimatedMediaItem']):
     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)
 class ThreadItem(SerializableAttrs['ThreadItem']):
     item_id: Optional[str] = None
@@ -285,9 +302,9 @@ class ThreadItem(SerializableAttrs['ThreadItem']):
     show_forward_attribution: Optional[bool] = None
     action_log: Optional[ThreadItemActionLog] = None
 
-    # These have only been observed over MQTT and not confirmed in direct_inbox
     media: Optional[RegularMediaItem] = None
     voice_media: Optional[VoiceMediaItem] = None
     animated_media: Optional[AnimatedMediaItem] = None
     visual_media: Optional[VisualMedia] = 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
 # 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
 
@@ -68,3 +68,15 @@ class Reaction:
         if not row:
             return None
         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 mauigpapi.types import (Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType,
-                             ReactionStatus)
+                             ReactionStatus, Reaction)
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.bridge import BasePortal, NotificationDisabler
 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)
         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:
             self.log.debug(f"Ignoring message {item.item_id} by {item.user_id}"
                            " 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)
             elif item.text:
                 event_id = await self._handle_instagram_text(intent, item)
-            # TODO handle attachments and reactions
+            # TODO handle other attachments
             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)
                 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:
                 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)
         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
     # region Updating portal info
 
@@ -409,7 +435,7 @@ class Portal(DBPortal, BasePortal):
         async with NotificationDisabler(self.mxid, source):
             for entry in reversed(entries):
                 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:
             self.log.trace("Leaving room with %s post-backfill", intent.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:
             # Removes don't have a sender, only the message sender can unsend messages anyway
             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 def handle_receipt(self, evt: ConversationReadEntry) -> None: