Преглед на файлове

Implement bridging visual_media, link and all share items

Tulir Asokan преди 4 години
родител
ревизия
8bc31f63d4
променени са 7 файла, в които са добавени 216 реда и са изтрити 43 реда
  1. 2 3
      ROADMAP.md
  2. 2 1
      mauigpapi/types/__init__.py
  3. 1 1
      mauigpapi/types/account.py
  4. 122 27
      mauigpapi/types/thread_item.py
  5. 4 0
      mautrix_instagram/db/message.py
  6. 1 1
      mautrix_instagram/matrix.py
  7. 84 10
      mautrix_instagram/portal.py

+ 2 - 3
ROADMAP.md

@@ -17,14 +17,13 @@
 * Instagram → Matrix
 * Instagram → Matrix
   * [ ] Message content
   * [ ] Message content
     * [x] Text
     * [x] Text
-    * [ ] Media
+    * [x] Media
       * [x] Images
       * [x] Images
       * [x] Videos
       * [x] Videos
       * [x] Gifs
       * [x] Gifs
       * [x] Voice messages
       * [x] Voice messages
       * [x] Locations
       * [x] Locations
-      * [ ] Media share
-      * [ ] Other random types
+      * [x] Story/reel share
   * [x] Message unsend
   * [x] Message unsend
   * [x] Message reactions
   * [x] Message reactions
   * [x] Message history
   * [x] Message history

+ 2 - 1
mauigpapi/types/__init__.py

@@ -13,7 +13,8 @@ from .thread_item import (ThreadItemType, ThreadItemActionLog, ViewMode, Creativ
                           RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
                           RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
                           AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
                           AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
                           AnimatedMediaItem, ThreadItem, VoiceMediaData, Reaction, Reactions,
                           AnimatedMediaItem, ThreadItem, VoiceMediaData, Reaction, Reactions,
-                          Location)
+                          Location, ExpiredMediaItem, ReelMediaShareItem, ReelShareItem, LinkItem,
+                          ReelShareType, ReelShareReactionInfo, SharingFrictionInfo, LinkContext)
 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 - 1
mauigpapi/types/account.py

@@ -41,7 +41,7 @@ class UserIdentifier(SerializableAttrs['UserIdentifier']):
 class BaseResponseUser(UserIdentifier, SerializableAttrs['BaseResponseUser']):
 class BaseResponseUser(UserIdentifier, SerializableAttrs['BaseResponseUser']):
     full_name: str
     full_name: str
     is_private: bool
     is_private: bool
-    is_verified: bool
+    is_verified: bool = False
     profile_pic_url: str
     profile_pic_url: str
     # When this doesn't exist, the profile picture is probably the default one
     # When this doesn't exist, the profile picture is probably the default one
     profile_pic_id: Optional[str] = None
     profile_pic_id: Optional[str] = None

+ 122 - 27
mauigpapi/types/thread_item.py

@@ -13,11 +13,12 @@
 #
 #
 # 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, Optional
+from typing import List, Optional, Union, Any
 
 
 import attr
 import attr
 from attr import dataclass
 from attr import dataclass
-from mautrix.types import SerializableAttrs, SerializableEnum
+from mautrix.types import SerializableAttrs, SerializableEnum, JSON
+from mautrix.types.util.serializable_attrs import _dict_to_attrs
 
 
 from .account import BaseResponseUser, UserIdentifier
 from .account import BaseResponseUser, UserIdentifier
 
 
@@ -122,19 +123,26 @@ class MediaType(SerializableEnum):
     SHOWREEL_NATIVE = 12
     SHOWREEL_NATIVE = 12
 
 
 
 
+@dataclass(kw_only=True)
+class ExpiredMediaItem(SerializableAttrs['ExpiredMediaItem']):
+    media_type: MediaType
+
+
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class RegularMediaItem(SerializableAttrs['RegularMediaItem']):
 class RegularMediaItem(SerializableAttrs['RegularMediaItem']):
     id: str
     id: str
     image_versions2: Optional[ImageVersions] = None
     image_versions2: Optional[ImageVersions] = None
     video_versions: Optional[List[VideoVersion]] = None
     video_versions: Optional[List[VideoVersion]] = None
