Browse Source

Update attachment upload method

Tulir Asokan 2 years ago
parent
commit
81a2f4c5ef

+ 34 - 21
mauigpapi/http/base.py

@@ -111,6 +111,25 @@ class BaseAndroidAPI:
             req = json.dumps(remove_nulls(req) if filter_nulls else req)
             req = json.dumps(remove_nulls(req) if filter_nulls else req)
         return {"signed_body": f"SIGNATURE.{req}"}
         return {"signed_body": f"SIGNATURE.{req}"}
 
 
+    @property
+    def _rupload_headers(self) -> dict[str, str]:
+        headers = {
+            "user-agent": self.state.user_agent,
+            "accept-language": self.state.device.language.replace("_", "-"),
+            "authorization": self.state.session.authorization,
+            "x-mid": self.state.cookies.get_value("mid"),
+            "ig-u-shbid": self.state.session.shbid,
+            "ig-u-shbts": self.state.session.shbts,
+            "ig-u-ds-user-id": self.state.session.ds_user_id,
+            "ig-u-rur": self.state.session.rur,
+            "ig-intended-user-id": self.state.session.ds_user_id or "0",
+            "x-fb-http-engine": "Liger",
+            "x-fb-client-ip": "True",
+            "x-fb-server-cluster": "True",
+            "accept-encoding": "gzip",
+        }
+        return {k: v for k, v in headers.items() if v is not None}
+
     @property
     @property
     def _headers(self) -> dict[str, str]:
     def _headers(self) -> dict[str, str]:
         headers = {
         headers = {
@@ -136,23 +155,11 @@ class BaseAndroidAPI:
             "x-fb-connection-type": self.state.device.connection_type,
             "x-fb-connection-type": self.state.device.connection_type,
             "x-ig-capabilities": self.state.application.CAPABILITIES,
             "x-ig-capabilities": self.state.application.CAPABILITIES,
             "x-ig-app-id": self.state.application.FACEBOOK_ANALYTICS_APPLICATION_ID,
             "x-ig-app-id": self.state.application.FACEBOOK_ANALYTICS_APPLICATION_ID,
-            "user-agent": self.state.user_agent,
-            "accept-language": self.state.device.language.replace("_", "-"),
-            "authorization": self.state.session.authorization,
-            "x-mid": self.state.cookies.get_value("mid"),
-            "ig-u-ig-direct-region-hint": self.state.session.region_hint,
-            "ig-u-shbid": self.state.session.shbid,
-            "ig-u-shbts": self.state.session.shbts,
-            "ig-u-ds-user-id": self.state.session.ds_user_id,
-            "ig-u-rur": self.state.session.rur,
-            "ig-intended-user-id": self.state.session.ds_user_id or "0",
             "ig-client-endpoint": "unknown",
             "ig-client-endpoint": "unknown",
-            "x-fb-http-engine": "Liger",
-            "x-fb-client-ip": "True",
             "x-fb-rmd": "cached=0;state=NO_MATCH",
             "x-fb-rmd": "cached=0;state=NO_MATCH",
-            "x-fb-server-cluster": "True",
             "x-tigon-is-retry": "False",
             "x-tigon-is-retry": "False",
-            "accept-encoding": "gzip",
+            "ig-u-ig-direct-region-hint": self.state.session.region_hint,
+            **self._rupload_headers,
         }
         }
         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}
 
 
@@ -189,11 +196,14 @@ class BaseAndroidAPI:
         headers: dict[str, str] | None = None,
         headers: dict[str, str] | None = None,
         query: dict[str, str] | None = None,
         query: dict[str, str] | None = None,
         response_type: Type[T] | None = JSON,
         response_type: Type[T] | None = JSON,
+        default_headers: bool = True,
+        url_override: URL | None = None,
     ) -> T:
     ) -> T:
-        headers = {**self._headers, **headers} if headers else self._headers
+        if default_headers:
+            headers = {**self._headers, **headers} if headers else self._headers
         if not raw:
         if not raw:
             data = self.sign(data, filter_nulls=filter_nulls)
             data = self.sign(data, filter_nulls=filter_nulls)
