Browse Source

Make login and state storage work

Tulir Asokan 4 years ago
parent
commit
0d8e5e2217

+ 12 - 33
mauigpapi/http/account.py

@@ -14,24 +14,18 @@
 # 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, Type, TypeVar
-import base64
-import struct
-import time
 import json
-import io
 
 from ..types import CurrentUserResponse
 from .base import BaseAndroidAPI
 
-
 T = TypeVar('T')
 
 
 class AccountAPI(BaseAndroidAPI):
     async def current_user(self) -> CurrentUserResponse:
-        url = (self.url / "api/v1/accounts/current_user/").with_query({"edit": "true"})
-        resp = await self.http.get(url)
-        return CurrentUserResponse.deserialize(await self.handle_response(resp))
+        return await self.std_http_get(f"/api/v1/accounts/current_user/", query={"edit": "true"},
+                                       response_type=CurrentUserResponse)
 
     async def set_biography(self, text: str) -> CurrentUserResponse:
         # TODO entities?
@@ -59,10 +53,8 @@ class AccountAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
             "query": query,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/send_recovery_flow_email/",
-                                    data=self.sign(req, filter_nulls=True))
         # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post(f"/api/v1/accounts/send_recovery_flow_email/", data=req)
 
     async def edit_profile(self, external_url: Optional[str] = None, gender: Optional[str] = None,
                            phone_number: Optional[str] = None, username: Optional[str] = None,
@@ -82,9 +74,8 @@ class AccountAPI(BaseAndroidAPI):
             "_uuid": self.state.device.uuid,
             **kwargs,
         }
-        resp = await self.http.post(self.url / f"api/v1/accounts/{command}",
-                                    data=self.sign(req, filter_nulls=True))
-        return response_type.deserialize(await self.handle_response(resp))
+        return await self.std_http_post(f"/api/v1/accounts/{command}/", data=req,
+                                        filter_nulls=True, response_type=response_type)
 
     async def read_msisdn_header(self, usage: str = "default"):
         req = {
@@ -94,30 +85,22 @@ class AccountAPI(BaseAndroidAPI):
         headers = {
             "X-DEVICE-ID": self.state.device.uuid,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/read_msisdn_header/",
-                                    data=self.sign(req), headers=headers)
-        # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/read_msisdn_header/", data=req,
+                                        headers=headers)
 
     async def msisdn_header_bootstrap(self, usage: str = "default"):
         req = {
             "mobile_subno_usage": usage,
             "device_id": self.state.device.uuid,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/msisdn_header_bootstrap/",
-                                    data=self.sign(req))
-        # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/msisdn_header_bootstrap/", data=req)
 
     async def contact_point_prefill(self, usage: str = "default"):
         req = {
             "mobile_subno_usage": usage,
             "device_id": self.state.device.uuid,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/contact_point_prefill/",
-                                    data=self.sign(req))
-        # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/contact_point_prefill/", data=req)
 
     async def get_prefill_candidates(self):
         req = {
@@ -125,10 +108,8 @@ class AccountAPI(BaseAndroidAPI):
             "usages": json.dumps(["account_recovery_omnibox"]),
             "device_id": self.state.device.uuid,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/contact_point_prefill/",
-                                    data=self.sign(req))
         # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/get_prefill_candidates/", data=req)
 
     async def process_contact_point_signals(self):
         req = {
@@ -139,7 +120,5 @@ class AccountAPI(BaseAndroidAPI):
             "_uuid": self.state.device.uuid,
             "google_tokens": json.dumps([]),
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/process_contact_point_signals/",
-                                    data=self.sign(req))
-        # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/process_contact_point_signals/",
+                                        data=req)

+ 4 - 6
mauigpapi/http/attribution.py

@@ -19,10 +19,9 @@ from ..errors import IGResponseError
 
 class LogAttributionAPI(BaseAndroidAPI):
     async def log_attribution(self):
-        resp = await self.http.get(self.url / "api/v1/attribution/log_attribution/",
-                                   data=self.sign({"adid": self.state.device.adid}))
         # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/attribution/log_attribution/",