-    original_width: int
-    original_height: int
+    original_width: Optional[int] = None
+    original_height: Optional[int] = None
     media_type: MediaType
     media_type: MediaType
     media_id: Optional[int] = None
     media_id: Optional[int] = None
     organic_tracking_token: Optional[str] = None
     organic_tracking_token: Optional[str] = None
     creative_config: Optional[CreativeConfig] = None
     creative_config: Optional[CreativeConfig] = None
     create_mode_attribution: Optional[CreateModeAttribution] = None
     create_mode_attribution: Optional[CreateModeAttribution] = None
 
 
+    # TODO carousel_media shares
+
     @property
     @property
     def best_image(self) -> Optional[ImageVersion]:
     def best_image(self) -> Optional[ImageVersion]:
         if not self.image_versions2:
         if not self.image_versions2:
@@ -179,27 +187,37 @@ class Caption(SerializableAttrs['Caption']):
     media_id: int
     media_id: int
 
 
 
 
+@dataclass
+class Location(SerializableAttrs['Location']):
+    pk: int
+    short_name: str
+    facebook_places_id: int
+    # TODO enum?
+    external_source: str  # facebook_places
+    name: str
+    address: str
+    city: str
+    lng: float
+    lat: float
+
+
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
-class MediaShareItem(SerializableAttrs['MediaShareItem']):
+class MediaShareItem(RegularMediaItem, SerializableAttrs['MediaShareItem']):
     taken_at: int
     taken_at: int
     pk: int
     pk: int
-    id: str
     device_timestamp: int
     device_timestamp: int
-    media_type: MediaType
     code: str
     code: str
     client_cache_key: str
     client_cache_key: str
     filter_type: int
     filter_type: int
-    image_versions2: ImageVersions
-    video_versions: VideoVersion
-    original_width: int
-    original_height: int
     user: BaseResponseUser
     user: BaseResponseUser
-    can_viewer_reshare: bool
+    # Not present in reel shares
+    can_viewer_reshare: Optional[bool] = None
     caption_is_edited: bool
     caption_is_edited: bool
     comment_likes_enabled: bool
     comment_likes_enabled: bool
     comment_threading_enabled: bool
     comment_threading_enabled: bool
     has_more_comments: bool
     has_more_comments: bool
     max_num_visible_preview_comments: int
     max_num_visible_preview_comments: int
+    # preview_comments: List[TODO]
     can_view_more_preview_comments: bool
     can_view_more_preview_comments: bool
     comment_count: int
     comment_count: int
     like_count: int
     like_count: int
@@ -207,7 +225,35 @@ class MediaShareItem(SerializableAttrs['MediaShareItem']):
     photo_of_you: bool
     photo_of_you: bool
     caption: Caption
     caption: Caption
     can_viewer_save: bool
     can_viewer_save: bool
-    organic_tracking_token: str
+    location: Optional[Location] = None
+
+
+@dataclass
+class SharingFrictionInfo(SerializableAttrs['SharingFrictionInfo']):
+    should_have_sharing_friction: bool
+    bloks_app_url: Optional[str]
+
+
+# The fields in this class have been observed in reel share items, but may exist elsewhere too.
+# If they're observed in other types, they should be moved to MediaShareItem.
+@dataclass
+class ReelMediaShareItem(MediaShareItem, SerializableAttrs['ReelMediaShareItem']):
+    # TODO enum?
+    caption_position: int
+    is_reel_media: bool
+    timezone_offset: int
+    # likers: List[TODO]
+    can_see_insights_as_brand: bool
+    expiring_at: int
+    sharing_friction_info: SharingFrictionInfo
+    is_in_profile_grid: bool
+    profile_grid_control_enabled: bool
+    is_shop_the_look_eligible: bool
+    # TODO enum?
+    deleted_reason: int
+    integrity_review_decision: str
+    # Not present in story_share, only reel_share
+    story_is_saved_to_archive: Optional[bool] = None
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
@@ -215,14 +261,23 @@ class ReplayableMediaItem(SerializableAttrs['ReplayableMediaItem']):
     view_mode: ViewMode
     view_mode: ViewMode
     seen_count: int
     seen_count: int
     seen_user_ids: List[int]
     seen_user_ids: List[int]
-    replay_expiring_at_us: Optional[Any] = None
+    replay_expiring_at_us: Optional[int] = None
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class VisualMedia(ReplayableMediaItem, SerializableAttrs['VisualMedia']):
 class VisualMedia(ReplayableMediaItem, SerializableAttrs['VisualMedia']):
