Browse Source

Add support for Instagram->Matrix images and videos

Tulir Asokan 4 years ago
parent
commit
dbe179cc3f
4 changed files with 104 additions and 11 deletions
  1. 2 2
      ROADMAP.md
  2. 7 0
      mauigpapi/http/base.py
  3. 24 0
      mauigpapi/types/thread_item.py
  4. 71 9
      mautrix_instagram/portal.py

+ 2 - 2
ROADMAP.md

@@ -18,8 +18,8 @@
   * [ ] Message content
   * [ ] Message content
     * [x] Text
     * [x] Text
     * [ ] Media
     * [ ] Media
-      * [ ] Images
-      * [ ] Videos
+      * [x] Images
+      * [x] Videos
       * [ ] Voice messages
       * [ ] Voice messages
       * [ ] Locations
       * [ ] Locations
   * [ ] Message unsend
   * [ ] Message unsend

+ 7 - 0
mauigpapi/http/base.py

@@ -93,6 +93,13 @@ class BaseAndroidAPI:
         }
         }
         return {k: v for k, v in headers.items() if v is not None}
         return {k: v for k, v in headers.items() if v is not None}
 
 
+    async def raw_http_get(self, url: URL) -> ClientResponse:
+        return await self.http.get(url, headers={
+            "user-agent": self.state.user_agent,
+            "accept-language": self.state.device.language.replace("_", "-"),
+            "authorization": self.state.session.authorization,
+        })
+
     async def std_http_post(self, path: str, data: Optional[JSON] = None, raw: bool = False,
     async def std_http_post(self, path: str, data: Optional[JSON] = None, raw: bool = False,
                             filter_nulls: bool = False, headers: Optional[Dict[str, str]] = None,
                             filter_nulls: bool = False, headers: Optional[Dict[str, str]] = None,
                             response_type: Optional[Type[T]] = JSON) -> T:
                             response_type: Optional[Type[T]] = JSON) -> T:

+ 24 - 0
mauigpapi/types/thread_item.py

@@ -133,6 +133,30 @@ class RegularMediaItem(SerializableAttrs['RegularMediaItem']):
     creative_config: Optional[CreativeConfig] = None
     creative_config: Optional[CreativeConfig] = None
     create_mode_attribution: Optional[CreateModeAttribution] = None
     create_mode_attribution: Optional[CreateModeAttribution] = None
 
 
+    @property
+    def best_image(self) -> Optional[ImageVersion]:
+        if not self.image_versions2:
+            return None
+        best: Optional[ImageVersion] = None
+        for version in self.image_versions2.candidates:
+            if version.width == self.original_width and version.height == self.original_height:
+                return version
+            elif not best or (version.width * version.height > best.width * best.height):
+                best = version
+        return best
+
+    @property
+    def best_video(self) -> Optional[VideoVersion]:
+        if not self.video_versions:
+            return None
+        best: Optional[VideoVersion] = None
+        for version in self.video_versions:
+            if version.width == self.original_width and version.height == self.original_height:
+                return version
+            elif not best or (version.width * version.height > best.width * best.height):
+                best = version
+        return best
+
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)
 class Caption(SerializableAttrs['Caption']):
 class Caption(SerializableAttrs['Caption']):

+ 71 - 9
mautrix_instagram/portal.py

@@ -14,20 +14,24 @@
 # 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 (Dict, Tuple, Optional, List, Deque, Set, Any, Union, AsyncGenerator,
 from typing import (Dict, Tuple, Optional, List, Deque, Set, Any, Union, AsyncGenerator,
-                    Awaitable, TYPE_CHECKING, cast)
+                    Awaitable, NamedTuple, TYPE_CHECKING, cast)
 from collections import deque
 from collections import deque
 from uuid import uuid4
 from uuid import uuid4
+import mimetypes
 import asyncio
 import asyncio
 
 
 import magic
 import magic
+from yarl import URL
 
 
-from mauigpapi.types import Thread, ThreadUser, ThreadItem
+from mauigpapi.types import Thread, ThreadUser, ThreadItem, RegularMediaItem, MediaType
 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,
-                           TextMessageEventContent)
+from mautrix.types import (EventID, MessageEventContent, RoomID, EventType, MessageType, ImageInfo,
+                           VideoInfo, MediaMessageEventContent, TextMessageEventContent,
+                           ContentURI, EncryptedFile)
 from mautrix.errors import MatrixError
 from mautrix.errors import MatrixError
 from mautrix.util.simple_lock import SimpleLock
 from mautrix.util.simple_lock import SimpleLock
+from mautrix.util.network_retry import call_with_net_retry
 
 
 from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
 from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
 from .config import Config
 from .config import Config
@@ -43,6 +47,9 @@ except ImportError:
 
 
 StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
 StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
 StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
 StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