+                                        data={"adid": self.state.device.adid})
 
     async def log_resurrect_attribution(self):
         req = {
@@ -31,10 +30,9 @@ class LogAttributionAPI(BaseAndroidAPI):
             "adid": self.state.device.adid,
             "_uuid": self.state.device.uuid,
         }
-        resp = await self.http.get(self.url / "api/v1/attribution/log_resurrect_attribution/",
-                                   data=self.sign(req))
         # Apparently this throws an error in the official app, so we catch it and return the error
         try:
-            return await self.handle_response(resp)
+            return await self.std_http_post("/api/v1/attribution/log_resurrect_attribution/",
+                                            data=req)
         except IGResponseError as e:
             return e

+ 42 - 11
mauigpapi/http/base.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, Dict, Any
+from typing import Optional, Dict, Any, TypeVar, Type
 import random
 import time
 import json
@@ -29,6 +29,8 @@ from ..errors import (IGActionSpamError, IGNotFoundError, IGRateLimitError, IGCh
                       IGLoginBadPasswordError, IGLoginInvalidUserError,
                       IGLoginTwoFactorRequiredError)
 
+T = TypeVar('T')
+
 
 class BaseAndroidAPI:
     url = URL("https://i.instagram.com")
@@ -45,9 +47,10 @@ class BaseAndroidAPI:
             req = req.serialize()
         if isinstance(req, dict):
             def remove_nulls(d: dict) -> dict:
-                return {k: v for k, v in d.items() if v is not None}
+                return {k: remove_nulls(v) if isinstance(v, dict) else v
+                        for k, v in d.items() if v is not None}
 
-            req = json.dumps(req, object_hook=remove_nulls if filter_nulls else None)
+            req = json.dumps(remove_nulls(req) if filter_nulls else req)
         return {"signed_body": f"SIGNATURE.{req}"}
 
     @property
@@ -55,7 +58,7 @@ class BaseAndroidAPI:
         headers = {
             "User-Agent": self.state.user_agent,
             "X-Ads-Opt-Out": str(int(self.state.session.ads_opt_out)),
-            # "X-DEVICE--ID": self.state.device.uuid,
+            # "X-DEVICE-ID": self.state.device.uuid,
             "X-CM-Bandwidth-KBPS": "-1.000",
             "X-CM-Latency": "-1.000",
             "X-IG-App-Locale": self.state.device.language,
@@ -87,6 +90,38 @@ class BaseAndroidAPI:
         }
         return {k: v for k, v in headers.items() if v is not None}
 
+    async def std_http_post(self, path: str, data: Optional[JSON] = None,
+                            filter_nulls: bool = False, headers: Optional[Dict[str, str]] = None,
+                            response_type: Optional[Type[T]] = JSON) -> T:
+        headers = {**self.headers, **headers} if headers else self.headers
+        resp = await self.http.post(url=self.url.with_path(path), headers=headers,
+                                    data=self.sign(data, filter_nulls=filter_nulls))
+        print(f"{path} response: {await resp.text()}")
+        if response_type is str or response_type is None:
+            self._handle_response_headers(resp)
+            if response_type is str:
+                return await resp.text()
+            return None
+        json_data = await self.handle_response(resp)
+        if response_type is not JSON:
+            return response_type.deserialize(json_data)
+        return json_data
+
+    async def std_http_get(self, path: str, query: Optional[Dict[str, str]] = None,
+                           headers: Optional[Dict[str, str]] = None,
+                           response_type: Optional[Type[T]] = JSON) -> T:
+        headers = {**self.headers, **headers} if headers else self.headers
+        query = {k: v for k, v in (query or {}).items() if v is not None}
+        resp = await self.http.get(url=self.url.with_path(path).with_query(query), headers=headers)
+        print(f"{path} response: {await resp.text()}")
+        if response_type is None:
+            self._handle_response_headers(resp)
+            return None
+        json_data = await self.handle_response(resp)
+        if response_type is not JSON:
+            return response_type.deserialize(json_data)
+        return json_data
+
     async def handle_response(self, resp: ClientResponse) -> JSON:
         self._handle_response_headers(resp)
         body = await resp.json()
@@ -144,10 +179,6 @@ class BaseAndroidAPI:
             "IG-Set-IG-U-IG-Direct-Region-Hint": "region_hint"
         }
         for header, field in fields.items():