-    url_expire_at_secs: int
-    playback_duration_secs: int
-    media: RegularMediaItem
+    url_expire_at_secs: Optional[int] = None
+    playback_duration_secs: Optional[int] = None
+    media: Union[RegularMediaItem, ExpiredMediaItem]
+
+    @classmethod
+    def deserialize(cls, data: JSON) -> 'VisualMedia':
+        data = {**data}
+        if "id" not in data["media"]:
+            data["media"] = ExpiredMediaItem.deserialize(data["media"])
+        else:
+            data["media"] = RegularMediaItem.deserialize(data["media"])
+        return _dict_to_attrs(cls, data)
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
@@ -291,17 +346,54 @@ class Reactions(SerializableAttrs['Reactions']):
 
 
 
 
 @dataclass
 @dataclass
-class Location(SerializableAttrs['Location']):
-    pk: int
-    short_name: str
-    facebook_places_id: int
+class LinkContext(SerializableAttrs['LinkContext']):
+    link_url: str
+    link_title: str
+    link_summary: str
+    link_image_url: str
+
+
+@dataclass
+class LinkItem(SerializableAttrs['LinkItem']):
+    text: str
+    link_context: LinkContext
+    client_context: str
+    mutation_token: str
+
+
+class ReelShareType(SerializableEnum):
+    REPLY = "reply"
+    REACTION = "reaction"
+
+
+@dataclass
+class ReelShareReactionInfo(SerializableAttrs['ReelShareReactionInfo']):
+    emoji: str
+    # TODO find type
+    # intensity: Any
+
+
+@dataclass
+class ReelShareItem(SerializableAttrs['ReelShareItem']):
+    text: str
+    type: ReelShareType
+    reel_owner_id: int
+    is_reel_persisted: int
+    reel_type: str
+    media: ReelMediaShareItem
+    reaction_info: Optional[ReelShareReactionInfo] = None
+
+
+@dataclass
+class StoryShareItem(SerializableAttrs['StoryShareItem']):
+    text: str
+    is_reel_persisted: bool
     # TODO enum?
     # TODO enum?
-    external_source: str  # facebook_places
-    name: str
-    address: str
-    city: str
-    lng: float
-    lat: float
+    reel_type: str  # user_reel
+    reel_id: str
+    # TODO enum?
+    story_share_type: str  # default
+    media: ReelMediaShareItem
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
@@ -322,6 +414,9 @@ class ThreadItem(SerializableAttrs['ThreadItem']):
     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
+    reel_share: Optional[ReelShareItem] = None
+    story_share: Optional[StoryShareItem] = None
     location: Optional[Location] = None
     location: Optional[Location] = None
     reactions: Optional[Reactions] = None
     reactions: Optional[Reactions] = None
     like: Optional[str] = None
     like: Optional[str] = None
+    link: Optional[LinkItem] = None

+ 4 - 0
mautrix_instagram/db/message.py

@@ -62,3 +62,7 @@ class Message:
         if not row:
         if not row:
             return None
             return None
         return cls(**row)
         return cls(**row)
+
+    @property
+    def is_internal(self) -> bool:
+        return self.item_id.startswith("fi.mau.instagram.")

+ 1 - 1
mautrix_instagram/matrix.py

@@ -99,7 +99,7 @@ class MatrixHandler(BaseMatrixHandler):
     async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
     async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
                                   data: SingleReceiptEventContent) -> None:
                                   data: SingleReceiptEventContent) -> None:
         message = await DBMessage.get_by_mxid(event_id, portal.mxid)
         message = await DBMessage.get_by_mxid(event_id, portal.mxid)
-        if not message:
+        if not message or message.is_internal:
             return
             return
         # TODO implement
         # TODO implement
         # user.log.debug(f"Marking messages in {portal.thread_id} read up to {message.item_id}")
         # user.log.debug(f"Marking messages in {portal.thread_id} read up to {message.item_id}")

+ 84 - 10
mautrix_instagram/portal.py

@@ -24,7 +24,7 @@ import magic
 
 
 from mauigpapi.types import (Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType,
 from mauigpapi.types import (Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType,
                              ReactionStatus, Reaction, AnimatedMediaItem, ThreadItemType,
                              ReactionStatus, Reaction, AnimatedMediaItem, ThreadItemType,
-                             VoiceMediaItem, Location)
+                             VoiceMediaItem, ExpiredMediaItem, MediaShareItem, ReelShareType)
 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,