+ReuploadedMediaInfo = NamedTuple('ReuploadedMediaInfo', mxc=Optional[ContentURI], url=str,
+                                 decryption_info=Optional[EncryptedFile], msgtype=MessageType,
+                                 file_name=str, info=Union[ImageInfo, VideoInfo])
 
 
 
 
 class Portal(DBPortal, BasePortal):
 class Portal(DBPortal, BasePortal):
@@ -117,7 +124,7 @@ class Portal(DBPortal, BasePortal):
             self.log.debug(f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}"
             self.log.debug(f"_upsert_reaction redacting {existing.mxid} and inserting {mxid}"
                            f" (message: {message.mxid})")
                            f" (message: {message.mxid})")
             await intent.redact(existing.mx_room, existing.mxid)
             await intent.redact(existing.mx_room, existing.mxid)
-            await existing.edit(emoji=reaction, mxid=mxid, mx_room=message.mx_room)
+            await existing.edit(reaction=reaction, mxid=mxid, mx_room=message.mx_room)
         else:
         else:
             self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})")
             self.log.debug(f"_upsert_reaction inserting {mxid} (message: {message.mxid})")
             await DBReaction(mxid=mxid, mx_room=message.mx_room, ig_item_id=message.item_id,
             await DBReaction(mxid=mxid, mx_room=message.mx_room, ig_item_id=message.item_id,
@@ -223,6 +230,59 @@ class Portal(DBPortal, BasePortal):
     # endregion
     # endregion
     # region Instagram event handling
     # region Instagram event handling
 
 
+    async def _reupload_instagram_media(self, source: 'u.User', media: RegularMediaItem,
+                                        intent: IntentAPI) -> Optional[ReuploadedMediaInfo]:
+        if media.media_type == MediaType.IMAGE:
+            image = media.best_image
+            url = image.url
+            msgtype = MessageType.IMAGE
+            info = ImageInfo(height=image.height, width=image.width)
+        elif media.media_type == MediaType.VIDEO:
+            video = media.best_video
+            url = video.url
+            msgtype = MessageType.VIDEO
+            info = VideoInfo(height=video.height, width=video.width)
+        else:
+            return None
+        resp = await source.client.raw_http_get(URL(url))
+        data = await resp.read()
+        info.mime_type = resp.headers["Content-Type"] or magic.from_buffer(data, mime=True)
+        info.size = len(data)
+        file_name = f"{msgtype.value[2:]}{mimetypes.guess_extension(info.mime_type)}"
+
+        upload_mime_type = info.mime_type
+        upload_file_name = file_name
+        decryption_info = None
+        if self.encrypted and encrypt_attachment:
+            data, decryption_info = encrypt_attachment(data)
+            upload_mime_type = "application/octet-stream"
+            upload_file_name = None
+
+        mxc = await call_with_net_retry(intent.upload_media, data, mime_type=upload_mime_type,
+                                        filename=upload_file_name, _action="upload media")
+
+        if decryption_info:
+            decryption_info.url = mxc
+            mxc = None
+
+        return ReuploadedMediaInfo(mxc=mxc, url=url, decryption_info=decryption_info,
+                                   file_name=file_name, msgtype=msgtype, info=info)
+
+    async def _handle_instagram_media(self, source: 'u.User', intent: IntentAPI, item: ThreadItem
+                                      ) -> Optional[EventID]:
+        reuploaded = await self._reupload_instagram_media(source, item.media, intent)
+        if not reuploaded:
+            self.log.debug(f"Unsupported media type: {item.media}")
+            return None
+        content = MediaMessageEventContent(body=reuploaded.file_name, external_url=reuploaded.url,
+                                           url=reuploaded.mxc, file=reuploaded.decryption_info,
+                                           info=reuploaded.info, msgtype=reuploaded.msgtype)
+        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)
+        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
     async def handle_instagram_item(self, source: 'u.User', sender: 'p.Puppet', item: ThreadItem
                                     ) -> None:
                                     ) -> None:
         if item.client_context in self._reqid_dedup:
         if item.client_context in self._reqid_dedup:
@@ -238,16 +298,18 @@ class Portal(DBPortal, BasePortal):
             self._msgid_dedup.appendleft(item.item_id)
             self._msgid_dedup.appendleft(item.item_id)
             intent = sender.intent_for(self)
             intent = sender.intent_for(self)
             event_id = None
             event_id = None
-            if item.text:
-                content = TextMessageEventContent(msgtype=MessageType.TEXT, body=item.text)
-                event_id = await self._send_message(intent, content,
-                                                    timestamp=item.timestamp // 1000)
+            if item.media:
+                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 attachments and reactions
             if event_id:
             if event_id:
                 await DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
                 await DBMessage(mxid=event_id, mx_room=self.mxid, item_id=item.item_id,
                                 receiver=self.receiver).insert()
                                 receiver=self.receiver).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}")
+            else:
+                self.log.debug(f"Unhandled Instagram message {item.item_id}")
 
 
     # endregion
     # endregion
     # region Updating portal info
     # region Updating portal info