-            try:
-                value = resp.headers[header]
-            except KeyError:
-                pass
-            else:
-                if value:
-                    setattr(self.state.session, field, value)
+            value = resp.headers.get(header)
+            if value and (header != "IG-Set-Authorization" or not value.endswith(":")):
+                setattr(self.state.session, field, value)

+ 2 - 4
mauigpapi/http/direct_inbox_feed.py

@@ -31,7 +31,5 @@ class DirectInboxAPI(BaseAndroidAPI):
             "persistentBadging": "true",
             "limit": limit,
         }
-        query = {k: v for k, v in query.items() if v is not None}
-        url = (self.url / "api/v1/direct_v2/inbox/").with_query(query)
-        resp = await self.http.get(url, headers=self.headers)
-        return DirectInboxResponse.deserialize(await self.handle_response(resp))
+        return await self.std_http_get("/api/v1/direct_v2/inbox/", query=query,
+                                       response_type=DirectInboxResponse)

+ 2 - 2
mauigpapi/http/launcher.py

@@ -83,5 +83,5 @@ class LauncherSyncAPI(BaseAndroidAPI):
         })
 
     async def __sync(self, req: Dict[str, Any]):
-        resp = await self.http.get(self.url / "api/v1/launcher/sync/", data=self.sign(req))
-        return await self.handle_response(resp)
+        # TODO parse response?
+        return await self.std_http_post("/api/v1/launcher/sync/", data=req)

+ 13 - 18
mauigpapi/http/login.py

@@ -21,7 +21,7 @@ import json
 import io
 
 from Crypto.PublicKey import RSA
-from Crypto.Cipher import PKCS1_OAEP, AES
+from Crypto.Cipher import PKCS1_v1_5, AES
 from Crypto.Random import get_random_bytes
 
 from ..types import LoginResponse, LoginResponseUser, LogoutResponse
@@ -50,8 +50,8 @@ class LoginAPI(BaseAndroidAPI):
             "country_codes": json.dumps([{"country_code": "1", "source": "default"}]),
             "jazoest": self._jazoest,
         }
-        resp = await self.http.post(url=self.url / "api/v1/accounts/login/", data=self.sign(req))
-        return LoginResponse.deserialize(await self.handle_response(resp))
+        return await self.std_http_post("/api/v1/accounts/login/", data=req,
+                                        response_type=LoginResponse)
 
     async def one_tap_app_login(self, user_id: str, nonce: str) -> LoginResponse:
         req = {
@@ -63,9 +63,8 @@ class LoginAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
             "login_nonce": nonce,
         }
-        resp = await self.http.post(url=self.url / "api/v1/accounts/one_tap_app_login/",
-                                    data=self.sign(req))
-        return LoginResponse.deserialize(await self.handle_response(resp))
+        return await self.std_http_post("/api/v1/accounts/one_tap_app_login/", data=req,
+                                        response_type=LoginResponse)
 
     async def two_factor_login(self, username: str, code: str, identifier: str,
                                trust_device: bool = True, method: Optional[str] = "1"
@@ -80,9 +79,8 @@ class LoginAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
             "verification_method": method,
         }
-        resp = await self.http.post(url=self.url / "api/v1/accounts/one_tap_app_login/",
-                                    data=self.sign(req))
-        return LoginResponseUser.deserialize(await self.handle_response(resp))
+        return await self.std_http_post("/api/v1/accounts/two_factor_login/", data=req,
+                                        response_type=LoginResponseUser)
 
     async def logout(self, one_tap_app_login: Optional[bool] = None) -> LogoutResponse:
         req = {
@@ -93,9 +91,8 @@ class LoginAPI(BaseAndroidAPI):
             "_uuid": self.state.device.uuid,
             "one_tap_app_login": one_tap_app_login,
         }
-        resp = await self.http.post(url=self.url / "api/v1/accounts/logout/",
-                                    data=self.sign(req))
-        return LogoutResponse.deserialize(await self.handle_response(resp))
+        return await self.std_http_post("/api/v1/accounts/logout/", data=req,
+                                        response_type=LogoutResponse)
 
     async def change_password(self, old_password: str, new_password: str):
         return self.change_password_encrypted(old_password=self._encrypt_password(old_password),
@@ -112,10 +109,8 @@ class LoginAPI(BaseAndroidAPI):
             "enc_new_password1": new_password1,
             "enc_new_password2": new_password2,
         }