@@ -189,7 +189,7 @@ class Portal(DBPortal, BasePortal):
     async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID,
     async def handle_matrix_reaction(self, sender: 'u.User', event_id: EventID,
                                      reacting_to: EventID, emoji: str) -> None:
                                      reacting_to: EventID, emoji: str) -> None:
         message = await DBMessage.get_by_mxid(reacting_to, self.mxid)
         message = await DBMessage.get_by_mxid(reacting_to, self.mxid)
-        if not message:
+        if not message or message.is_internal:
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             self.log.debug(f"Ignoring reaction to unknown event {reacting_to}")
             return
             return
 
 
@@ -226,7 +226,7 @@ class Portal(DBPortal, BasePortal):
             return
             return
 
 
         message = await DBMessage.get_by_mxid(event_id, self.mxid)
         message = await DBMessage.get_by_mxid(event_id, self.mxid)
-        if message:
+        if message and not message.is_internal:
             try:
             try:
                 await message.delete()
                 await message.delete()
                 await sender.client.delete_item(self.thread_id, message.item_id)
                 await sender.client.delete_item(self.thread_id, message.item_id)
@@ -252,11 +252,15 @@ class Portal(DBPortal, BasePortal):
                                         intent: IntentAPI) -> Optional[ReuploadedMediaInfo]:
                                         intent: IntentAPI) -> Optional[ReuploadedMediaInfo]:
         if media.media_type == MediaType.IMAGE:
         if media.media_type == MediaType.IMAGE:
             image = media.best_image
             image = media.best_image
+            if not image:
+                return None
             url = image.url
             url = image.url
             msgtype = MessageType.IMAGE
             msgtype = MessageType.IMAGE
             info = ImageInfo(height=image.height, width=image.width)
             info = ImageInfo(height=image.height, width=image.width)
         elif media.media_type == MediaType.VIDEO:
         elif media.media_type == MediaType.VIDEO:
             video = media.best_video
             video = media.best_video
+            if not video:
+                return None
             url = video.url
             url = video.url
             msgtype = MessageType.VIDEO
             msgtype = MessageType.VIDEO
             info = VideoInfo(height=video.height, width=video.width)
             info = VideoInfo(height=video.height, width=video.width)
@@ -315,11 +319,25 @@ class Portal(DBPortal, BasePortal):
                                       ) -> Optional[EventID]:
                                       ) -> Optional[EventID]:
         if item.media:
         if item.media:
             reuploaded = await self._reupload_instagram_media(source, item.media, intent)
             reuploaded = await self._reupload_instagram_media(source, item.media, intent)
+        elif item.visual_media:
+            if isinstance(item.visual_media.media, ExpiredMediaItem):
+                # TODO send error message instead
+                return None
+            reuploaded = await self._reupload_instagram_media(source, item.visual_media.media,
+                                                              intent)
         elif item.animated_media:
         elif item.animated_media:
             reuploaded = await self._reupload_instagram_animated(source, item.animated_media,
             reuploaded = await self._reupload_instagram_animated(source, item.animated_media,
                                                                  intent)
                                                                  intent)
         elif item.voice_media:
         elif item.voice_media:
             reuploaded = await self._reupload_instagram_voice(source, item.voice_media, intent)
             reuploaded = await self._reupload_instagram_voice(source, item.voice_media, intent)
+        elif item.reel_share:
+            reuploaded = await self._reupload_instagram_media(source, item.reel_share.media,
+                                                              intent)
+        elif item.story_share:
+            reuploaded = await self._reupload_instagram_media(source, item.story_share.media,
+                                                              intent)
+        elif item.media_share:
+            reuploaded = await self._reupload_instagram_media(source, item.media_share, intent)
         else:
         else:
             reuploaded = None
             reuploaded = None
         if not reuploaded:
         if not reuploaded:
@@ -330,9 +348,56 @@ class Portal(DBPortal, BasePortal):
                                            info=reuploaded.info, msgtype=reuploaded.msgtype)
                                            info=reuploaded.info, msgtype=reuploaded.msgtype)
         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_text(self, intent: IntentAPI, item: ThreadItem) -> EventID:
-        content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text or item.like)
-        return await self._send_message(intent, content, timestamp=item.timestamp // 1000)
+    async def _handle_instagram_media_share(self, source: 'u.User', intent: IntentAPI,
+                                            item: ThreadItem) -> Optional[EventID]:
+        share_item = item.media_share or item.story_share.media
+        if share_item == item.media_share:
+            prefix_text = f"Sent {share_item.user.username}'s photo"
+        else:
+            prefix_text = f"Shared {share_item.user.username}'s story"
+        prefix = TextMessageEventContent(msgtype=MessageType.NOTICE, body=prefix_text)
+        await self._send_message(intent, prefix)
+        event_id = await self._handle_instagram_media(source, intent, item)
+        if share_item.caption:
+            body = f"> {share_item.caption.user.username}: {share_item.caption.text}"
+            formatted_body = (f"<blockquote><strong>{share_item.caption.user.username}</strong>"
+                              f" {share_item.caption.text}</blockquote>")
+            caption = TextMessageEventContent(msgtype=MessageType.TEXT, body=body,
+                                              formatted_body=formatted_body, format=Format.HTML)
+            await self._send_message(intent, caption)
+        return event_id
+
+    async def _handle_instagram_reel_share(self, source: 'u.User', intent: IntentAPI,
+                                           item: ThreadItem) -> Optional[EventID]:
+        if item.reel_share.type == ReelShareType.REPLY:
+            if item.reel_share.reel_owner_id == source.igpk:
+                prefix = "Replied to your story"
+            else:
+                prefix = f"Sent {item.reel_share.media.user.username}'s story"
+        elif item.reel_share.type == ReelShareType.REACTION:
+            prefix = "Reacted to your story"
+        else:
+            self.log.debug(f"Unsupported reel share type {item.reel_share.type}")
+            return None
+        prefix = TextMessageEventContent(msgtype=MessageType.NOTICE, body=prefix)
+        content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text)
+        await self._send_message(intent, prefix)
+        fake_item_id = f"fi.mau.instagram.reel_share_item.{item.reel_share.media.pk}"
+        existing = await DBMessage.get_by_item_id(fake_item_id, self.receiver)
+        if existing:
+            # If the user already reacted or replied to the same reel share item,
+            # use a Matrix reply instead of reposting the image.
+            content.set_reply(existing.mxid)
+        else:
+            media_event_id = await self._handle_instagram_media(source, intent, item)
+            await DBMessage(mxid=media_event_id, mx_room=self.mxid, item_id=fake_item_id,
+                            receiver=self.receiver, sender=item.reel_share.media.user.pk).insert()
+        return await self._send_message(intent, content)
+
+    async def _handle_instagram_text(self, intent: IntentAPI, text: str, timestamp: int
+                                     ) -> EventID:
+        content = TextMessageEventContent(msgtype=MessageType.TEXT, body=text)
+        return await self._send_message(intent, content, timestamp=timestamp // 1000)
 
 
     async def _handle_instagram_location(self, intent: IntentAPI, item: ThreadItem) -> EventID:
     async def _handle_instagram_location(self, intent: IntentAPI, item: ThreadItem) -> EventID:
         loc = item.location
         loc = item.location
@@ -378,13 +443,22 @@ class Portal(DBPortal, BasePortal):
             else:
             else:
                 intent = sender.intent_for(self)
                 intent = sender.intent_for(self)
             event_id = None
             event_id = None
-            if item.media or item.animated_media or item.voice_media:
+            if item.media or item.animated_media or item.voice_media or item.visual_media:
                 event_id = await self._handle_instagram_media(source, intent, item)
                 event_id = await self._handle_instagram_media(source, intent, item)
             elif item.location:
             elif item.location:
                 event_id = await self._handle_instagram_location(intent, item)
                 event_id = await self._handle_instagram_location(intent, item)
-            # We handle likes as text because Matrix clients do big emoji on their own.
-            if item.text or item.like:
-                event_id = await self._handle_instagram_text(intent, item)
+            elif item.reel_share:
+                event_id = await self._handle_instagram_reel_share(source, intent, item)
+            elif item.media_share or item.story_share:
+                event_id = await self._handle_instagram_media_share(source, intent, item)
+            if item.text:
+                event_id = await self._handle_instagram_text(intent, item.text, item.timestamp)
+            elif item.like:
+                # We handle likes as text because Matrix clients do big emoji on their own.
+                event_id = await self._handle_instagram_text(intent, item.like, item.timestamp)
+            elif item.link:
+                event_id = await self._handle_instagram_text(intent, item.link.text,
+                                                             item.timestamp)
             if event_id:
             if event_id:
                 msg = DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
                 msg = DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
                                 receiver=self.receiver, sender=sender.pk)
                                 receiver=self.receiver, sender=sender.pk)