-        url = self.url.with_path(path).with_query(query or {})
+        url = (url_override or self.url).with_path(path).with_query(query or {})
         resp = await self.proxy_with_retry(
         resp = await self.proxy_with_retry(
             f"AndroidAPI.std_http_post: {url}",
             f"AndroidAPI.std_http_post: {url}",
             lambda: self.http.post(url=url, headers=headers, data=data),
             lambda: self.http.post(url=url, headers=headers, data=data),
@@ -204,7 +214,7 @@ class BaseAndroidAPI:
             if response_type is str:
             if response_type is str:
                 return await resp.text()
                 return await resp.text()
             return None
             return None
-        json_data = await self._handle_response(resp)
+        json_data = await self._handle_response(resp, is_external=bool(url_override))
         if response_type is not JSON:
         if response_type is not JSON:
             return response_type.deserialize(json_data)
             return response_type.deserialize(json_data)
         return json_data
         return json_data
@@ -227,18 +237,21 @@ class BaseAndroidAPI:
         if response_type is None:
         if response_type is None:
             self._handle_response_headers(resp)
             self._handle_response_headers(resp)
             return None
             return None
-        json_data = await self._handle_response(resp)
+        json_data = await self._handle_response(resp, is_external=False)
         if response_type is not JSON:
         if response_type is not JSON:
             return response_type.deserialize(json_data)
             return response_type.deserialize(json_data)
         return json_data
         return json_data
 
 
-    async def _handle_response(self, resp: ClientResponse) -> JSON:
-        self._handle_response_headers(resp)
+    async def _handle_response(self, resp: ClientResponse, is_external: bool) -> JSON:
+        if not is_external:
+            self._handle_response_headers(resp)
         try:
         try:
             body = await resp.json()
             body = await resp.json()
         except (json.JSONDecodeError, ContentTypeError) as e:
         except (json.JSONDecodeError, ContentTypeError) as e:
             raise IGUnknownError(resp) from e
             raise IGUnknownError(resp) from e
-        if body.get("status", "fail") == "ok":
+        if not is_external and body.get("status", "fail") == "ok":
+            return body
+        elif is_external and 200 <= resp.status < 300:
             return body
             return body
         else:
         else:
             err = await self._get_response_error(resp)
             err = await self._get_response_error(resp)

+ 33 - 129
mauigpapi/http/upload.py

@@ -15,152 +15,56 @@
 # 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 __future__ import annotations
 from __future__ import annotations
 
 
-from uuid import uuid4
-import json
 import random
 import random
 import time
 import time
 
 
-from ..types import FinishUploadResponse, MediaType, UploadPhotoResponse, UploadVideoResponse
+from yarl import URL
+
+from ..types import FacebookUploadResponse
 from .base import BaseAndroidAPI
 from .base import BaseAndroidAPI
 
 
 
 
 class UploadAPI(BaseAndroidAPI):
 class UploadAPI(BaseAndroidAPI):
-    async def upload_photo(
-        self,
-        data: bytes,
-        mime: str,
-        upload_id: str | None = None,
-        width: int | None = None,
-        height: int | None = None,
-    ) -> UploadPhotoResponse:
-        upload_id = upload_id or str(int(time.time() * 1000))
-        name = f"{upload_id}_0_{random.randint(1000000000, 9999999999)}"
-        params = {
-            "retry_context": json.dumps(
-                {
-                    "num_step_auto_retry": 0,
-                    "num_reupload": 0,
-                    "num_step_manual_retry": 0,
-                }
-            ),
-            "media_type": str(MediaType.IMAGE.value),
-            "upload_id": upload_id,
-            "xsharing_user_ids": json.dumps([]),
-        }
-        if mime == "image/jpeg":
-            params["image_compression"] = json.dumps(
-                {"lib_name": "moz", "lib_version": "3.1.m", "quality": 80}
-            )
-        if width and height:
-            params["original_width"] = str(width)
-            params["original_height"] = str(height)
-        headers = {
-            "X_FB_PHOTO_WATERFALL_ID": str(uuid4()),
-            "X-Entity-Type": mime,
-            "Offset": "0",
-            "X-Instagram-Rupload-Params": json.dumps(params),
-            "X-Entity-Name": name,
-            "X-Entity-Length": str(len(data)),
+    rupload_fb = URL("https://rupload.facebook.com")
+
+    def _make_rupload_headers(self, length: int, name: str, mime: str) -> dict[str, str]:
+        return {
+            **self._rupload_headers,
+            "x-entity-length": str(length),
+            "x-entity-name": name,
+            "x-entity-type": mime,
+            "offset": "0",
             "Content-Type": "application/octet-stream",
             "Content-Type": "application/octet-stream",
             "priority": "u=6, i",
             "priority": "u=6, i",
         }
         }
-        return await self.std_http_post(
-            f"/rupload_igphoto/{name}",
-            headers=headers,
-            data=data,
-            raw=True,
-            response_type=UploadPhotoResponse,
-        )
 
 
-    async def upload_mp4(
+    async def upload(
         self,
         self,
         data: bytes,
         data: bytes,
+        mimetype: str,
         upload_id: str | None = None,
         upload_id: str | None = None,
-        audio: bool = False,
-        duration_ms: int | None = None,
-        width: int | None = None,
-        height: int | None = None,
-    ) -> tuple[UploadVideoResponse, str]:
+    ) -> FacebookUploadResponse:
         upload_id = upload_id or str(int(time.time() * 1000))
         upload_id = upload_id or str(int(time.time() * 1000))
         name = f"{upload_id}_0_{random.randint(1000000000, 9999999999)}"
         name = f"{upload_id}_0_{random.randint(1000000000, 9999999999)}"
-        media_type = MediaType.AUDIO if audio else MediaType.VIDEO
-        params: dict[str, str] = {
-            "retry_context": json.dumps(
-                {
-                    "num_step_auto_retry": 0,
-                    "num_reupload": 0,
-                    "num_step_manual_retry": 0,
-                }
-            ),
-            "media_type": str(media_type.value),
-            "upload_id": upload_id,
-            "xsharing_user_ids": json.dumps([]),
-        }
-        if duration_ms:
-            params["upload_media_duration_ms"] = str(duration_ms)
-        if audio:
-            params["is_direct_voice"] = "1"
+        headers = self._make_rupload_headers(len(data), name, mimetype)
+        if mimetype.startswith("image/"):
+            path_type = "messenger_gif" if mimetype == "image/gif" else "messenger_image"
+            headers["image_type"] = "FILE_ATTACHMENT"
+        elif mimetype.startswith("video/"):
+            path_type = "messenger_video"
+            headers["video_type"] = "FILE_ATTACHMENT"
+        elif mimetype.startswith("audio/"):
+            path_type = "messenger_audio"
+            headers["audio_type"] = "VOICE_MESSAGE"
         else:
         else:
-            params["direct_v2"] = "1"
-            params["for_direct_story"] = "1"
-            params["content_tags"] = "use_default_cover"
-            params["extract_cover_frame"] = "1"
-            if width and height:
-                params["upload_media_width"] = str(width)
-                params["upload_media_height"] = str(height)
-        headers = {
-            "X_FB_VIDEO_WATERFALL_ID": str(uuid4()),
-            "X-Entity-Type": "audio/mp4" if audio else "video/mp4",
-            "Offset": "0",
-            "X-Instagram-Rupload-Params": json.dumps(params),
-            "X-Entity-Name": name,
-            "X-Entity-Length": str(len(data)),
-            "Content-Type": "application/octet-stream",
-            "priority": "u=6, i",
-        }
-        if not audio:
-            headers["segment-type"] = "3"
-            headers["segment-start-offset"] = "0"
-        return (
-            await self.std_http_post(
-                f"/rupload_igvideo/{name}",
-                headers=headers,
-                data=data,
-                raw=True,
-                response_type=UploadVideoResponse,
-            ),
-            upload_id,
-        )
-
-    async def finish_upload(
-        self, upload_id: str, source_type: str, video: bool = False
-    ) -> FinishUploadResponse:
-        headers = {
-            "retry_context": json.dumps(
-                {
-                    "num_step_auto_retry": 0,
-                    "num_reupload": 0,
-                    "num_step_manual_retry": 0,
-                }
-            ),
-        }
-        req = {
-            "timezone_offset": self.state.device.timezone_offset,
-            "_csrftoken": self.state.cookies.csrf_token,
-            "source_type": source_type,
-            "_uid": self.state.session.ds_user_id,
-            "device_id": self.state.device.id,
-            "_uuid": self.state.device.uuid,
-            "upload_id": upload_id,
-            "device": self.state.device.payload,
-        }
-        query = {}
-        if video:
-            query["video"] = "1"
+            path_type = "messenger_file"
+            headers["file_type"] = "FILE_ATTACHMENT"
         return await self.std_http_post(
         return await self.std_http_post(
-            "/api/v1/media/upload_finish/",
+            f"/{path_type}/{name}",
+            url_override=self.rupload_fb,
+            default_headers=False,
             headers=headers,
             headers=headers,
-            data=req,
-            query=query,
-            response_type=FinishUploadResponse,
+            data=data,
+            raw=True,
+            response_type=FacebookUploadResponse,
         )
         )

+ 1 - 1
mauigpapi/types/__init__.py

@@ -102,5 +102,5 @@ from .thread_item import (
     VoiceMediaItem,
     VoiceMediaItem,
     XMAMediaShareItem,
     XMAMediaShareItem,
 )
 )
-from .upload import FinishUploadResponse, UploadPhotoResponse, UploadVideoResponse
+from .upload import FacebookUploadResponse
 from .user import SearchResultUser, UserSearchResponse
 from .user import SearchResultUser, UserSearchResponse

+ 3 - 0
mauigpapi/types/thread_item.py

@@ -85,6 +85,9 @@ class ThreadItemType(ExtensibleEnum):
     XMA_CLIP = "xma_clip"
     XMA_CLIP = "xma_clip"
     EXPIRED_PLACEHOLDER = "expired_placeholder"
     EXPIRED_PLACEHOLDER = "expired_placeholder"
     AVATAR_STICKER = "avatar_sticker"
     AVATAR_STICKER = "avatar_sticker"
+    PHOTO_ATTACHMENT = "photo_attachment"
+    VIDEO_ATTACHMENT = "video_attachment"
+    VOICE_ATTACHMENT = "voice_attachment"
 
 
 
 
 @dataclass(kw_only=True)
 @dataclass(kw_only=True)

+ 2 - 15
mauigpapi/types/upload.py

@@ -21,18 +21,5 @@ from mautrix.types import SerializableAttrs
 
 
 
 
 @dataclass
 @dataclass
-class FinishUploadResponse(SerializableAttrs):
-    status: str
-
-
-@dataclass
-class UploadPhotoResponse(SerializableAttrs):
-    upload_id: str
-    status: str
-    xsharing_nonces: Any
-
-
-@dataclass
-class UploadVideoResponse(SerializableAttrs):
-    status: str
-    xsharing_nonces: Any
+class FacebookUploadResponse(SerializableAttrs):
+    media_id: int

+ 17 - 37
mautrix_instagram/portal.py

@@ -427,9 +427,7 @@ class Portal(DBPortal, BasePortal):
                 mime_type = "image/jpeg"
                 mime_type = "image/jpeg"
 
 
         self.log.debug(f"Uploading photo from {event_id} (mime: {mime_type})")
         self.log.debug(f"Uploading photo from {event_id} (mime: {mime_type})")
-        upload_resp = await sender.client.upload_photo(
-            data, mime=mime_type, width=width, height=height
-        )
+        upload_resp = await sender.client.upload(data, mimetype=mime_type)
         self.log.debug(f"Broadcasting uploaded photo with request ID {request_id}")
         self.log.debug(f"Broadcasting uploaded photo with request ID {request_id}")
         retry_num = 0
         retry_num = 0
         max_retries = 4
         max_retries = 4
@@ -437,10 +435,12 @@ class Portal(DBPortal, BasePortal):
             try:
             try:
                 return await sender.client.broadcast(
                 return await sender.client.broadcast(
                     self.thread_id,
                     self.thread_id,
-                    ThreadItemType.CONFIGURE_PHOTO,
+                    ThreadItemType.PHOTO_ATTACHMENT,
                     client_context=request_id,
                     client_context=request_id,
-                    upload_id=upload_resp.upload_id,
+                    attachment_fbid=str(upload_resp.media_id),
                     allow_full_aspect_ratio="true",
                     allow_full_aspect_ratio="true",
+                    ae_dual_send="false",
+                    btt_dual_send="false",
                 )
                 )
             except IGResponseError as e:
             except IGResponseError as e:
                 if e.response.status == 503 and retry_num < max_retries:
                 if e.response.status == 503 and retry_num < max_retries:
@@ -459,28 +459,6 @@ class Portal(DBPortal, BasePortal):
                 else:
                 else:
                     raise e
                     raise e
 
 
-    async def _needs_conversion(
-        self,
-        data: bytes,
-        mime_type: str,
-    ) -> bool:
-        if mime_type != "video/mp4":
-            self.log.info(f"Will convert: mime_type is {mime_type}")
-            return True
-        # Comment this out for now, as it seems like retrying on 500
-        # might be sufficient to fix the video upload problems
-        # (but keep it handy in case it turns out videos are still failing)
-        # else:
-        #     probe = await ffmpeg.probe_bytes(data, input_mime=mime_type, logger=self.log)
-        #     is_there_correct_stream = any(
-        #         "pix_fmt" in stream and stream["pix_fmt"] == "yuv420p"
-        #         for stream in probe["streams"]
-        #     )
-        #     if not is_there_correct_stream:
-        #         self.log.info(f"Will convert: no yuv420p stream found")
-        #         return True
-        #     return False
-
     async def _handle_matrix_video(
     async def _handle_matrix_video(
         self,
         self,
         sender: u.User,
         sender: u.User,
@@ -492,7 +470,7 @@ class Portal(DBPortal, BasePortal):
         width: int | None = None,
         width: int | None = None,
         height: int | None = None,
         height: int | None = None,
     ) -> CommandResponse:
     ) -> CommandResponse:
-        if await self._needs_conversion(data, mime_type):
+        if mime_type != "video/mp4":
             self.log.debug(f"Converting video in {event_id} from {mime_type} to mp4")
             self.log.debug(f"Converting video in {event_id} from {mime_type} to mp4")
             data = await ffmpeg.convert_bytes(
             data = await ffmpeg.convert_bytes(
                 data,
                 data,
@@ -512,9 +490,7 @@ class Portal(DBPortal, BasePortal):
             )
             )
 
 
         self.log.debug(f"Uploading video from {event_id}")
         self.log.debug(f"Uploading video from {event_id}")
-        _, upload_id = await sender.client.upload_mp4(
-            data, duration_ms=duration, width=width, height=height
-        )
+        upload_resp = await sender.client.upload(data, mimetype="video/mp4")
         self.log.debug(f"Broadcasting uploaded video with request ID {request_id}")
         self.log.debug(f"Broadcasting uploaded video with request ID {request_id}")
         retry_num = 0
         retry_num = 0
         max_retries = 4
         max_retries = 4
@@ -522,10 +498,12 @@ class Portal(DBPortal, BasePortal):
             try:
             try:
                 return await sender.client.broadcast(
                 return await sender.client.broadcast(
                     self.thread_id,
                     self.thread_id,
-                    ThreadItemType.CONFIGURE_VIDEO,
+                    ThreadItemType.VIDEO_ATTACHMENT,
                     client_context=request_id,
                     client_context=request_id,
-                    upload_id=upload_id,
-                    video_result="",
+                    attachment_fbid=str(upload_resp.media_id),
+                    video_result=str(upload_resp.media_id),
+                    ae_dual_send="false",
+                    btt_dual_send="false",
                 )
                 )
             except IGResponseError as e:
             except IGResponseError as e:
                 if e.response.status == 500 and retry_num < max_retries:
                 if e.response.status == 500 and retry_num < max_retries:
@@ -561,13 +539,15 @@ class Portal(DBPortal, BasePortal):
             )
             )
 
 
         self.log.debug(f"Uploading audio from {event_id}")
         self.log.debug(f"Uploading audio from {event_id}")
-        _, upload_id = await sender.client.upload_mp4(data, audio=True, duration_ms=duration)
+        upload_resp = await sender.client.upload(data, mimetype="audio/mp4")
         self.log.debug(f"Broadcasting uploaded audio with request ID {request_id}")
         self.log.debug(f"Broadcasting uploaded audio with request ID {request_id}")
         return await sender.client.broadcast(
         return await sender.client.broadcast(
             self.thread_id,
             self.thread_id,
-            ThreadItemType.SHARE_VOICE,
+            ThreadItemType.VOICE_ATTACHMENT,
             client_context=request_id,
             client_context=request_id,
-            upload_id=upload_id,
+            attachment_fbid=str(upload_resp.media_id),
+            # TODO upload_id?
+            ae_dual_send="false",
             waveform=json.dumps([(part or 0) / 1024 for part in waveform], separators=(",", ":")),
             waveform=json.dumps([(part or 0) / 1024 for part in waveform], separators=(",", ":")),
             waveform_sampling_frequency_hz="10",
             waveform_sampling_frequency_hz="10",
         )
         )