-        resp = await self.http.post(self.url / "api/v1/accounts/change_password/",
-                                    data=self.sign(req))
         # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_post("/api/v1/accounts/change_password/", data=req)
 
     def _encrypt_password(self, password: str) -> str:
         # Key and IV for AES encryption
@@ -125,10 +120,10 @@ class LoginAPI(BaseAndroidAPI):
         # Encrypt AES key with Instagram's RSA public key
         pubkey_bytes = base64.b64decode(self.state.session.password_encryption_pubkey)
         pubkey = RSA.import_key(pubkey_bytes)
-        cipher_rsa = PKCS1_OAEP.new(pubkey)
+        cipher_rsa = PKCS1_v1_5.new(pubkey)
         encrypted_rand_key = cipher_rsa.encrypt(rand_key)
 
-        cipher_aes = AES.new(rand_key, AES.MODE_GCM, iv=iv)
+        cipher_aes = AES.new(rand_key, AES.MODE_GCM, nonce=iv)
         # Add the current time to the additional authenticated data (AAD) section
         current_time = int(time.time())
         cipher_aes.update(str(current_time).encode("utf-8"))
@@ -144,7 +139,7 @@ class LoginAPI(BaseAndroidAPI):
         buf.write(encrypted_rand_key)
         buf.write(auth_tag)
         buf.write(encrypted_passwd)
-        encoded = base64.b64encode(buf.getvalue())
+        encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
         return f"#PWD_INSTAGRAM:4:{current_time}:{encoded}"
 
     @property

+ 4 - 4
mauigpapi/http/login_simulate.py

@@ -65,7 +65,7 @@ class LoginSimulateAPI(AccountAPI, LogAttributionAPI, QeSyncAPI, ZRTokenAPI, Log
             await item
 
     async def _facebook_ota(self):
-        url = (self.url / "api/v1/facebook_ota/").with_query({
+        query = {
             "fields": self.state.application.FACEBOOK_OTA_FIELDS,
             "custom_user_id": self.state.cookies.user_id,
             "signed_body": "SIGNATURE.",
@@ -73,9 +73,9 @@ class LoginSimulateAPI(AccountAPI, LogAttributionAPI, QeSyncAPI, ZRTokenAPI, Log
             "version_name": self.state.application.APP_VERSION,
             "custom_app_id": self.state.application.FACEBOOK_ORCA_APPLICATION_ID,
             "custom_device_id": self.state.device.uuid,
-        })
-        resp = await self.http.get(url)
-        return await self.handle_response(resp)
+        }
+        # TODO parse response?
+        return await self.std_http_get("/api/v1/facebook_ota/", query=query)
 
     async def upgrade_login(self) -> LoginResponse:
         user_id = self.state.cookies.user_id

+ 24 - 7
mauigpapi/http/qe.py

@@ -13,16 +13,33 @@
 #
 # 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 ..errors import IGCookieNotFoundError
+from ..types import QeSyncResponse
 from .base import BaseAndroidAPI
 
 
 class QeSyncAPI(BaseAndroidAPI):
-    async def qe_sync_experiments(self) -> None:
-        await self.__sync(self.state.application.EXPERIMENTS)
+    async def qe_sync_experiments(self) -> QeSyncResponse:
+        return await self.__sync(self.state.application.EXPERIMENTS)
 
-    async def qe_sync_login_experiments(self) -> None:
-        await self.__sync(self.state.application.LOGIN_EXPERIMENTS)
+    async def qe_sync_login_experiments(self) -> QeSyncResponse:
+        return await self.__sync(self.state.application.LOGIN_EXPERIMENTS)
 
-    async def __sync(self, experiments: str) -> None:
-        # TODO implement
-        pass
+    async def __sync(self, experiments: str) -> QeSyncResponse:
+        try:
+            uid = self.state.cookies.user_id
+        except IGCookieNotFoundError:
+            req = {"id": self.state.device.uuid}
+        else:
+            req = {
+                "_csrftoken": self.state.cookies.csrf_token,
+                "id": uid,
+                "_uid": uid,
+                "_uuid": self.state.device.uuid,
+            }
+        req["experiments"] = experiments
+        headers = {"X-DEVICE-ID": self.state.device.uuid}
+        resp = await self.std_http_post("/api/v1/qe/sync/", data=req, headers=headers,
+                                        response_type=QeSyncResponse)
+        self.state.experiments.update(resp)
+        return resp

+ 3 - 4
mauigpapi/http/zr.py

@@ -18,12 +18,11 @@ from .base import BaseAndroidAPI
 
 class ZRTokenAPI(BaseAndroidAPI):
     async def zr_token_result(self):
-        url = (self.url / "api/v1/zr/token/result/").with_query({
+        query = {
             "device_id": self.state.device.id,
             "token_hash": "",
             "custom_device_id": self.state.device.uuid,
             "fetch_reason": "token_expired",
-        })
-        resp = await self.http.get(url)
+        }
         # TODO parse response content
-        return await self.handle_response(resp)
+        return await self.std_http_get("/api/v1/zr/token/result/", query=query)

+ 6 - 19
mauigpapi/state/cookies.py

@@ -33,31 +33,18 @@ class Cookies(Serializable):
 
     def serialize(self) -> JSON:
         return {
-            "version": "tough-cookie@4.0.0",
-            "storeType": "MemoryCookieStore",
-            "rejectPublicSuffixes": True,
-            "cookies": [{
-                **morsel,
-                "key": key,
+            morsel.key: {
+                **{k: v for k, v in morsel.items() if v},
                 "value": morsel.value,
-            } for key, morsel in self.jar.filter_cookies(ig_url).items()],
+            } for morsel in self.jar
         }
 
     @classmethod
     def deserialize(cls, raw: JSON) -> 'Cookies':
         cookie = SimpleCookie()
-        for item in raw["cookies"]:
-            key = item.pop("key")
-            cookie[key] = item.pop("value")
-            item.pop("hostOnly", None)
-            item.pop("lastAccessed", None)
-            item.pop("creation", None)
-            try:
-                # Morsel.update() is case-insensitive, but not dash-insensitive
-                item["max-age"] = item.pop("maxAge")
-            except KeyError:
-                pass
-            cookie[key].update(item)
+        for key, data in raw.items():
+            cookie[key] = data.pop("value")
+            cookie[key].update(data)
         cookies = cls()
         cookies.jar.update_cookies(cookie, ig_url)
         return cookies

+ 7 - 8
mauigpapi/state/session.py

@@ -16,19 +16,18 @@
 from typing import Optional, Union
 
 from attr import dataclass
-import attr
 
 from mautrix.types import SerializableAttrs
 
 
 @dataclass
 class AndroidSession(SerializableAttrs['AndroidSession']):
-    eu_dc_enabled: Optional[bool] = attr.ib(default=None, metadata={"json": "euDCEnabled"})
-    thumbnail_cache_busting_value: int = attr.ib(default=1000, metadata={"json": "thumbnailCacheBustingValue"})
-    ads_opt_out: bool = attr.ib(default=None, metadata={"json": "adsOptOut"})
+    eu_dc_enabled: Optional[bool] = None
+    thumbnail_cache_busting_value: int = 1000
+    ads_opt_out: bool = False
 
-    ig_www_claim: Optional[str] = attr.ib(default=None, metadata={"json": "igWWWClaim"})
+    ig_www_claim: Optional[str] = None
     authorization: Optional[str] = None
-    password_encryption_pubkey: Optional[str] = attr.ib(default=None, metadata={"json": "passwordEncryptionPubKey"})
-    password_encryption_key_id: Union[None, str, int] = attr.ib(default=None, metadata={"json": "passwordEncryptionKeyId"})
-    region_hint: Optional[str] = attr.ib(default=None, metadata={"json": "regionHint"})
+    password_encryption_pubkey: Optional[str] = None
+    password_encryption_key_id: Union[None, str, int] = None
+    region_hint: Optional[str] = None

+ 29 - 12
mauigpapi/types/account.py

@@ -13,31 +13,40 @@
 #
 # 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 Any, List
+from typing import Any, List, Optional, Dict
 
 from attr import dataclass
 
 from mautrix.types import SerializableAttrs
 
 
-@dataclass
+@dataclass(kw_only=True)
 class BaseResponseUser(SerializableAttrs['BaseResponseUser']):
     pk: int
     username: str
     full_name: str
     is_private: bool
     profile_pic_url: str
-    profile_pic_id: str
+    # When this doesn't exist, the profile picture is probably the default one
+    profile_pic_id: Optional[str] = None
     is_verified: bool
     has_anonymous_profile_picture: bool
 
     phone_number: str
-    country_code: int
-    national_number: int
+    country_code: Optional[int] = None
+    national_number: Optional[int] = None
 
+    # TODO enum both of these?
     reel_auto_archive: str
     allowed_commenter_type: str
 
+    # These are at least in login and current_user, might not be in other places though
+    is_business: bool
+    # TODO enum?
+    account_type: int
+    is_call_to_action_enabled: Any
+    account_badges: List[Any]
+
 
 @dataclass
 class EntityText(SerializableAttrs['EntityText']):
@@ -53,23 +62,31 @@ class HDProfilePictureVersion(SerializableAttrs['HDProfilePictureVersion']):
     height: int
 
 
-# Not sure if these are actually the same
-HDProfilePictureURLInfo = HDProfilePictureVersion
+@dataclass
+class ProfileEditParams(SerializableAttrs['ProfileEditParams']):
+    should_show_confirmation_dialog: bool
+    is_pending_review: bool
+    confirmation_dialog_text: str
+    disclaimer_text: str
 
 
-@dataclass
-class CurrentUser(SerializableAttrs['CurrentUser']):
+@dataclass(kw_only=True)
+class CurrentUser(BaseResponseUser, SerializableAttrs['CurrentUser']):
     biography: str
     can_link_entities_in_bio: bool
     biography_with_entities: EntityText
+    biography_product_mentions: List[Any]
     external_url: str
-    has_biography_translation: bool
-    hd_profile_pic_versions: HDProfilePictureVersion
-    hd_profile_pic_url_info: HDProfilePictureURLInfo
+    has_biography_translation: bool = False
+    hd_profile_pic_versions: List[HDProfilePictureVersion]
+    hd_profile_pic_url_info: HDProfilePictureVersion
     show_conversion_edit_entry: bool
     birthday: Any
     gender: int
+    custom_gender: str
     email: str
+    profile_edit_params: Dict[str, ProfileEditParams]
+
 
 @dataclass
 class CurrentUserResponse(SerializableAttrs['CurrentUserResponse']):

+ 8 - 4
mauigpapi/types/login.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 Any, Optional
+from typing import Any, Optional, List
 
 from attr import dataclass
 
@@ -33,15 +33,19 @@ class LoginResponseNametag(SerializableAttrs['LoginResponseNametag']):
 @dataclass
 class LoginResponseUser(BaseResponseUser, SerializableAttrs['LoginResponseUser']):
     can_boost_post: bool
-    is_business: bool
-    account_type: int
-    is_call_to_action_enabled: Any
     can_see_organic_insights: bool
     show_insights_terms: bool
     has_placed_orders: bool
     nametag: LoginResponseNametag
     allow_contacts_sync: bool
 
+    # These are from manually observed responses rather than igpapi
+    total_igtv_videos: int
+    interop_messaging_user_fbid: int
+    is_using_unified_inbox_for_direct: bool
+    can_see_primary_country_in_settings: str
+    professional_conversion_suggested_account_type: Optional[int]
+
 
 @dataclass
 class LoginResponse(SerializableAttrs['LoginResponse']):

+ 0 - 1
optional-requirements.txt

@@ -3,7 +3,6 @@
 
 #/e2be
 python-olm>=3,<4
-pycryptodome>=3,<4
 unpaddedbase64>=1,<2
 
 #/metrics

+ 1 - 0
requirements.txt

@@ -6,3 +6,4 @@ yarl>=1,<2
 attrs>=19.1
 mautrix>=0.8,<0.9
 asyncpg>=0.20,<0.22
+pycryptodome>=3,<4