Bladeren bron

Blacken and isort everything

Tulir Asokan 3 jaren geleden
bovenliggende
commit
73d356de4b
60 gewijzigde bestanden met toevoegingen van 2678 en 1419 verwijderingen
  1. 18 0
      .github/workflows/python-lint.yml
  2. 2 2
      mauigpapi/__init__.py
  3. 21 7
      mauigpapi/errors/__init__.py
  4. 1 0
      mauigpapi/errors/base.py
  5. 2 1
      mauigpapi/errors/response.py
  6. 48 23
      mauigpapi/http/account.py
  7. 4 4
      mauigpapi/http/api.py
  8. 64 32
      mauigpapi/http/base.py
  9. 29 18
      mauigpapi/http/challenge.py
  10. 42 25
      mauigpapi/http/login.py
  11. 78 34
      mauigpapi/http/thread.py
  12. 50 28
      mauigpapi/http/upload.py
  13. 3 2
      mauigpapi/http/user.py
  14. 2 2
      mauigpapi/mqtt/__init__.py
  15. 338 162
      mauigpapi/mqtt/conn.py
  16. 13 5
      mauigpapi/mqtt/otclient.py
  17. 226 111
      mauigpapi/mqtt/subscription.py
  18. 3 3
      mauigpapi/mqtt/thrift/__init__.py
  19. 12 17
      mauigpapi/mqtt/thrift/autospec.py
  20. 3 3
      mauigpapi/mqtt/thrift/ig_objects.py
  21. 6 5
      mauigpapi/mqtt/thrift/read.py
  22. 1 1
      mauigpapi/mqtt/thrift/type.py
  23. 24 15
      mauigpapi/mqtt/thrift/write.py
  24. 350 219
      mauigpapi/state/application.py
  25. 8 5
      mauigpapi/state/cookies.py
  26. 1 1
      mauigpapi/state/device.py
  27. 8 6
      mauigpapi/state/state.py
  28. 97 27
      mauigpapi/types/__init__.py
  29. 1 1
      mauigpapi/types/account.py
  30. 1 0
      mauigpapi/types/challenge.py
  31. 2 1
      mauigpapi/types/direct_inbox.py
  32. 1 1
      mauigpapi/types/error.py
  33. 1 1
      mauigpapi/types/login.py
  34. 3 3
      mauigpapi/types/mqtt.py
  35. 8 6
      mauigpapi/types/qe.py
  36. 3 2
      mauigpapi/types/thread.py
  37. 8 7
      mauigpapi/types/thread_item.py
  38. 1 1
      mauigpapi/types/user.py
  39. 17 13
      mautrix_instagram/__main__.py
  40. 58 32
      mautrix_instagram/commands/auth.py
  41. 44 16
      mautrix_instagram/commands/conn.py
  42. 13 6
      mautrix_instagram/commands/misc.py
  43. 2 2
      mautrix_instagram/commands/typehint.py
  44. 8 6
      mautrix_instagram/config.py
  45. 4 4
      mautrix_instagram/db/__init__.py
  46. 21 12
      mautrix_instagram/db/message.py
  47. 85 46
      mautrix_instagram/db/portal.py
  48. 64 39
      mautrix_instagram/db/puppet.py
  49. 45 21
      mautrix_instagram/db/reaction.py
  50. 24 12
      mautrix_instagram/db/upgrade.py
  51. 24 19
      mautrix_instagram/db/user.py
  52. 3 4
      mautrix_instagram/get_version.py
  53. 50 24
      mautrix_instagram/matrix.py
  54. 394 201
      mautrix_instagram/portal.py
  55. 80 42
      mautrix_instagram/puppet.py
  56. 122 73
      mautrix_instagram/user.py
  57. 15 4
      mautrix_instagram/util/color_log.py
  58. 1 1
      mautrix_instagram/version.py
  59. 109 61
      mautrix_instagram/web/provisioning_api.py
  60. 12 0
      pyproject.toml

+ 18 - 0
.github/workflows/python-lint.yml

@@ -0,0 +1,18 @@
+name: Python lint
+
+on: [push, pull_request]
+
+jobs:
+  lint:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - uses: actions/setup-python@v2
+      with:
+        python-version: "3.10"
+    - uses: isort/isort-action@master
+      with:
+        sortPaths: "./mautrix_instagram ./mauigpapi"
+    - uses: psf/black@21.12b0
+      with:
+        src: "./mautrix_instagram ./mauigpapi"

+ 2 - 2
mauigpapi/__init__.py

@@ -1,3 +1,3 @@
-from .state import AndroidState
-from .mqtt import AndroidMQTT, SkywalkerSubscription, GraphQLSubscription
 from .http import AndroidAPI
+from .mqtt import AndroidMQTT, GraphQLSubscription, SkywalkerSubscription
+from .state import AndroidState

+ 21 - 7
mauigpapi/errors/__init__.py

@@ -1,8 +1,22 @@
 from .base import IGError
-from .mqtt import IGMQTTError, MQTTNotLoggedIn, MQTTNotConnected, IrisSubscribeError
-from .state import IGUserIDNotFoundError, IGCookieNotFoundError, IGNoCheckpointError
-from .response import (IGResponseError, IGActionSpamError, IGNotFoundError, IGRateLimitError,
-                       IGCheckpointError, IGUserHasLoggedOutError, IGLoginRequiredError,
-                       IGPrivateUserError, IGSentryBlockError, IGInactiveUserError, IGLoginError,
-                       IGLoginTwoFactorRequiredError, IGLoginBadPasswordError, IGBad2FACodeError,
-                       IGLoginInvalidUserError, IGNotLoggedInError, IGChallengeWrongCodeError)
+from .mqtt import IGMQTTError, IrisSubscribeError, MQTTNotConnected, MQTTNotLoggedIn
+from .response import (
+    IGActionSpamError,
+    IGBad2FACodeError,
+    IGChallengeWrongCodeError,
+    IGCheckpointError,
+    IGInactiveUserError,
+    IGLoginBadPasswordError,
+    IGLoginError,
+    IGLoginInvalidUserError,
+    IGLoginRequiredError,
+    IGLoginTwoFactorRequiredError,
+    IGNotFoundError,
+    IGNotLoggedInError,
+    IGPrivateUserError,
+    IGRateLimitError,
+    IGResponseError,
+    IGSentryBlockError,
+    IGUserHasLoggedOutError,
+)
+from .state import IGCookieNotFoundError, IGNoCheckpointError, IGUserIDNotFoundError

+ 1 - 0
mauigpapi/errors/base.py

@@ -14,5 +14,6 @@
 # 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/>.
 
+
 class IGError(Exception):
     pass

+ 2 - 1
mauigpapi/errors/response.py

@@ -16,10 +16,11 @@
 from typing import Optional, get_type_hints
 
 from aiohttp import ClientResponse
+
 from mautrix.types import JSON, Serializable
 
+from ..types import CheckpointResponse, LoginErrorResponse, LoginRequiredResponse, SpamResponse
 from .base import IGError
-from ..types import SpamResponse, CheckpointResponse, LoginRequiredResponse, LoginErrorResponse
 
 
 class IGChallengeWrongCodeError(IGError):

+ 48 - 23
mauigpapi/http/account.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,27 +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 typing import Optional, Type, TypeVar
+from __future__ import annotations
+
+from typing import Type, TypeVar
 import json
 
 from ..types import CurrentUserResponse
 from .base import BaseAndroidAPI
 
-T = TypeVar('T')
+T = TypeVar("T")
 
 
 class AccountAPI(BaseAndroidAPI):
     async def current_user(self) -> CurrentUserResponse:
-        return await self.std_http_get(f"/api/v1/accounts/current_user/", query={"edit": "true"},
-                                       response_type=CurrentUserResponse)
+        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?
         return await self.__command("set_biography", device_id=self.state.device.id, raw_text=text)
 
     async def set_profile_picture(self, upload_id: str) -> CurrentUserResponse:
-        return await self.__command("change_profile_picture",
-                                    use_fbuploader="true", upload_id=upload_id)
+        return await self.__command(
+            "change_profile_picture", use_fbuploader="true", upload_id=upload_id
+        )
 
     async def remove_profile_picture(self) -> CurrentUserResponse:
         return await self.__command("remove_profile_picture")
@@ -56,26 +62,44 @@ class AccountAPI(BaseAndroidAPI):
         # TODO parse response content
         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,
-                           # TODO should there be a last_name?
-                           first_name: Optional[str] = None, biography: Optional[str] = None,
-                           email: Optional[str] = None) -> CurrentUserResponse:
-        return await self.__command("edit_profile", device_id=self.state.device.id, email=email,
-                                    external_url=external_url, first_name=first_name,
-                                    username=username, phone_number=phone_number, gender=gender,
-                                    biography=biography)
-
-    async def __command(self, command: str, response_type: Type[T] = CurrentUserResponse,
-                        **kwargs: str) -> T:
+    async def edit_profile(
+        self,
+        external_url: str | None = None,
+        gender: str | None = None,
+        phone_number: str | None = None,
+        username: str | None = None,
+        # TODO should there be a last_name?
+        first_name: str | None = None,
+        biography: str | None = None,
+        email: str | None = None,
+    ) -> CurrentUserResponse:
+        return await self.__command(
+            "edit_profile",
+            device_id=self.state.device.id,
+            email=email,
+            external_url=external_url,
+            first_name=first_name,
+            username=username,
+            phone_number=phone_number,
+            gender=gender,
+            biography=biography,
+        )
+
+    async def __command(
+        self, command: str, response_type: Type[T] = CurrentUserResponse, **kwargs: str
+    ) -> T:
         req = {
             "_csrftoken": self.state.cookies.csrf_token,
             "_uid": self.state.cookies.user_id,
             "_uuid": self.state.device.uuid,
             **kwargs,
         }
-        return await self.std_http_post(f"/api/v1/accounts/{command}/", data=req,
-                                        filter_nulls=True, response_type=response_type)
+        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 = {
@@ -116,5 +140,6 @@ class AccountAPI(BaseAndroidAPI):
             "_uuid": self.state.device.uuid,
             "google_tokens": json.dumps([]),
         }
-        return await self.std_http_post("/api/v1/accounts/process_contact_point_signals/",
-                                        data=req)
+        return await self.std_http_post(
+            "/api/v1/accounts/process_contact_point_signals/", data=req
+        )

+ 4 - 4
mauigpapi/http/api.py

@@ -1,9 +1,9 @@
-from .thread import ThreadAPI
-from .upload import UploadAPI
-from .challenge import ChallengeAPI
 from .account import AccountAPI
-from .qe import QeSyncAPI
+from .challenge import ChallengeAPI
 from .login import LoginAPI
+from .qe import QeSyncAPI
+from .thread import ThreadAPI
+from .upload import UploadAPI
 from .user import UserAPI
 
 

+ 64 - 32
mauigpapi/http/base.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,25 +13,45 @@
 #
 # 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, TypeVar, Type, Union
+from __future__ import annotations
+
+from typing import Any, Type, TypeVar
+import json
 import logging
 import random
 import time
-import json
 
-from aiohttp import ClientSession, ClientResponse
+from aiohttp import ClientResponse, ClientSession
 from yarl import URL
+
 from mautrix.types import JSON, Serializable
 from mautrix.util.logging import TraceLogger
 
+from ..errors import (
+    IGActionSpamError,
+    IGBad2FACodeError,
+    IGCheckpointError,
+    IGInactiveUserError,
+    IGLoginBadPasswordError,
+    IGLoginInvalidUserError,
+    IGLoginRequiredError,
+    IGLoginTwoFactorRequiredError,
+    IGNotFoundError,
+    IGPrivateUserError,
+    IGRateLimitError,
+    IGResponseError,
+    IGSentryBlockError,
+    IGUserHasLoggedOutError,
+)
 from ..state import AndroidState
-from ..errors import (IGActionSpamError, IGNotFoundError, IGRateLimitError, IGCheckpointError,
-                      IGUserHasLoggedOutError, IGLoginRequiredError, IGPrivateUserError,
-                      IGSentryBlockError, IGInactiveUserError, IGResponseError, IGBad2FACodeError,
-                      IGLoginBadPasswordError, IGLoginInvalidUserError,
-                      IGLoginTwoFactorRequiredError)
 
-T = TypeVar('T')
+T = TypeVar("T")
+
+
+def remove_nulls(d: dict) -> dict:
+    return {
+        k: remove_nulls(v) if isinstance(v, dict) else v for k, v in d.items() if v is not None
+    }
 
 
 class BaseAndroidAPI:
@@ -40,25 +60,21 @@ class BaseAndroidAPI:
     state: AndroidState
     log: TraceLogger
 
-    def __init__(self, state: AndroidState, log: Optional[TraceLogger] = None) -> None:
+    def __init__(self, state: AndroidState, log: TraceLogger | None = None) -> None:
         self.http = ClientSession(cookie_jar=state.cookies.jar)
         self.state = state
         self.log = log or logging.getLogger("mauigpapi.http")
 
     @staticmethod
-    def sign(req: Any, filter_nulls: bool = False) -> Dict[str, str]:
+    def sign(req: Any, filter_nulls: bool = False) -> dict[str, str]:
         if isinstance(req, Serializable):
             req = req.serialize()
         if isinstance(req, dict):
-            def remove_nulls(d: dict) -> dict:
-                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(remove_nulls(req) if filter_nulls else req)
         return {"signed_body": f"SIGNATURE.{req}"}
 
     @property
-    def _headers(self) -> Dict[str, str]:
+    def _headers(self) -> dict[str, str]:
         headers = {
             "x-ads-opt-out": str(int(self.state.session.ads_opt_out)),
             "x-device-id": self.state.device.uuid,
@@ -70,8 +86,11 @@ class BaseAndroidAPI:
             "x-ig-bandwidth-speed-kbps": "-1.000",
             "x-ig-bandwidth-totalbytes-b": "0",
             "x-ig-bandwidth-totaltime-ms": "0",
-            "x-ig-eu-dc-enabled": (str(self.state.session.eu_dc_enabled).lower()
-                                   if self.state.session.eu_dc_enabled is not None else None),
+            "x-ig-eu-dc-enabled": (
+                str(self.state.session.eu_dc_enabled).lower()
+                if self.state.session.eu_dc_enabled is not None
+                else None
+            ),
             "x-ig-app-startup-country": self.state.device.language.split("_")[1],
             "x-bloks-version-id": self.state.application.BLOKS_VERSION_ID,
             "x-ig-www-claim": self.state.session.ig_www_claim or "0",
@@ -97,18 +116,27 @@ class BaseAndroidAPI:
         }
         return {k: v for k, v in headers.items() if v is not None}
 
-    def raw_http_get(self, url: Union[URL, str]):
+    def raw_http_get(self, url: URL | str):
         if isinstance(url, str):
             url = URL(url, encoded=True)
-        return self.http.get(url, headers={
-            "user-agent": self.state.user_agent,
-            "accept-language": self.state.device.language.replace("_", "-"),
-        })
-
-    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,
-                            query: Optional[Dict[str, str]] = None,
-                            response_type: Optional[Type[T]] = JSON) -> T:
+        return self.http.get(
+            url,
+            headers={
+                "user-agent": self.state.user_agent,
+                "accept-language": self.state.device.language.replace("_", "-"),
+            },
+        )
+
+    async def std_http_post(
+        self,
+        path: str,
+        data: JSON = None,
+        raw: bool = False,
+        filter_nulls: bool = False,
+        headers: dict[str, str] | None = None,
+        query: dict[str, str] | None = None,
+        response_type: Type[T] | None = JSON,
+    ) -> T:
         headers = {**self._headers, **headers} if headers else self._headers
         if not raw:
             data = self.sign(data, filter_nulls=filter_nulls)
@@ -125,9 +153,13 @@ class BaseAndroidAPI:
             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:
+    async def std_http_get(
+        self,
+        path: str,
+        query: dict[str, str] | None = None,
+        headers: dict[str, str] | None = None,
+        response_type: Type[T] | None = 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)

+ 29 - 18
mauigpapi/http/challenge.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,11 +13,11 @@
 #
 # 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 Union
+from __future__ import annotations
 
-from .base import BaseAndroidAPI
+from ..errors import IGChallengeWrongCodeError, IGResponseError
 from ..types import ChallengeStateResponse
-from ..errors import IGResponseError, IGChallengeWrongCodeError
+from .base import BaseAndroidAPI
 
 
 class ChallengeAPI(BaseAndroidAPI):
@@ -30,11 +30,13 @@ class ChallengeAPI(BaseAndroidAPI):
             "guid": self.state.device.uuid,
             "device_id": self.state.device.id,
         }
-        return self.__handle_resp(await self.std_http_get(self.__path, query=query,
-                                                          response_type=ChallengeStateResponse))
+        return self.__handle_resp(
+            await self.std_http_get(self.__path, query=query, response_type=ChallengeStateResponse)
+        )
 
-    async def challenge_select_method(self, choice: str, is_replay: bool = False
-                                      ) -> ChallengeStateResponse:
+    async def challenge_select_method(
+        self, choice: str, is_replay: bool = False
+    ) -> ChallengeStateResponse:
         path = self.__path
         if is_replay:
             path = path.replace("/challenge/", "/challenge/replay/")
@@ -44,8 +46,9 @@ class ChallengeAPI(BaseAndroidAPI):
             "guid": self.state.device.uuid,
             "device_id": self.state.device.id,
         }
-        return self.__handle_resp(await self.std_http_post(path, data=req,
-                                                           response_type=ChallengeStateResponse))
+        return self.__handle_resp(
+            await self.std_http_post(path, data=req, response_type=ChallengeStateResponse)
+        )
 
     async def challenge_delta_review(self, was_me: bool = True) -> ChallengeStateResponse:
         return await self.challenge_select_method("0" if was_me else "1")
@@ -57,10 +60,11 @@ class ChallengeAPI(BaseAndroidAPI):
             "guid": self.state.device.uuid,
             "device_id": self.state.device.id,
         }
-        return self.__handle_resp(await self.std_http_post(self.__path, data=req,
-                                                           response_type=ChallengeStateResponse))
+        return self.__handle_resp(
+            await self.std_http_post(self.__path, data=req, response_type=ChallengeStateResponse)
+        )
 
-    async def challenge_send_security_code(self, code: Union[str, int]) -> ChallengeStateResponse:
+    async def challenge_send_security_code(self, code: str | int) -> ChallengeStateResponse:
         req = {
             "security_code": code,
             "_csrftoken": self.state.cookies.csrf_token,
@@ -68,8 +72,11 @@ class ChallengeAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
         }
         try:
-            return self.__handle_resp(await self.std_http_post(
-                self.__path, data=req, response_type=ChallengeStateResponse))
+            return self.__handle_resp(
+                await self.std_http_post(
+                    self.__path, data=req, response_type=ChallengeStateResponse
+                )
+            )
         except IGResponseError as e:
             if e.response.status == 400:
                 raise IGChallengeWrongCodeError((await e.response.json())["message"]) from e
@@ -81,9 +88,13 @@ class ChallengeAPI(BaseAndroidAPI):
             "guid": self.state.device.uuid,
             "device_id": self.state.device.id,
         }
-        return self.__handle_resp(await self.std_http_post(
-            self.__path.replace("/challenge/", "/challenge/reset/"),
-            data=req, response_type=ChallengeStateResponse))
+        return self.__handle_resp(
+            await self.std_http_post(
+                self.__path.replace("/challenge/", "/challenge/reset/"),
+                data=req,
+                response_type=ChallengeStateResponse,
+            )
+        )
 
     async def challenge_auto(self, reset: bool = False) -> ChallengeStateResponse:
         if reset:

+ 42 - 25
mauigpapi/http/login.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,24 +13,29 @@
 #
 # 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
+from __future__ import annotations
+
 import base64
+import io
+import json
 import struct
 import time
-import json
-import io
 
+from Crypto.Cipher import AES, PKCS1_v1_5
 from Crypto.PublicKey import RSA
-from Crypto.Cipher import PKCS1_v1_5, AES
 from Crypto.Random import get_random_bytes
 
-from ..types import LoginResponse, LoginResponseUser, LogoutResponse
+from ..types import LoginResponse, LogoutResponse
 from .base import BaseAndroidAPI
 
 
 class LoginAPI(BaseAndroidAPI):
-    async def login(self, username: str, password: Optional[str] = None,
-                    encrypted_password: Optional[str] = None) -> LoginResponse:
+    async def login(
+        self,
+        username: str,
+        password: str | None = None,
+        encrypted_password: str | None = None,
+    ) -> LoginResponse:
         if password:
             if encrypted_password:
                 raise ValueError("Only one of password or encrypted_password must be provided")
@@ -50,8 +55,9 @@ class LoginAPI(BaseAndroidAPI):
             "country_codes": json.dumps([{"country_code": "1", "source": "default"}]),
             "jazoest": self._jazoest,
         }
-        return await self.std_http_post("/api/v1/accounts/login/", data=req,
-                                        response_type=LoginResponse)
+        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,12 +69,18 @@ class LoginAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
             "login_nonce": nonce,
         }
-        return await self.std_http_post("/api/v1/accounts/one_tap_app_login/", data=req,
-                                        response_type=LoginResponse)
+        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, is_totp: bool = True,
-                               ) -> LoginResponse:
+    async def two_factor_login(
+        self,
+        username: str,
+        code: str,
+        identifier: str,
+        trust_device: bool = True,
+        is_totp: bool = True,
+    ) -> LoginResponse:
         req = {
             "verification_code": code,
             "_csrftoken": self.state.cookies.csrf_token,
@@ -79,10 +91,11 @@ class LoginAPI(BaseAndroidAPI):
             "device_id": self.state.device.id,
             "verification_method": "0" if is_totp else "1",
         }
-        return await self.std_http_post("/api/v1/accounts/two_factor_login/", data=req,
-                                        response_type=LoginResponse)
+        return await self.std_http_post(
+            "/api/v1/accounts/two_factor_login/", data=req, response_type=LoginResponse
+        )
 
-    async def logout(self, one_tap_app_login: Optional[bool] = None) -> LogoutResponse:
+    async def logout(self, one_tap_app_login: bool | None = None) -> LogoutResponse:
         req = {
             "guid": self.state.device.uuid,
             "phone_id": self.state.device.phone_id,
@@ -91,16 +104,20 @@ class LoginAPI(BaseAndroidAPI):
             "_uuid": self.state.device.uuid,
             "one_tap_app_login": one_tap_app_login,
         }
-        return await self.std_http_post("/api/v1/accounts/logout/", data=req,
-                                        response_type=LogoutResponse)
+        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),
-                                              new_password1=self._encrypt_password(new_password),
-                                              new_password2=self._encrypt_password(new_password))
+        return self.change_password_encrypted(
+            old_password=self._encrypt_password(old_password),
+            new_password1=self._encrypt_password(new_password),
+            new_password2=self._encrypt_password(new_password),
+        )
 
-    async def change_password_encrypted(self, old_password: str, new_password1: str,
-                                        new_password2: str):
+    async def change_password_encrypted(
+        self, old_password: str, new_password1: str, new_password2: str
+    ):
         req = {
             "_csrftoken": self.state.cookies.csrf_token,
             "_uid": self.state.cookies.user_id,

+ 78 - 34
mauigpapi/http/thread.py

@@ -13,17 +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 typing import Optional, AsyncIterable, Type, Union
+from __future__ import annotations
 
+from typing import AsyncIterable, Type
+
+from ..types import (
+    CommandResponse,
+    DMInboxResponse,
+    DMThreadResponse,
+    ShareVoiceResponse,
+    Thread,
+    ThreadAction,
+    ThreadItem,
+    ThreadItemType,
+)
 from .base import BaseAndroidAPI, T
-from ..types import (DMInboxResponse, DMThreadResponse, Thread, ThreadItem, ThreadAction,
-                     ThreadItemType, CommandResponse, ShareVoiceResponse)
 
 
 class ThreadAPI(BaseAndroidAPI):
-    async def get_inbox(self, cursor: Optional[str] = None, seq_id: Optional[str] = None,
-                        message_limit: int = 10, limit: int = 20, pending: bool = False,
-                        direction: str = "older") -> DMInboxResponse:
+    async def get_inbox(
+        self,
+        cursor: str | None = None,
+        seq_id: str | None = None,
+        message_limit: int = 10,
+        limit: int = 20,
+        pending: bool = False,
+        direction: str = "older",
+    ) -> DMInboxResponse:
         query = {
             "visual_message_return_type": "unseen",
             "cursor": cursor,
@@ -34,11 +50,13 @@ class ThreadAPI(BaseAndroidAPI):
             "limit": limit,
         }
         inbox_type = "pending_inbox" if pending else "inbox"
-        return await self.std_http_get(f"/api/v1/direct_v2/{inbox_type}/", query=query,
-                                       response_type=DMInboxResponse)
+        return await self.std_http_get(
+            f"/api/v1/direct_v2/{inbox_type}/", query=query, response_type=DMInboxResponse
+        )
 
-    async def iter_inbox(self, start_at: Optional[DMInboxResponse] = None,
-                         message_limit: int = 10) -> AsyncIterable[Thread]:
+    async def iter_inbox(
+        self, start_at: DMInboxResponse | None = None, message_limit: int = 10
+    ) -> AsyncIterable[Thread]:
         if start_at:
             cursor = start_at.inbox.oldest_cursor
             seq_id = start_at.seq_id
@@ -57,9 +75,14 @@ class ThreadAPI(BaseAndroidAPI):
             for thread in resp.inbox.threads:
                 yield thread
 
-    async def get_thread(self, thread_id: str, cursor: Optional[str] = None, limit: int = 10,
-                         direction: str = "older", seq_id: Optional[int] = None
-                         ) -> DMThreadResponse:
+    async def get_thread(
+        self,
+        thread_id: str,
+        cursor: str | None = None,
+        limit: int = 10,
+        direction: str = "older",
+        seq_id: int | None = None,
+    ) -> DMThreadResponse:
         query = {
             "visual_message_return_type": "unseen",
             "cursor": cursor,
@@ -67,11 +90,13 @@ class ThreadAPI(BaseAndroidAPI):
             "seq_id": seq_id,
             "limit": limit,
         }
-        return await self.std_http_get(f"/api/v1/direct_v2/threads/{thread_id}/", query=query,
-                                       response_type=DMThreadResponse)
+        return await self.std_http_get(
+            f"/api/v1/direct_v2/threads/{thread_id}/", query=query, response_type=DMThreadResponse
+        )
 
-    async def iter_thread(self, thread_id: str, seq_id: Optional[int] = None,
-                          cursor: Optional[str] = None) -> AsyncIterable[ThreadItem]:
+    async def iter_thread(
+        self, thread_id: str, seq_id: int | None = None, cursor: str | None = None
+    ) -> AsyncIterable[ThreadItem]:
         has_more = True
         while has_more:
             resp = await self.get_thread(thread_id, seq_id=seq_id, cursor=cursor)
@@ -81,13 +106,20 @@ class ThreadAPI(BaseAndroidAPI):
                 yield item
 
     async def delete_item(self, thread_id: str, item_id: str) -> None:
-        await self.std_http_post(f"/api/v1/direct_v2/threads/{thread_id}/items/{item_id}/delete/",
-                                 data={"_csrftoken": self.state.cookies.csrf_token,
-                                       "_uuid": self.state.device.uuid})
+        await self.std_http_post(
+            f"/api/v1/direct_v2/threads/{thread_id}/items/{item_id}/delete/",
+            data={"_csrftoken": self.state.cookies.csrf_token, "_uuid": self.state.device.uuid},
+        )
 
-    async def _broadcast(self, thread_id: str, item_type: str, response_type: Type[T],
-                         signed: bool = False, client_context: Optional[str] = None, **kwargs
-                         ) -> T:
+    async def _broadcast(
+        self,
+        thread_id: str,
+        item_type: str,
+        response_type: Type[T],
+        signed: bool = False,
+        client_context: str | None = None,
+        **kwargs,
+    ) -> T:
         client_context = client_context or self.state.gen_client_context()
         form = {
             "action": ThreadAction.SEND_ITEM.value,
@@ -102,17 +134,29 @@ class ThreadAPI(BaseAndroidAPI):
             **kwargs,
             "offline_threading_id": client_context,
         }
-        return await self.std_http_post(f"/api/v1/direct_v2/threads/broadcast/{item_type}/",
-                                        data=form, raw=not signed, response_type=response_type)
+        return await self.std_http_post(
+            f"/api/v1/direct_v2/threads/broadcast/{item_type}/",
+            data=form,
+            raw=not signed,
+            response_type=response_type,
+        )
 
-    async def broadcast(self, thread_id: str, item_type: ThreadItemType, signed: bool = False,
-                        client_context: Optional[str] = None, **kwargs) -> CommandResponse:
-        return await self._broadcast(thread_id, item_type.value, CommandResponse, signed,
-                                     client_context, **kwargs)
+    async def broadcast(
+        self,
+        thread_id: str,
+        item_type: ThreadItemType,
+        signed: bool = False,
+        client_context: str | None = None,
+        **kwargs,
+    ) -> CommandResponse:
+        return await self._broadcast(
+            thread_id, item_type.value, CommandResponse, signed, client_context, **kwargs
+        )
 
-    async def broadcast_audio(self, thread_id: str, is_direct: bool,
-                              client_context: Optional[str] = None, **kwargs
-                              ) -> Union[ShareVoiceResponse, CommandResponse]:
+    async def broadcast_audio(
+        self, thread_id: str, is_direct: bool, client_context: str | None = None, **kwargs
+    ) -> ShareVoiceResponse | CommandResponse:
         response_type = ShareVoiceResponse if is_direct else CommandResponse
-        return await self._broadcast(thread_id, "share_voice", response_type, False,
-                                     client_context, **kwargs)
+        return await self._broadcast(
+            thread_id, "share_voice", response_type, False, client_context, **kwargs
+        )

+ 50 - 28
mauigpapi/http/upload.py

@@ -16,12 +16,12 @@
 from __future__ import annotations
 
 from uuid import uuid4
+import json
 import random
 import time
-import json
 
+from ..types import FinishUploadResponse, MediaType, UploadPhotoResponse, UploadVideoResponse
 from .base import BaseAndroidAPI
-from ..types import UploadPhotoResponse, UploadVideoResponse, FinishUploadResponse, MediaType
 
 
 class UploadAPI(BaseAndroidAPI):
@@ -36,21 +36,21 @@ class UploadAPI(BaseAndroidAPI):
         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,
-            }),
+            "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
-            })
+            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)
@@ -64,8 +64,13 @@ class UploadAPI(BaseAndroidAPI):
             "Content-Type": "application/octet-stream",
             "priority": "u=6, i",
         }
-        return await self.std_http_post(f"/rupload_igphoto/{name}", headers=headers, data=data,
-                                        raw=True, response_type=UploadPhotoResponse)
+        return await self.std_http_post(
+            f"/rupload_igphoto/{name}",
+            headers=headers,
+            data=data,
+            raw=True,
+            response_type=UploadPhotoResponse,
+        )
 
     async def upload_mp4(
         self,
@@ -80,11 +85,13 @@ class UploadAPI(BaseAndroidAPI):
         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,
-            }),
+            "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([]),
@@ -114,18 +121,28 @@ class UploadAPI(BaseAndroidAPI):
         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
+        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,
-            }),
+            "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,
@@ -140,5 +157,10 @@ class UploadAPI(BaseAndroidAPI):
         query = {}
         if video:
             query["video"] = "1"
-        return await self.std_http_post("/api/v1/media/upload_finish/", headers=headers, data=req,
-                                        query=query, response_type=FinishUploadResponse)
+        return await self.std_http_post(
+            "/api/v1/media/upload_finish/",
+            headers=headers,
+            data=req,
+            query=query,
+            response_type=FinishUploadResponse,
+        )

+ 3 - 2
mauigpapi/http/user.py

@@ -24,5 +24,6 @@ class UserAPI(BaseAndroidAPI):
             "q": query,
             "count": count,
         }
-        return await self.std_http_get("/api/v1/users/search/", query=req,
-                                       response_type=UserSearchResponse)
+        return await self.std_http_get(
+            "/api/v1/users/search/", query=req, response_type=UserSearchResponse
+        )

+ 2 - 2
mauigpapi/mqtt/__init__.py

@@ -1,3 +1,3 @@
-from .subscription import SkywalkerSubscription, GraphQLSubscription
-from .events import Connect, Disconnect
 from .conn import AndroidMQTT
+from .events import Connect, Disconnect
+from .subscription import GraphQLSubscription, SkywalkerSubscription

+ 338 - 162
mauigpapi/mqtt/conn.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,49 +13,63 @@
 #
 # 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 (Union, Set, Optional, Any, Dict, Awaitable, Type, List, TypeVar, Callable,
-                    Iterable)
+from __future__ import annotations
+
+from typing import Any, Awaitable, Callable, Iterable, Type, TypeVar
 from collections import defaultdict
-from socket import socket, error as SocketError
-from uuid import uuid4
-import urllib.request
-import logging
+from socket import error as SocketError, socket
 import asyncio
-import random
-import zlib
-import time
 import json
+import logging
 import re
+import time
+import urllib.request
+import zlib
 
-import paho.mqtt.client
 from paho.mqtt.client import MQTTMessage, WebsocketConnectionError
 from yarl import URL
+import paho.mqtt.client
+
 from mautrix.util.logging import TraceLogger
 
-from ..errors import MQTTNotLoggedIn, MQTTNotConnected, IrisSubscribeError
+from ..errors import IrisSubscribeError, MQTTNotConnected, MQTTNotLoggedIn
 from ..state import AndroidState
-from ..types import (CommandResponse, ThreadItemType, ThreadAction, ReactionStatus, TypingStatus,
-                     IrisPayload, PubsubPayload, AppPresenceEventPayload, RealtimeDirectEvent,
-                     RealtimeZeroProvisionPayload, ClientConfigUpdatePayload, MessageSyncEvent,
-                     MessageSyncMessage, LiveVideoCommentPayload, PubsubEvent, IrisPayloadData,
-                     ThreadSyncEvent)
-from .thrift import RealtimeConfig, RealtimeClientInfo, ForegroundStateConfig, IncomingMessage
-from .otclient import MQTToTClient
-from .subscription import everclear_subscriptions, RealtimeTopic, GraphQLQueryID
+from ..types import (
+    AppPresenceEventPayload,
+    ClientConfigUpdatePayload,
+    CommandResponse,
+    IrisPayload,
+    IrisPayloadData,
+    LiveVideoCommentPayload,
+    MessageSyncEvent,
+    MessageSyncMessage,
+    PubsubEvent,
+    PubsubPayload,
+    ReactionStatus,
+    RealtimeDirectEvent,
+    RealtimeZeroProvisionPayload,
+    ThreadAction,
+    ThreadItemType,
+    ThreadSyncEvent,
+    TypingStatus,
+)
 from .events import Connect, Disconnect
+from .otclient import MQTToTClient
+from .subscription import GraphQLQueryID, RealtimeTopic, everclear_subscriptions
+from .thrift import ForegroundStateConfig, IncomingMessage, RealtimeClientInfo, RealtimeConfig
 
 try:
     import socks
 except ImportError:
     socks = None
 
-T = TypeVar('T')
+T = TypeVar("T")
 
 ACTIVITY_INDICATOR_REGEX = re.compile(
-    r"/direct_v2/threads/([\w_]+)/activity_indicator_id/([\w_]+)")
+    r"/direct_v2/threads/([\w_]+)/activity_indicator_id/([\w_]+)"
+)
 
-INBOX_THREAD_REGEX = re.compile(
-    r"/direct_v2/inbox/threads/([\w_]+)")
+INBOX_THREAD_REGEX = re.compile(r"/direct_v2/inbox/threads/([\w_]+)")
 
 
 class AndroidMQTT:
@@ -63,21 +77,25 @@ class AndroidMQTT:
     _client: MQTToTClient
     log: TraceLogger
     state: AndroidState
-    _graphql_subs: Set[str]
-    _skywalker_subs: Set[str]
-    _iris_seq_id: Optional[int]
-    _iris_snapshot_at_ms: Optional[int]
-    _publish_waiters: Dict[int, asyncio.Future]
-    _response_waiters: Dict[RealtimeTopic, asyncio.Future]
-    _response_waiter_locks: Dict[RealtimeTopic, asyncio.Lock]
-    _message_response_waiters: Dict[str, asyncio.Future]
-    _disconnect_error: Optional[Exception]
-    _event_handlers: Dict[Type[T], List[Callable[[T], Awaitable[None]]]]
+    _graphql_subs: set[str]
+    _skywalker_subs: set[str]
+    _iris_seq_id: int | None
+    _iris_snapshot_at_ms: int | None
+    _publish_waiters: dict[int, asyncio.Future]
+    _response_waiters: dict[RealtimeTopic, asyncio.Future]
+    _response_waiter_locks: dict[RealtimeTopic, asyncio.Lock]
+    _message_response_waiters: dict[str, asyncio.Future]
+    _disconnect_error: Exception | None
+    _event_handlers: dict[Type[T], list[Callable[[T], Awaitable[None]]]]
 
     # region Initialization
 
-    def __init__(self, state: AndroidState, loop: Optional[asyncio.AbstractEventLoop] = None,
-                 log: Optional[TraceLogger] = None) -> None:
+    def __init__(
+        self,
+        state: AndroidState,
+        loop: asyncio.AbstractEventLoop | None = None,
+        log: TraceLogger | None = None,
+    ) -> None:
         self._graphql_subs = set()
         self._skywalker_subs = set()
         self._iris_seq_id = None
@@ -110,9 +128,13 @@ class AndroidMQTT:
                 "socks5": socks.SOCKS5,
                 "socks4": socks.SOCKS4,
             }[proxy_url.scheme]
-            self._client.proxy_set(proxy_type=proxy_type, proxy_addr=proxy_url.host,
-                                   proxy_port=proxy_url.port, proxy_username=proxy_url.user,
-                                   proxy_password=proxy_url.password)
+            self._client.proxy_set(
+                proxy_type=proxy_type,
+                proxy_addr=proxy_url.host,
+                proxy_port=proxy_url.port,
+                proxy_username=proxy_url.user,
+                proxy_password=proxy_url.password,
+            )
         self._client.enable_logger()
         self._client.tls_set()
         # mqtt.max_inflight_messages_set(20)  # The rest will get queued
@@ -130,10 +152,16 @@ class AndroidMQTT:
         self._client.on_socket_unregister_write = self._on_socket_unregister_write
 
     def _form_client_id(self) -> bytes:
-        subscribe_topics = [RealtimeTopic.PUBSUB, RealtimeTopic.SUB_IRIS_RESPONSE,
-                            RealtimeTopic.REALTIME_SUB, RealtimeTopic.REGION_HINT,
-                            RealtimeTopic.SEND_MESSAGE_RESPONSE, RealtimeTopic.MESSAGE_SYNC,
-                            RealtimeTopic.UNKNOWN_179, RealtimeTopic.UNKNOWN_PP]
+        subscribe_topics = [
+            RealtimeTopic.PUBSUB,
+            RealtimeTopic.SUB_IRIS_RESPONSE,
+            RealtimeTopic.REALTIME_SUB,
+            RealtimeTopic.REGION_HINT,
+            RealtimeTopic.SEND_MESSAGE_RESPONSE,
+            RealtimeTopic.MESSAGE_SYNC,
+            RealtimeTopic.UNKNOWN_179,
+            RealtimeTopic.UNKNOWN_PP,
+        ]
         subscribe_topic_ids = [int(topic.encoded) for topic in subscribe_topics]
         password = f"sessionid={self.state.cookies['sessionid']}"
         cfg = RealtimeConfig(
@@ -150,7 +178,7 @@ class AndroidMQTT:
                 is_initially_foreground=True,
                 network_type=1,
                 network_subtype=0,
-                client_mqtt_session_id=int(time.time() * 1000) & 0xffffffff,
+                client_mqtt_session_id=int(time.time() * 1000) & 0xFFFFFFFF,
                 subscribe_topics=subscribe_topic_ids,
                 client_type="cookie_auth",
                 app_id=567067343352427,
@@ -187,8 +215,9 @@ class AndroidMQTT:
     def _on_socket_unregister_write(self, client: MQTToTClient, _: Any, sock: socket) -> None:
         self._loop.remove_writer(sock)
 
-    def _on_connect_handler(self, client: MQTToTClient, _: Any, flags: Dict[str, Any], rc: int
-                            ) -> None:
+    def _on_connect_handler(
+        self, client: MQTToTClient, _: Any, flags: dict[str, Any], rc: int
+    ) -> None:
         if rc != 0:
             err = paho.mqtt.client.connack_string(rc)
             self.log.error("MQTT Connection Error: %s (%d)", err, rc)
@@ -241,9 +270,7 @@ class AndroidMQTT:
         if (blank, direct_v2, threads) != ("", "direct_v2", "threads"):
             self.log.debug(f"Got unexpected first parts in direct thread path {path}")
             raise ValueError("unexpected first three parts in _parse_direct_thread_path")
-        additional = {
-            "thread_id": thread_id
-        }
+        additional = {"thread_id": thread_id}
         if rest:
             subitem_key = rest[0]
             if subitem_key == "approval_required_for_new_members":
@@ -312,16 +339,19 @@ class AndroidMQTT:
         for data in message.data:
             match = ACTIVITY_INDICATOR_REGEX.match(data.path)
             if match:
-                evt = PubsubEvent(data=data, base=message, thread_id=match.group(1),
-                                  activity_indicator_id=match.group(2))
+                evt = PubsubEvent(
+                    data=data,
+                    base=message,
+                    thread_id=match.group(1),
+                    activity_indicator_id=match.group(2),
+                )
                 self._loop.create_task(self._dispatch(evt))
             elif not data.double_publish:
                 self.log.debug("Pubsub: no activity indicator on data: %s", data)
             else:
                 self.log.debug("Pubsub: double publish: %s", data.path)
 
-    def _parse_realtime_sub_item(self, topic: Union[str, GraphQLQueryID], raw: dict
-                                 ) -> Iterable[Any]:
+    def _parse_realtime_sub_item(self, topic: str | GraphQLQueryID, raw: dict) -> Iterable[Any]:
         if topic == GraphQLQueryID.APP_PRESENCE:
             yield AppPresenceEventPayload.deserialize(raw).presence_event
         elif topic == GraphQLQueryID.ZERO_PROVISION:
@@ -333,11 +363,13 @@ class AndroidMQTT:
         elif topic == "direct":
             event = raw["event"]
             for item in raw["data"]:
-                yield RealtimeDirectEvent.deserialize({
-                    "event": event,
-                    **self._parse_direct_thread_path(item["path"]),
-                    **item,
-                })
+                yield RealtimeDirectEvent.deserialize(
+                    {
+                        "event": event,
+                        **self._parse_direct_thread_path(item["path"]),
+                        **item,
+                    }
+                )
 
     def _on_realtime_sub(self, payload: bytes) -> None:
         parsed_thrift = IncomingMessage.from_thrift(payload)
@@ -346,8 +378,13 @@ class AndroidMQTT:
         except ValueError:
             topic = parsed_thrift.topic
         self.log.trace(f"Got realtime sub event {topic} / {parsed_thrift.payload}")
-        allowed = ("direct", GraphQLQueryID.APP_PRESENCE, GraphQLQueryID.ZERO_PROVISION,
-                   GraphQLQueryID.CLIENT_CONFIG_UPDATE, GraphQLQueryID.LIVE_REALTIME_COMMENTS)
+        allowed = (
+            "direct",
+            GraphQLQueryID.APP_PRESENCE,
+            GraphQLQueryID.ZERO_PROVISION,
+            GraphQLQueryID.CLIENT_CONFIG_UPDATE,
+            GraphQLQueryID.LIVE_REALTIME_COMMENTS,
+        )
         if topic not in allowed:
             return
         parsed_json = json.loads(parsed_thrift.payload)
@@ -371,8 +408,9 @@ class AndroidMQTT:
                     ccid = data["payload"]["client_context"]
                     waiter = self._message_response_waiters.pop(ccid)
                 except KeyError as e:
-                    self.log.debug("No handler (%s) for send message response: %s",
-                                   e, message.payload)
+                    self.log.debug(
+                        "No handler (%s) for send message response: %s", e, message.payload
+                    )
                 else:
                     self.log.trace("Got response to %s: %s", ccid, message.payload)
                     waiter.set_result(message)
@@ -380,8 +418,9 @@ class AndroidMQTT:
                 try:
                     waiter = self._response_waiters.pop(topic)
                 except KeyError:
-                    self.log.debug("No handler for MQTT message in %s: %s",
-                                   topic.value, message.payload)
+                    self.log.debug(
+                        "No handler for MQTT message in %s: %s", topic.value, message.payload
+                    )
                 else:
                     self.log.trace("Got response %s: %s", topic.value, message.payload)
                     waiter.set_result(message)
@@ -399,8 +438,9 @@ class AndroidMQTT:
             # TODO custom class
             raise MQTTNotLoggedIn("MQTT reconnection failed") from e
 
-    def add_event_handler(self, evt_type: Type[T], handler: Callable[[T], Awaitable[None]]
-                          ) -> None:
+    def add_event_handler(
+        self, evt_type: Type[T], handler: Callable[[T], Awaitable[None]]
+    ) -> None:
         self._event_handlers[evt_type].append(handler)
 
     async def _dispatch(self, evt: T) -> None:
@@ -413,8 +453,14 @@ class AndroidMQTT:
     def disconnect(self) -> None:
         self._client.disconnect()
 
-    async def listen(self, graphql_subs: Set[str] = None, skywalker_subs: Set[str] = None,
-                     seq_id: int = None, snapshot_at_ms: int = None, retry_limit: int = 5) -> None:
+    async def listen(
+        self,
+        graphql_subs: set[str] | None = None,
+        skywalker_subs: set[str] | None = None,
+        seq_id: int = None,
+        snapshot_at_ms: int = None,
+        retry_limit: int = 5,
+    ) -> None:
         self._graphql_subs = graphql_subs or set()
         self._skywalker_subs = skywalker_subs or set()
         self._iris_seq_id = seq_id
@@ -453,8 +499,12 @@ class AndroidMQTT:
                     if connection_retries > retry_limit:
                         raise MQTTNotConnected(f"Connection failed {connection_retries} times")
                     sleep = connection_retries * 2
-                    await self._dispatch(Disconnect(reason="MQTT Error: no connection, retrying "
-                                                           f"in {connection_retries} seconds"))
+                    await self._dispatch(
+                        Disconnect(
+                            reason="MQTT Error: no connection, retrying "
+                            f"in {connection_retries} seconds"
+                        )
+                    )
                     await asyncio.sleep(sleep)
                 else:
                     err = paho.mqtt.client.error_string(rc)
@@ -473,8 +523,7 @@ class AndroidMQTT:
 
     # region Basic outgoing MQTT
 
-    def publish(self, topic: RealtimeTopic, payload: Union[str, bytes, dict]
-                ) -> asyncio.Future:
+    def publish(self, topic: RealtimeTopic, payload: str | bytes | dict) -> asyncio.Future:
         if isinstance(payload, dict):
             payload = json.dumps(payload)
         if isinstance(payload, str):
@@ -487,40 +536,48 @@ class AndroidMQTT:
         self._publish_waiters[info.mid] = fut
         return fut
 
-    async def request(self, topic: RealtimeTopic, response: RealtimeTopic,
-                      payload: Union[str, bytes, dict], timeout: Optional[int] = None
-                      ) -> MQTTMessage:
+    async def request(
+        self,
+        topic: RealtimeTopic,
+        response: RealtimeTopic,
+        payload: str | bytes | dict,
+        timeout: int | None = None,
+    ) -> MQTTMessage:
         async with self._response_waiter_locks[response]:
             fut = asyncio.Future()
             self._response_waiters[response] = fut
             await self.publish(topic, payload)
-            self.log.trace(f"Request published to {topic.value}, "
-                           f"waiting for response {response.name}")
+            self.log.trace(
+                f"Request published to {topic.value}, waiting for response {response.name}"
+            )
             return await asyncio.wait_for(fut, timeout)
 
     async def iris_subscribe(self, seq_id: int, snapshot_at_ms: int) -> None:
         self.log.debug(f"Requesting iris subscribe {seq_id}/{snapshot_at_ms}")
-        resp = await self.request(RealtimeTopic.SUB_IRIS, RealtimeTopic.SUB_IRIS_RESPONSE,
-                                  {"seq_id": seq_id, "snapshot_at_ms": snapshot_at_ms},
-                                  timeout=20 * 1000)
+        resp = await self.request(
+            RealtimeTopic.SUB_IRIS,
+            RealtimeTopic.SUB_IRIS_RESPONSE,
+            {"seq_id": seq_id, "snapshot_at_ms": snapshot_at_ms},
+            timeout=20 * 1000,
+        )
         self.log.debug("Iris subscribe response: %s", resp.payload.decode("utf-8"))
         resp_dict = json.loads(resp.payload.decode("utf-8"))
         if resp_dict["error_type"] and resp_dict["error_message"]:
             raise IrisSubscribeError(resp_dict["error_type"], resp_dict["error_message"])
 
-    def graphql_subscribe(self, subs: Set[str]) -> asyncio.Future:
+    def graphql_subscribe(self, subs: set[str]) -> asyncio.Future:
         self._graphql_subs |= subs
         return self.publish(RealtimeTopic.REALTIME_SUB, {"sub": list(subs)})
 
-    def graphql_unsubscribe(self, subs: Set[str]) -> asyncio.Future:
+    def graphql_unsubscribe(self, subs: set[str]) -> asyncio.Future:
         self._graphql_subs -= subs
         return self.publish(RealtimeTopic.REALTIME_SUB, {"unsub": list(subs)})
 
-    def skywalker_subscribe(self, subs: Set[str]) -> asyncio.Future:
+    def skywalker_subscribe(self, subs: set[str]) -> asyncio.Future:
         self._skywalker_subs |= subs
         return self.publish(RealtimeTopic.PUBSUB, {"sub": list(subs)})
 
-    def skywalker_unsubscribe(self, subs: Set[str]) -> asyncio.Future:
+    def skywalker_unsubscribe(self, subs: set[str]) -> asyncio.Future:
         self._skywalker_subs -= subs
         return self.publish(RealtimeTopic.PUBSUB, {"unsub": list(subs)})
 
@@ -529,14 +586,19 @@ class AndroidMQTT:
 
     async def send_foreground_state(self, state: ForegroundStateConfig) -> None:
         self.log.debug("Updating foreground state: %s", state)
-        await self.publish(RealtimeTopic.FOREGROUND_STATE,
-                           zlib.compress(state.to_thrift(), level=9))
+        await self.publish(
+            RealtimeTopic.FOREGROUND_STATE, zlib.compress(state.to_thrift(), level=9)
+        )
         if state.keep_alive_timeout:
             self._client._keepalive = state.keep_alive_timeout
 
-    async def send_command(self, thread_id: str, action: ThreadAction,
-                           client_context: Optional[str] = None, **kwargs: Any
-                           ) -> Optional[CommandResponse]:
+    async def send_command(
+        self,
+        thread_id: str,
+        action: ThreadAction,
+        client_context: str | None = None,
+        **kwargs: Any,
+    ) -> CommandResponse | None:
         client_context = client_context or self.state.gen_client_context()
         req = {
             "thread_id": thread_id,
@@ -555,81 +617,195 @@ class AndroidMQTT:
             fut = asyncio.Future()
             self._message_response_waiters[client_context] = fut
             await self.publish(RealtimeTopic.SEND_MESSAGE, req)
-            self.log.trace(f"Request published to {RealtimeTopic.SEND_MESSAGE}, "
-                           f"waiting for response {RealtimeTopic.SEND_MESSAGE_RESPONSE}")
+            self.log.trace(
+                f"Request published to {RealtimeTopic.SEND_MESSAGE}, "
+                f"waiting for response {RealtimeTopic.SEND_MESSAGE_RESPONSE}"
+            )
             resp = await fut
             return CommandResponse.parse_json(resp.payload.decode("utf-8"))
 
-    def send_item(self, thread_id: str, item_type: ThreadItemType, shh_mode: bool = False,
-                  client_context: Optional[str] = None, **kwargs: Any
-                  ) -> Awaitable[CommandResponse]:
-        return self.send_command(thread_id, item_type=item_type.value,
-                                 is_shh_mode=str(int(shh_mode)), action=ThreadAction.SEND_ITEM,
-                                 client_context=client_context, **kwargs)
-
-    def send_hashtag(self, thread_id: str, hashtag: str, text: str = "", shh_mode: bool = False,
-                     client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, item_id=hashtag, shh_mode=shh_mode,
-                              item_type=ThreadItemType.HASHTAG, client_context=client_context)
-
-    def send_like(self, thread_id: str, shh_mode: bool = False,
-                  client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, shh_mode=shh_mode, item_type=ThreadItemType.LIKE,
-                              client_context=client_context)
-
-    def send_location(self, thread_id: str, venue_id: str, text: str = "",
-                      shh_mode: bool = False, client_context: Optional[str] = None
-                      ) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, item_id=venue_id, shh_mode=shh_mode,
-                              item_type=ThreadItemType.LOCATION, client_context=client_context)
-
-    def send_media(self, thread_id: str, media_id: str, text: str = "", shh_mode: bool = False,
-                   client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, media_id=media_id, shh_mode=shh_mode,
-                              item_type=ThreadItemType.MEDIA_SHARE, client_context=client_context)
-
-    def send_profile(self, thread_id: str, user_id: str, text: str = "", shh_mode: bool = False,
-                     client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, item_id=user_id, shh_mode=shh_mode,
-                              item_type=ThreadItemType.PROFILE, client_context=client_context)
-
-    def send_reaction(self, thread_id: str, emoji: str, item_id: str,
-                      reaction_status: ReactionStatus = ReactionStatus.CREATED,
-                      target_item_type: ThreadItemType = ThreadItemType.TEXT,
-                      shh_mode: bool = False, client_context: Optional[str] = None
-                      ) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, reaction_status=reaction_status.value, node_type="item",
-                              reaction_type="like", target_item_type=target_item_type.value,
-                              emoji=emoji, item_id=item_id, reaction_action_source="double_tap",
-                              shh_mode=shh_mode, item_type=ThreadItemType.REACTION,
-                              client_context=client_context)
-
-    def send_user_story(self, thread_id: str, media_id: str, text: str = "",
-                        shh_mode: bool = False, client_context: Optional[str] = None
-                        ) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, item_id=media_id, shh_mode=shh_mode,
-                              item_type=ThreadItemType.REEL_SHARE, client_context=client_context)
-
-    def send_text(self, thread_id: str, text: str = "", shh_mode: bool = False,
-                  client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_item(thread_id, text=text, shh_mode=shh_mode,
-                              item_type=ThreadItemType.TEXT, client_context=client_context)
-
-    def mark_seen(self, thread_id: str, item_id: str, client_context: Optional[str] = None
-                  ) -> Awaitable[None]:
-        return self.send_command(thread_id, item_id=item_id, action=ThreadAction.MARK_SEEN,
-                                 client_context=client_context)
-
-    def mark_visual_item_seen(self, thread_id: str, item_id: str,
-                              client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_command(thread_id, item_id=item_id,
-                                 action=ThreadAction.MARK_VISUAL_ITEM_SEEN,
-                                 client_context=client_context)
-
-    def indicate_activity(self, thread_id: str, activity_status: TypingStatus = TypingStatus.TEXT,
-                          client_context: Optional[str] = None) -> Awaitable[CommandResponse]:
-        return self.send_command(thread_id, activity_status=activity_status.value,
-                                 action=ThreadAction.INDICATE_ACTIVITY,
-                                 client_context=client_context)
+    def send_item(
+        self,
+        thread_id: str,
+        item_type: ThreadItemType,
+        shh_mode: bool = False,
+        client_context: str | None = None,
+        **kwargs: Any,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_command(
+            thread_id,
+            item_type=item_type.value,
+            is_shh_mode=str(int(shh_mode)),
+            action=ThreadAction.SEND_ITEM,
+            client_context=client_context,
+            **kwargs,
+        )
+
+    def send_hashtag(
+        self,
+        thread_id: str,
+        hashtag: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            item_id=hashtag,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.HASHTAG,
+            client_context=client_context,
+        )
+
+    def send_like(
+        self, thread_id: str, shh_mode: bool = False, client_context: str | None = None
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.LIKE,
+            client_context=client_context,
+        )
+
+    def send_location(
+        self,
+        thread_id: str,
+        venue_id: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            item_id=venue_id,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.LOCATION,
+            client_context=client_context,
+        )
+
+    def send_media(
+        self,
+        thread_id: str,
+        media_id: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            media_id=media_id,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.MEDIA_SHARE,
+            client_context=client_context,
+        )
+
+    def send_profile(
+        self,
+        thread_id: str,
+        user_id: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            item_id=user_id,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.PROFILE,
+            client_context=client_context,
+        )
+
+    def send_reaction(
+        self,
+        thread_id: str,
+        emoji: str,
+        item_id: str,
+        reaction_status: ReactionStatus = ReactionStatus.CREATED,
+        target_item_type: ThreadItemType = ThreadItemType.TEXT,
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            reaction_status=reaction_status.value,
+            node_type="item",
+            reaction_type="like",
+            target_item_type=target_item_type.value,
+            emoji=emoji,
+            item_id=item_id,
+            reaction_action_source="double_tap",
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.REACTION,
+            client_context=client_context,
+        )
+
+    def send_user_story(
+        self,
+        thread_id: str,
+        media_id: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            item_id=media_id,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.REEL_SHARE,
+            client_context=client_context,
+        )
+
+    def send_text(
+        self,
+        thread_id: str,
+        text: str = "",
+        shh_mode: bool = False,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_item(
+            thread_id,
+            text=text,
+            shh_mode=shh_mode,
+            item_type=ThreadItemType.TEXT,
+            client_context=client_context,
+        )
+
+    def mark_seen(
+        self, thread_id: str, item_id: str, client_context: str | None = None
+    ) -> Awaitable[None]:
+        return self.send_command(
+            thread_id,
+            item_id=item_id,
+            action=ThreadAction.MARK_SEEN,
+            client_context=client_context,
+        )
+
+    def mark_visual_item_seen(
+        self, thread_id: str, item_id: str, client_context: str | None = None
+    ) -> Awaitable[CommandResponse]:
+        return self.send_command(
+            thread_id,
+            item_id=item_id,
+            action=ThreadAction.MARK_VISUAL_ITEM_SEEN,
+            client_context=client_context,
+        )
+
+    def indicate_activity(
+        self,
+        thread_id: str,
+        activity_status: TypingStatus = TypingStatus.TEXT,
+        client_context: str | None = None,
+    ) -> Awaitable[CommandResponse]:
+        return self.send_command(
+            thread_id,
+            activity_status=activity_status.value,
+            action=ThreadAction.INDICATE_ACTIVITY,
+            client_context=client_context,
+        )
 
     # endregion

+ 13 - 5
mauigpapi/mqtt/otclient.py

@@ -13,9 +13,10 @@
 #
 # 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/>.
-import paho.mqtt.client
 import struct
 
+import paho.mqtt.client
+
 
 class MQTToTClient(paho.mqtt.client.Client):
     # This is equivalent to the original _send_connect, except:
@@ -26,8 +27,7 @@ class MQTToTClient(paho.mqtt.client.Client):
         proto_ver = self._protocol
         protocol = b"MQTToT"
 
-        remaining_length = (2 + len(protocol) + 1 +
-                            1 + 2 + len(self._client_id))
+        remaining_length = 2 + len(protocol) + 1 + 1 + 2 + len(self._client_id)
 
         # Username, password, clean session
         connect_flags = 0x80 + 0x40 + 0x02
@@ -37,8 +37,16 @@ class MQTToTClient(paho.mqtt.client.Client):
         packet.append(command)
 
         self._pack_remaining_length(packet, remaining_length)
-        packet.extend(struct.pack(f"!H{len(protocol)}sBBH",
-                                  len(protocol), protocol, proto_ver, connect_flags, keepalive))
+        packet.extend(
+            struct.pack(
+                f"!H{len(protocol)}sBBH",
+                len(protocol),
+                protocol,
+                proto_ver,
+                connect_flags,
+                keepalive,
+            )
+        )
         packet.extend(self._client_id)
 
         self._keepalive = keepalive

+ 226 - 111
mauigpapi/mqtt/subscription.py

@@ -13,7 +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 Any, Optional, Union, Dict
+from __future__ import annotations
+
+from typing import Any
 from enum import Enum
 from uuid import uuid4
 import json
@@ -21,11 +23,11 @@ import json
 
 class SkywalkerSubscription:
     @staticmethod
-    def direct_sub(user_id: Union[str, int]) -> str:
+    def direct_sub(user_id: str | int) -> str:
         return f"ig/u/v1/{user_id}"
 
     @staticmethod
-    def live_sub(user_id: Union[str, int]) -> str:
+    def live_sub(user_id: str | int) -> str:
         return f"ig/live_notification_subscribe/{user_id}"
 
 
@@ -64,154 +66,267 @@ everclear_subscriptions = {
 
 class GraphQLSubscription:
     @staticmethod
-    def _fmt(query_id: GraphQLQueryID, input_params: Any,
-             client_logged: Optional[bool] = None) -> str:
+    def _fmt(
+        query_id: GraphQLQueryID, input_params: Any, client_logged: bool | None = None
+    ) -> str:
         params = {
             "input_data": input_params,
-            **({"%options": {"client_logged": client_logged}}
-               if client_logged is not None else {}),
+            **(
+                {"%options": {"client_logged": client_logged}} if client_logged is not None else {}
+            ),
         }
         return f"1/graphqlsubscriptions/{query_id.value}/{json.dumps(params)}"
 
     @classmethod
-    def app_presence(cls, subscription_id: Optional[str] = None,
-                     client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.APP_PRESENCE,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4())},
-                        client_logged=client_logged)
+    def app_presence(
+        cls, subscription_id: str | None = None, client_logged: bool | None = None
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.APP_PRESENCE,
+            input_params={"client_subscription_id": subscription_id or str(uuid4())},
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def async_ad(cls, user_id: str, subscription_id: Optional[str] = None,
-                 client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.ASYNC_AD_SUB,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "user_id": user_id},
-                        client_logged=client_logged)
+    def async_ad(
+        cls,
+        user_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.ASYNC_AD_SUB,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "user_id": user_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def client_config_update(cls, subscription_id: Optional[str] = None,
-                             client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.CLIENT_CONFIG_UPDATE,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4())},
-                        client_logged=client_logged)
+    def client_config_update(
+        cls, subscription_id: str | None = None, client_logged: bool | None = None
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.CLIENT_CONFIG_UPDATE,
+            input_params={"client_subscription_id": subscription_id or str(uuid4())},
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def direct_status(cls, subscription_id: Optional[str] = None,
-                      client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.DIRECT_STATUS,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4())},
-                        client_logged=client_logged)
+    def direct_status(
+        cls, subscription_id: str | None = None, client_logged: bool | None = None
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.DIRECT_STATUS,
+            input_params={"client_subscription_id": subscription_id or str(uuid4())},
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def direct_typing(cls, user_id: str, client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.DIRECT_TYPING,
-                        input_params={"user_id": user_id},
-                        client_logged=client_logged)
+    def direct_typing(cls, user_id: str, client_logged: bool | None = None) -> str:
+        return cls._fmt(
+            GraphQLQueryID.DIRECT_TYPING,
+            input_params={"user_id": user_id},
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def ig_live_wave(cls, broadcast_id: str, receiver_id: str,
-                     subscription_id: Optional[str] = None, client_logged: Optional[bool] = None
-                     ) -> str:
-        return cls._fmt(GraphQLQueryID.LIVE_WAVE,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id, "receiver_id": receiver_id},
-                        client_logged=client_logged)
+    def ig_live_wave(
+        cls,
+        broadcast_id: str,
+        receiver_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.LIVE_WAVE,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+                "receiver_id": receiver_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def interactivity_activate_question(cls, broadcast_id: str,
-                                        subscription_id: Optional[str] = None,
-                                        client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.INTERACTIVITY_ACTIVATE_QUESTION,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id},
-                        client_logged=client_logged)
+    def interactivity_activate_question(
+        cls,
+        broadcast_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.INTERACTIVITY_ACTIVATE_QUESTION,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
     def interactivity_realtime_question_submissions_status(
-        cls, broadcast_id: str, subscription_id: Optional[str] = None,
-        client_logged: Optional[bool] = None
+        cls,
+        broadcast_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
     ) -> str:
-        return cls._fmt(GraphQLQueryID.INTERACTIVITY_REALTIME_QUESTION_SUBMISSION_STATUS,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id},
-                        client_logged=client_logged)
+        return cls._fmt(
+            GraphQLQueryID.INTERACTIVITY_REALTIME_QUESTION_SUBMISSION_STATUS,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def interactivity(cls, broadcast_id: str, subscription_id: Optional[str] = None,
-                      client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.INTERACTIVITY_SUB,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id},
-                        client_logged=client_logged)
+    def interactivity(
+        cls,
+        broadcast_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.INTERACTIVITY_SUB,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def live_realtime_comments(cls, broadcast_id: str, subscription_id: Optional[str] = None,
-                               client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.LIVE_REALTIME_COMMENTS,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id},
-                        client_logged=client_logged)
+    def live_realtime_comments(
+        cls,
+        broadcast_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.LIVE_REALTIME_COMMENTS,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def live_realtime_typing_indicator(cls, broadcast_id: str,
-                                       subscription_id: Optional[str] = None,
-                                       client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.LIVE_TYPING_INDICATOR,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "broadcast_id": broadcast_id},
-                        client_logged=client_logged)
+    def live_realtime_typing_indicator(
+        cls,
+        broadcast_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.LIVE_TYPING_INDICATOR,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "broadcast_id": broadcast_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def media_feedback(cls, feedback_id: str, subscription_id: Optional[str] = None,
-                       client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.MEDIA_FEEDBACK,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "feedback_id": feedback_id},
-                        client_logged=client_logged)
+    def media_feedback(
+        cls,
+        feedback_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.MEDIA_FEEDBACK,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "feedback_id": feedback_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def react_native_ota_update(cls, build_number: str, subscription_id: Optional[str] = None,
-                                client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.REACT_NATIVE_OTA,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "build_number": build_number},
-                        client_logged=client_logged)
+    def react_native_ota_update(
+        cls,
+        build_number: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.REACT_NATIVE_OTA,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "build_number": build_number,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def video_call_co_watch_control(cls, video_call_id: str, subscription_id: Optional[str] = None,
-                                    client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.VIDEO_CALL_CO_WATCH_CONTROL,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "video_call_id": video_call_id},
-                        client_logged=client_logged)
+    def video_call_co_watch_control(
+        cls,
+        video_call_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.VIDEO_CALL_CO_WATCH_CONTROL,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "video_call_id": video_call_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def video_call_in_call_alert(cls, video_call_id: str, subscription_id: Optional[str] = None,
-                                 client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.VIDEO_CALL_IN_ALERT,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "video_call_id": video_call_id},
-                        client_logged=client_logged)
+    def video_call_in_call_alert(
+        cls,
+        video_call_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.VIDEO_CALL_IN_ALERT,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "video_call_id": video_call_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def video_call_prototype_publish(cls, video_call_id: str,
-                                     subscription_id: Optional[str] = None,
-                                     client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.VIDEO_CALL_PROTOTYPE_PUBLISH,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "video_call_id": video_call_id},
-                        client_logged=client_logged)
+    def video_call_prototype_publish(
+        cls,
+        video_call_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.VIDEO_CALL_PROTOTYPE_PUBLISH,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "video_call_id": video_call_id,
+            },
+            client_logged=client_logged,
+        )
 
     @classmethod
-    def zero_provision(cls, device_id: str, subscription_id: Optional[str] = None,
-                       client_logged: Optional[bool] = None) -> str:
-        return cls._fmt(GraphQLQueryID.ZERO_PROVISION,
-                        input_params={"client_subscription_id": subscription_id or str(uuid4()),
-                                      "device_id": device_id},
-                        client_logged=client_logged)
+    def zero_provision(
+        cls,
+        device_id: str,
+        subscription_id: str | None = None,
+        client_logged: bool | None = None,
+    ) -> str:
+        return cls._fmt(
+            GraphQLQueryID.ZERO_PROVISION,
+            input_params={
+                "client_subscription_id": subscription_id or str(uuid4()),
+                "device_id": device_id,
+            },
+            client_logged=client_logged,
+        )
 
 
-_topic_map: Dict[str, str] = {
+_topic_map: dict[str, str] = {
     "/pp": "34",  # unknown
     "/ig_sub_iris": "134",
     "/ig_sub_iris_response": "135",
@@ -227,7 +342,7 @@ _topic_map: Dict[str, str] = {
     "179": "179",  # also unknown
 }
 
-_reverse_topic_map: Dict[str, str] = {value: key for key, value in _topic_map.items()}
+_reverse_topic_map: dict[str, str] = {value: key for key, value in _topic_map.items()}
 
 
 class RealtimeTopic(Enum):
@@ -250,5 +365,5 @@ class RealtimeTopic(Enum):
         return _topic_map[self.value]
 
     @staticmethod
-    def decode(val: str) -> 'RealtimeTopic':
+    def decode(val: str) -> RealtimeTopic:
         return RealtimeTopic(_reverse_topic_map[val])

+ 3 - 3
mauigpapi/mqtt/thrift/__init__.py

@@ -1,5 +1,5 @@
+from .autospec import autospec, field
+from .ig_objects import ForegroundStateConfig, IncomingMessage, RealtimeClientInfo, RealtimeConfig
 from .read import ThriftReader
-from .write import ThriftWriter
 from .type import TType
-from .autospec import autospec, field
-from .ig_objects import RealtimeConfig, RealtimeClientInfo, ForegroundStateConfig, IncomingMessage
+from .write import ThriftWriter

+ 12 - 17
mauigpapi/mqtt/thrift/autospec.py

@@ -14,29 +14,22 @@
 # 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 Tuple, Union
-import sys
 
 import attr
 
 from .type import TType
 
-TYPE_META = "net.maunium.instagram.thrift.type"
+TYPE_META = "fi.mau.instagram.thrift.type"
 
-if sys.version_info >= (3, 7):
-    def _get_type_class(typ):
-        try:
-            return typ.__origin__
-        except AttributeError:
-            return None
-else:
-    def _get_type_class(typ):
-        try:
-            return typ.__extra__
-        except AttributeError:
-            return None
 
+def _get_type_class(typ):
+    try:
+        return typ.__origin__
+    except AttributeError:
+        return None
 
-Subtype = Union[None, TType, Tuple['Subtype', 'Subtype']]
+
+Subtype = Union[None, TType, Tuple["Subtype", "Subtype"]]
 
 
 def _guess_type(python_type, name: str) -> Tuple[TType, Subtype]:
@@ -56,8 +49,10 @@ def _guess_type(python_type, name: str) -> Tuple[TType, Subtype]:
     if type_class == list:
         return TType.LIST, _guess_type(args[0], f"{name} item")
     elif type_class == dict:
-        return TType.MAP, (_guess_type(args[0], f"{name} key"),
-                           _guess_type(args[1], f"{name} value"))
+        return TType.MAP, (
+            _guess_type(args[0], f"{name} key"),
+            _guess_type(args[1], f"{name} value"),
+        )
     elif type_class == set:
         return TType.SET, _guess_type(args[0], f"{name} item")
 

+ 3 - 3
mauigpapi/mqtt/thrift/ig_objects.py

@@ -17,10 +17,10 @@ from typing import Dict, List, Union
 
 from attr import dataclass
 
+from .autospec import autospec, field
+from .read import ThriftReader
 from .type import TType
-from .autospec import field, autospec
 from .write import ThriftWriter
-from .read import ThriftReader
 
 
 @autospec
@@ -96,7 +96,7 @@ class IncomingMessage:
     payload: str
 
     @classmethod
-    def from_thrift(cls, data: bytes) -> 'IncomingMessage':
+    def from_thrift(cls, data: bytes) -> "IncomingMessage":
         buf = ThriftReader(data)
         topic_type = buf.read_field()
         if topic_type == TType.BINARY:

+ 6 - 5
mauigpapi/mqtt/thrift/read.py

@@ -13,7 +13,8 @@
 #
 # 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
+from __future__ import annotations
+
 import io
 
 from .type import TType
@@ -21,7 +22,7 @@ from .type import TType
 
 class ThriftReader(io.BytesIO):
     prev_field_id: int
-    stack: List[int]
+    stack: list[int]
 
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
@@ -51,7 +52,7 @@ class ThriftReader(io.BytesIO):
         result = 0
         while True:
             byte = self._read_byte()
-            result |= (byte & 0x7f) << shift
+            result |= (byte & 0x7F) << shift
             if (byte & 0x80) == 0:
                 break
             shift += 7
@@ -61,9 +62,9 @@ class ThriftReader(io.BytesIO):
         byte = self._read_byte()
         if byte == 0:
             return TType.STOP
-        delta = (byte & 0xf0) >> 4
+        delta = (byte & 0xF0) >> 4
         if delta == 0:
             self.prev_field_id = self._from_zigzag(self.read_varint())
         else:
             self.prev_field_id += delta
-        return TType(byte & 0x0f)
+        return TType(byte & 0x0F)

+ 1 - 1
mauigpapi/mqtt/thrift/type.py

@@ -33,4 +33,4 @@ class TType(IntEnum):
     STRUCT = 12
 
     # internal
-    BOOL = 0xa1
+    BOOL = 0xA1

+ 24 - 15
mauigpapi/mqtt/thrift/write.py

@@ -13,7 +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 Any, Union, List, Dict, Optional
+from __future__ import annotations
+
+from typing import Any
 import io
 
 from .type import TType
@@ -21,7 +23,7 @@ from .type import TType
 
 class ThriftWriter(io.BytesIO):
     prev_field_id: int
-    stack: List[int]
+    stack: list[int]
 
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
@@ -36,7 +38,7 @@ class ThriftWriter(io.BytesIO):
         if self.stack:
             self.prev_field_id = self.stack.pop()
 
-    def _write_byte(self, byte: Union[int, TType]) -> None:
+    def _write_byte(self, byte: int | TType) -> None:
         self.write(bytes([byte]))
 
     @staticmethod
@@ -45,7 +47,7 @@ class ThriftWriter(io.BytesIO):
 
     def _write_varint(self, val: int) -> None:
         while True:
-            byte = val & ~0x7f
+            byte = val & ~0x7F
             if byte == 0:
                 self._write_byte(val)
                 break
@@ -53,7 +55,7 @@ class ThriftWriter(io.BytesIO):
                 self._write_byte(0)
                 break
             else:
-                self._write_byte((val & 0xff) | 0x80)
+                self._write_byte((val & 0xFF) | 0x80)
                 val = val >> 7
 
     def _write_word(self, val: int) -> None:
@@ -75,19 +77,20 @@ class ThriftWriter(io.BytesIO):
             self._write_word(field_id)
         self.prev_field_id = field_id
 
-    def write_map(self, field_id: int, key_type: TType, value_type: TType, val: Dict[Any, Any]
-                  ) -> None:
+    def write_map(
+        self, field_id: int, key_type: TType, value_type: TType, val: dict[Any, Any]
+    ) -> None:
         self.write_field_begin(field_id, TType.MAP)
         if not map:
             self._write_byte(0)
             return
         self._write_varint(len(val))
-        self._write_byte(((key_type.value & 0xf) << 4) | (value_type.value & 0xf))
+        self._write_byte(((key_type.value & 0xF) << 4) | (value_type.value & 0xF))
         for key, value in val.items():
             self.write_val(None, key_type, key)
             self.write_val(None, value_type, value)
 
-    def write_string_direct(self, val: Union[str, bytes]) -> None:
+    def write_string_direct(self, val: str | bytes) -> None:
         if isinstance(val, str):
             val = val.encode("utf-8")
         self._write_varint(len(val))
@@ -113,12 +116,12 @@ class ThriftWriter(io.BytesIO):
         self.write_field_begin(field_id, TType.I64)
         self._write_long(val)
 
-    def write_list(self, field_id: int, item_type: TType, val: List[Any]) -> None:
+    def write_list(self, field_id: int, item_type: TType, val: list[Any]) -> None:
         self.write_field_begin(field_id, TType.LIST)
-        if len(val) < 0x0f:
+        if len(val) < 0x0F:
             self._write_byte((len(val) << 4) | item_type.value)
         else:
-            self._write_byte(0xf0 | item_type.value)
+            self._write_byte(0xF0 | item_type.value)
             self._write_varint(len(val))
         for item in val:
             self.write_val(None, item_type, item)
@@ -127,7 +130,7 @@ class ThriftWriter(io.BytesIO):
         self.write_field_begin(field_id, TType.STRUCT)
         self._push_stack()
 
-    def write_val(self, field_id: Optional[int], ttype: TType, val: Any) -> None:
+    def write_val(self, field_id: int | None, ttype: TType, val: Any) -> None:
         if ttype == TType.BOOL:
             if field_id is None:
                 raise ValueError("booleans can only be in structs")
@@ -157,8 +160,14 @@ class ThriftWriter(io.BytesIO):
                 continue
 
             start = len(self.getvalue())
-            if field_type in (TType.BOOL, TType.BYTE, TType.I16, TType.I32, TType.I64,
-                              TType.BINARY):
+            if field_type in (
+                TType.BOOL,
+                TType.BYTE,
+                TType.I16,
+                TType.I32,
+                TType.I64,
+                TType.BINARY,
+            ):
                 self.write_val(field_id, field_type, val)
             elif field_type in (TType.LIST, TType.SET):
                 self.write_list(field_id, inner_type, val)

+ 350 - 219
mauigpapi/state/application.py

@@ -13,15 +13,16 @@
 #
 # 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/>.
-import pkgutil
 import json
+import pkgutil
 
 from attr import dataclass
 
 from mautrix.types import SerializableAttrs
 
-default_capabilities = json.loads(pkgutil.get_data("mauigpapi.state",
-                                                   "samples/supported-capabilities.json"))
+default_capabilities = json.loads(
+    pkgutil.get_data("mauigpapi.state", "samples/supported-capabilities.json")
+)
 
 
 @dataclass
@@ -47,219 +48,349 @@ class AndroidApplication(SerializableAttrs):
     CAPABILITIES: str = "3brTvx8="
     SUPPORTED_CAPABILITIES: str = default_capabilities
 
-    EXPERIMENTS: str = ",".join((
-        "ig_android_ad_stories_scroll_perf_universe", "ig_android_ads_bottom_sheet_report_flow",
-        "ig_android_ads_data_preferences_universe", "ig_android_ads_rendering_logging",
-        "ig_android_canvas_cookie_universe", "ig_android_feed_ads_ppr_universe",
-        "ig_android_graphql_survey_new_proxy_universe", "ig_android_viewpoint_occlusion",
-        "ig_android_viewpoint_stories_public_testing",
-        "ig_promote_interactive_poll_sticker_igid_universe", "ig_stories_ads_delivery_rules",
-        "ig_stories_ads_media_based_insertion", "ig_android_li_session_chaining",
-        "ig_android_logging_metric_universe_v2", "mi_viewpoint_viewability_universe",
-        "ig_android_branded_content_appeal_states",
-        "ig_android_branded_content_tag_redesign_organic",
-        "ig_branded_content_settings_unsaved_changes_dialog",
-        "ig_branded_content_tagging_approval_request_flow_brand_side_v2",
-        "ig_rn_branded_content_settings_approval_on_select_save",
-        "aymt_instagram_promote_flow_abandonment_ig_universe",
-        "ig_android_account_insights_shopping_content_universe",
-        "ig_android_business_attribute_sync", "ig_android_business_promote_tooltip",
-        "ig_android_business_transaction_in_stories_consumer",
-        "ig_android_business_transaction_in_stories_creator",
-        "ig_android_create_page_on_top_universe", "ig_android_fb_sync_options_universe",
-        "ig_android_fb_url_universe", "ig_android_fbpage_on_profile_side_tray",
-        "ig_android_insights_post_dismiss_button", "ig_android_location_integrity_universe",
-        "ig_android_personal_user_xposting_destination_fix", "ig_android_place_signature_universe",
-        "ig_android_product_breakdown_post_insights", "ig_android_secondary_inbox_universe",
-        "ig_android_share_publish_page_universe",
-        "ig_android_skip_button_content_on_connect_fb_universe",
-        "ig_business_new_value_prop_universe", "ig_android_business_cross_post_with_biz_id_infra",
-        "ig_android_claim_location_page", "ig_android_edit_location_page_info",
-        "ig_android_page_claim_deeplink_qe", "ig_biz_growth_insights_universe",
-        "android_cameracore_fbaudio_integration_ig_universe",
-        "android_ig_cameracore_aspect_ratio_fix", "ig_android_arengine_remote_scripting_universe",
-        "ig_android_camera_formats_ranking_universe", "ig_android_camera_gyro_universe",
-        "ig_android_camera_reduce_file_exif_reads",
-        "ig_android_enable_automated_instruction_text_ar",
-        "ig_android_image_exif_metadata_ar_effect_id_universe", "ig_android_optic_face_detection",
-        "ig_android_optic_new_architecture", "ig_android_optic_photo_cropping_fixes",
-        "ig_android_recognition_tracking_thread_prority_universe", "ig_android_ttcp_improvements",
-        "ig_camera_android_bg_processor",
-        "ig_camera_android_effect_metadata_cache_refresh_universe",
-        "ig_camera_android_facetracker_v12_universe",
-        "ig_camera_android_feed_effect_attribution_universe",
-        "ig_camera_android_gallery_search_universe",
-        "ig_camera_android_gyro_senser_sampling_period_universe",
-        "ig_camera_android_paris_filter_universe", "ig_camera_android_share_effect_link_universe",
-        "ig_camera_android_subtle_filter_universe", "ig_cameracore_android_new_optic_camera2",
-        "ig_cameracore_android_new_optic_camera2_galaxy",
-        "ig_android_external_gallery_import_affordance",
-        "ig_android_feed_auto_share_to_facebook_dialog", "ig_android_fs_creation_flow_tweaks",
-        "ig_android_fs_new_gallery", "ig_android_fs_new_gallery_hashtag_prompts",
-        "ig_android_music_story_fb_crosspost_universe", "ig_android_partial_share_sheet",
-        "ig_android_photo_creation_large_width", "ig_android_render_thread_memory_leak_holdout",
-        "ig_android_direct_add_member_dialog_universe",
-        "ig_android_direct_aggregated_media_and_reshares",
-        "ig_android_direct_block_from_group_message_requests",
-        "ig_android_direct_inbox_cache_universe",
-        "ig_android_direct_leave_from_group_message_requests",
-        "ig_android_direct_mark_as_read_notif_action", "ig_android_direct_message_follow_button",
-        "ig_android_direct_multi_upload_universe", "ig_android_direct_mutation_manager_media_3",
-        "ig_android_direct_new_gallery", "ig_android_direct_segmented_video",
-        "ig_android_direct_state_observer", "ig_android_direct_thread_target_queue_universe",
-        "ig_android_direct_view_more_qe",
-        "ig_android_direct_wellbeing_message_reachability_settings",
-        "ig_android_disable_manual_retries", "ig_android_gallery_grid_controller_folder_cache",
-        "ig_android_multi_thread_sends", "ig_android_on_notification_cleared_async_universe",
-        "ig_android_push_reliability_universe",
-        "ig_android_wait_for_app_initialization_on_push_action_universe",
-        "ig_direct_android_bubble_system", "ig_direct_max_participants",
-        "ig_android_explore_recyclerview_universe", "ig_android_explore_reel_loading_state",
-        "ig_android_not_interested_secondary_options",
-        "ig_android_save_to_collections_bottom_sheet_refactor",
-        "ig_android_save_to_collections_flow", "ig_android_smplt_universe",
-        "ig_explore_2018_post_chaining_account_recs_dedupe_universe",
-        "ig_explore_2019_h1_destination_cover", "ig_explore_2019_h1_video_autoplay_resume",
-        "ig_explore_reel_ring_universe", "ig_android_feed_cache_update",
-        "ig_android_view_info_universe", "ig_carousel_bumped_organic_impression_client_universe",
-        "ig_end_of_feed_universe", "ig_android_igtv_autoplay_on_prepare",
-        "ig_android_igtv_browse_long_press", "ig_android_igtv_explore2x2_viewer",
-        "ig_android_igtv_pip", "ig_android_igtv_player_follow_button",
-        "ig_android_igtv_refresh_tv_guide_interval", "ig_android_igtv_stories_preview",
-        "ig_android_igtv_whitelisted_for_web", "ig_android_biz_story_to_fb_page_improvement",
-        "ig_android_contact_point_upload_rate_limit_killswitch",
-        "ig_android_country_code_fix_universe", "ig_android_dual_destination_quality_improvement",
-        "ig_android_explore_discover_people_entry_point_universe",
-        "ig_android_fb_follow_server_linkage_universe", "ig_android_fb_link_ui_polish_universe",
-        "ig_android_fb_profile_integration_universe", "ig_android_fbc_upsell_on_dp_first_load",
-        "ig_android_ig_personal_account_to_fb_page_linkage_backfill",
-        "ig_android_inline_editing_local_prefill", "ig_android_interest_follows_universe",
-        "ig_android_invite_list_button_redesign_universe",
-        "ig_android_login_onetap_upsell_universe", "ig_android_new_follower_removal_universe",
-        "ig_android_persistent_nux", "ig_android_qp_kill_switch",
-        "ig_android_recommend_accounts_destination_routing_fix",
-        "ig_android_self_following_v2_universe", "ig_android_self_story_button_non_fbc_accounts",
-        "ig_android_self_story_setting_option_in_menu",
-        "ig_android_separate_empty_feed_su_universe",
-        "ig_android_show_create_content_pages_universe",
-        "ig_android_show_self_followers_after_becoming_private_universe",
-        "ig_android_suggested_users_background",
-        "ig_android_test_not_signing_address_book_unlink_endpoint",
-        "ig_android_test_remove_button_main_cta_self_followers_universe",
-        "ig_android_unfollow_from_main_feed_v2", "ig_android_unfollow_reciprocal_universe",
-        "ig_android_unify_graph_management_actions",
-        "ig_android_whats_app_contact_invite_universe",
-        "ig_android_xposting_feed_to_stories_reshares_universe",
-        "ig_android_xposting_newly_fbc_people", "ig_android_xposting_reel_memory_share_universe",
-        "ig_android_zero_rating_carrier_signal", "ig_fb_graph_differentiation",
-        "ig_graph_evolution_holdout_universe", "ig_graph_management_h2_2019_universe",
-        "ig_graph_management_production_h2_2019_holdout_universe", "ig_inventory_connections",
-        "ig_pacing_overriding_universe", "ig_sim_api_analytics_reporting",
-        "ig_xposting_biz_feed_to_story_reshare", "ig_xposting_mention_reshare_stories",
-        "instagram_ns_qp_prefetch_universe", "ig_android_analytics_background_uploader_schedule",
-        "ig_android_appstate_logger", "ig_android_apr_lazy_build_request_infra",
-        "ig_android_camera_leak", "ig_android_crash_fix_detach_from_gl_context",
-        "ig_android_dead_code_detection", "ig_android_disk_usage_logging_universe",
-        "ig_android_dropframe_manager", "ig_android_image_upload_quality_universe",
-        "ig_android_mainfeed_generate_prefetch_background", "ig_android_media_remodel",
-        "ig_android_memory_use_logging_universe", "ig_android_network_perf_qpl_ppr",
-        "ig_android_qpl_class_marker", "ig_android_react_native_email_sms_settings_universe",
-        "ig_android_sharedpreferences_qpl_logging", "ig_disable_fsync_universe",
-        "ig_mprotect_code_universe", "ig_prefetch_scheduler_backtest",
-        "ig_traffic_routing_universe", "ig_android_qr_code_nametag", "ig_android_qr_code_scanner",
-        "ig_android_live_egl10_compat", "ig_android_live_realtime_comments_universe",
-        "ig_android_live_subscribe_user_level_universe", "ig_android_tango_cpu_overuse_universe",
-        "igqe_pending_tagged_posts", "ig_android_enable_zero_rating",
-        "ig_android_sso_kototoro_app_universe", "ig_android_sso_use_trustedapp_universe",
-        "ig_android_whitehat_options_universe",
-        "ig_android_payments_growth_promote_payments_in_payments",
-        "ig_android_purx_native_checkout_universe",
-        "ig_android_shopping_pdp_post_purchase_sharing", "ig_payment_checkout_cvv",
-        "ig_payment_checkout_info", "ig_payments_billing_address",
-        "ig_android_profile_thumbnail_impression", "ig_android_user_url_deeplink_fbpage_endpoint",
-        "instagram_android_profile_follow_cta_context_feed",
-        "ig_android_mqtt_cookie_auth_memcache_universe", "ig_android_realtime_mqtt_logging",
-        "ig_rti_inapp_notifications_universe", "ig_android_live_webrtc_livewith_params",
-        "saved_collections_cache_universe", "ig_android_search_nearby_places_universe",
-        "ig_search_hashtag_content_advisory_remove_snooze",
-        "ig_android_shopping_bag_null_state_v1", "ig_android_shopping_bag_optimization_universe",
-        "ig_android_shopping_checkout_signaling",
-        "ig_android_shopping_product_metadata_on_product_tiles_universe",
-        "ig_android_wishlist_reconsideration_universe", "ig_biz_post_approval_nux_universe",
-        "ig_commerce_platform_ptx_bloks_universe", "ig_shopping_bag_universe",
-        "ig_shopping_checkout_improvements_universe",
-        "ig_shopping_checkout_improvements_v2_universe", "ig_shopping_checkout_mvp_experiment",
-        "ig_shopping_insights_wc_copy_update_android", "ig_shopping_size_selector_redesign",
-        "instagram_shopping_hero_carousel_visual_variant_consolidation",
-        "ig_android_audience_control", "ig_android_camera_upsell_dialog",
-        "ig_android_create_mode_memories_see_all", "ig_android_create_mode_tap_to_cycle",
-        "ig_android_create_mode_templates", "ig_android_feed_post_sticker",
-        "ig_android_frx_creation_question_responses_reporting",
-        "ig_android_frx_highlight_cover_reporting_qe", "ig_android_music_browser_redesign",
-        "ig_android_publisher_stories_migration", "ig_android_rainbow_hashtags",
-        "ig_android_recipient_picker", "ig_android_reel_tray_item_impression_logging_viewpoint",
-        "ig_android_save_all", "ig_android_stories_blacklist",
-        "ig_android_stories_boomerang_v2_universe", "ig_android_stories_context_sheets_universe",
-        "ig_android_stories_gallery_sticker_universe",
-        "ig_android_stories_gallery_video_segmentation", "ig_android_stories_layout_universe",
-        "ig_android_stories_music_awareness_universe", "ig_android_stories_music_lyrics",
-        "ig_android_stories_music_overlay", "ig_android_stories_music_search_typeahead",
-        "ig_android_stories_project_eclipse", "ig_android_stories_question_sticker_music_format",
-        "ig_android_stories_quick_react_gif_universe",
-        "ig_android_stories_share_extension_video_segmentation",
-        "ig_android_stories_sundial_creation_universe", "ig_android_stories_video_prefetch_kb",
-        "ig_android_stories_vpvd_container_module_fix", "ig_android_stories_weblink_creation",
-        "ig_android_story_bottom_sheet_clips_single_audio_mas",
-        "ig_android_story_bottom_sheet_music_mas", "ig_android_story_bottom_sheet_top_clips_mas",
-        "ig_android_xposting_dual_destination_shortcut_fix",
-        "ig_stories_allow_camera_actions_while_recording", "ig_stories_rainbow_ring",
-        "ig_threads_clear_notifications_on_has_seen", "ig_threads_sanity_check_thread_viewkeys",
-        "ig_android_action_sheet_migration_universe", "ig_android_emoji_util_universe_3",
-        "ig_android_recyclerview_binder_group_enabled_universe",
-        "ig_emoji_render_counter_logging_universe", "ig_android_delete_ssim_compare_img_soon",
-        "ig_android_reel_raven_video_segmented_upload_universe",
-        "ig_android_render_output_surface_timeout_universe",
-        "ig_android_sidecar_segmented_streaming_universe",
-        "ig_android_stories_video_seeking_audio_bug_fix", "ig_android_video_abr_universe",
-        "ig_android_video_call_finish_universe", "ig_android_video_exoplayer_2",
-        "ig_android_video_fit_scale_type_igtv", "ig_android_video_product_specific_abr",
-        "ig_android_video_qp_logger_universe", "ig_android_video_raven_bitrate_ladder_universe",
-        "ig_android_video_raven_passthrough", "ig_android_video_raven_streaming_upload_universe",
-        "ig_android_video_streaming_upload_universe",
-        "ig_android_video_upload_hevc_encoding_universe", "ig_android_video_upload_quality_qe1",
-        "ig_android_video_upload_transform_matrix_fix_universe",
-        "ig_android_video_visual_quality_score_based_abr",
-        "ig_video_experimental_encoding_consumption_universe",
-        "ig_android_vc_background_call_toast_universe", "ig_android_vc_capture_universe",
-        "ig_android_vc_codec_settings", "ig_android_vc_cowatch_config_universe",
-        "ig_android_vc_cowatch_media_share_universe", "ig_android_vc_cowatch_universe",
-        "ig_android_vc_cpu_overuse_universe", "ig_android_vc_explicit_intent_for_notification",
-        "ig_android_vc_face_effects_universe", "ig_android_vc_join_timeout_universe",
-        "ig_android_vc_migrate_to_bluetooth_v2_universe",
-        "ig_android_vc_missed_call_call_back_action_universe",
-        "ig_android_vc_shareable_moments_universe",
-        "ig_android_comment_warning_non_english_universe", "ig_android_feed_post_warning_universe",
-        "ig_android_image_pdq_calculation", "ig_android_logged_in_delta_migration",
-        "ig_android_wellbeing_support_frx_cowatch_reporting",
-        "ig_android_wellbeing_support_frx_hashtags_reporting",
-        "ig_android_wellbeing_timeinapp_v1_universe", "ig_challenge_general_v2",
-        "ig_ei_option_setting_universe"
-    ))
-    LOGIN_EXPERIMENTS: str = "".join((
-        "ig_android_device_detection_info_upload", "ig_android_device_info_foreground_reporting",
-        "ig_android_suma_landing_page", "ig_android_device_based_country_verification",
-        "ig_android_direct_add_direct_to_android_native_photo_share_sheet",
-        "ig_android_direct_main_tab_universe_v2",
-        "ig_account_identity_logged_out_signals_global_holdout_universe",
-        "ig_android_fb_account_linking_sampling_freq_universe",
-        "ig_android_login_identifier_fuzzy_match", "ig_android_nux_add_email_device",
-        "ig_android_passwordless_account_password_creation_universe",
-        "ig_android_reg_modularization_universe", "ig_android_retry_create_account_universe",
-        "ig_growth_android_profile_pic_prefill_with_fb_pic_2",
-        "ig_android_quickcapture_keep_screen_on", "ig_android_gmail_oauth_in_reg",
-        "ig_android_reg_nux_headers_cleanup_universe", "ig_android_smartlock_hints_universe",
-        "ig_android_security_intent_switchoff", "ig_android_sim_info_upload",
-        "ig_android_caption_typeahead_fix_on_o_universe",
-        "ig_android_video_render_codec_low_memory_gc", "ig_android_device_verification_fb_signup",
-        "ig_android_device_verification_separate_endpoint"
-    ))
+    EXPERIMENTS: str = ",".join(
+        (
+            "ig_android_ad_stories_scroll_perf_universe",
+            "ig_android_ads_bottom_sheet_report_flow",
+            "ig_android_ads_data_preferences_universe",
+            "ig_android_ads_rendering_logging",
+            "ig_android_canvas_cookie_universe",
+            "ig_android_feed_ads_ppr_universe",
+            "ig_android_graphql_survey_new_proxy_universe",
+            "ig_android_viewpoint_occlusion",
+            "ig_android_viewpoint_stories_public_testing",
+            "ig_promote_interactive_poll_sticker_igid_universe",
+            "ig_stories_ads_delivery_rules",
+            "ig_stories_ads_media_based_insertion",
+            "ig_android_li_session_chaining",
+            "ig_android_logging_metric_universe_v2",
+            "mi_viewpoint_viewability_universe",
+            "ig_android_branded_content_appeal_states",
+            "ig_android_branded_content_tag_redesign_organic",
+            "ig_branded_content_settings_unsaved_changes_dialog",
+            "ig_branded_content_tagging_approval_request_flow_brand_side_v2",
+            "ig_rn_branded_content_settings_approval_on_select_save",
+            "aymt_instagram_promote_flow_abandonment_ig_universe",
+            "ig_android_account_insights_shopping_content_universe",
+            "ig_android_business_attribute_sync",
+            "ig_android_business_promote_tooltip",
+            "ig_android_business_transaction_in_stories_consumer",
+            "ig_android_business_transaction_in_stories_creator",
+            "ig_android_create_page_on_top_universe",
+            "ig_android_fb_sync_options_universe",
+            "ig_android_fb_url_universe",
+            "ig_android_fbpage_on_profile_side_tray",
+            "ig_android_insights_post_dismiss_button",
+            "ig_android_location_integrity_universe",
+            "ig_android_personal_user_xposting_destination_fix",
+            "ig_android_place_signature_universe",
+            "ig_android_product_breakdown_post_insights",
+            "ig_android_secondary_inbox_universe",
+            "ig_android_share_publish_page_universe",
+            "ig_android_skip_button_content_on_connect_fb_universe",
+            "ig_business_new_value_prop_universe",
+            "ig_android_business_cross_post_with_biz_id_infra",
+            "ig_android_claim_location_page",
+            "ig_android_edit_location_page_info",
+            "ig_android_page_claim_deeplink_qe",
+            "ig_biz_growth_insights_universe",
+            "android_cameracore_fbaudio_integration_ig_universe",
+            "android_ig_cameracore_aspect_ratio_fix",
+            "ig_android_arengine_remote_scripting_universe",
+            "ig_android_camera_formats_ranking_universe",
+            "ig_android_camera_gyro_universe",
+            "ig_android_camera_reduce_file_exif_reads",
+            "ig_android_enable_automated_instruction_text_ar",
+            "ig_android_image_exif_metadata_ar_effect_id_universe",
+            "ig_android_optic_face_detection",
+            "ig_android_optic_new_architecture",
+            "ig_android_optic_photo_cropping_fixes",
+            "ig_android_recognition_tracking_thread_prority_universe",
+            "ig_android_ttcp_improvements",
+            "ig_camera_android_bg_processor",
+            "ig_camera_android_effect_metadata_cache_refresh_universe",
+            "ig_camera_android_facetracker_v12_universe",
+            "ig_camera_android_feed_effect_attribution_universe",
+            "ig_camera_android_gallery_search_universe",
+            "ig_camera_android_gyro_senser_sampling_period_universe",
+            "ig_camera_android_paris_filter_universe",
+            "ig_camera_android_share_effect_link_universe",
+            "ig_camera_android_subtle_filter_universe",
+            "ig_cameracore_android_new_optic_camera2",
+            "ig_cameracore_android_new_optic_camera2_galaxy",
+            "ig_android_external_gallery_import_affordance",
+            "ig_android_feed_auto_share_to_facebook_dialog",
+            "ig_android_fs_creation_flow_tweaks",
+            "ig_android_fs_new_gallery",
+            "ig_android_fs_new_gallery_hashtag_prompts",
+            "ig_android_music_story_fb_crosspost_universe",
+            "ig_android_partial_share_sheet",
+            "ig_android_photo_creation_large_width",
+            "ig_android_render_thread_memory_leak_holdout",
+            "ig_android_direct_add_member_dialog_universe",
+            "ig_android_direct_aggregated_media_and_reshares",
+            "ig_android_direct_block_from_group_message_requests",
+            "ig_android_direct_inbox_cache_universe",
+            "ig_android_direct_leave_from_group_message_requests",
+            "ig_android_direct_mark_as_read_notif_action",
+            "ig_android_direct_message_follow_button",
+            "ig_android_direct_multi_upload_universe",
+            "ig_android_direct_mutation_manager_media_3",
+            "ig_android_direct_new_gallery",
+            "ig_android_direct_segmented_video",
+            "ig_android_direct_state_observer",
+            "ig_android_direct_thread_target_queue_universe",
+            "ig_android_direct_view_more_qe",
+            "ig_android_direct_wellbeing_message_reachability_settings",
+            "ig_android_disable_manual_retries",
+            "ig_android_gallery_grid_controller_folder_cache",
+            "ig_android_multi_thread_sends",
+            "ig_android_on_notification_cleared_async_universe",
+            "ig_android_push_reliability_universe",
+            "ig_android_wait_for_app_initialization_on_push_action_universe",
+            "ig_direct_android_bubble_system",
+            "ig_direct_max_participants",
+            "ig_android_explore_recyclerview_universe",
+            "ig_android_explore_reel_loading_state",
+            "ig_android_not_interested_secondary_options",
+            "ig_android_save_to_collections_bottom_sheet_refactor",
+            "ig_android_save_to_collections_flow",
+            "ig_android_smplt_universe",
+            "ig_explore_2018_post_chaining_account_recs_dedupe_universe",
+            "ig_explore_2019_h1_destination_cover",
+            "ig_explore_2019_h1_video_autoplay_resume",
+            "ig_explore_reel_ring_universe",
+            "ig_android_feed_cache_update",
+            "ig_android_view_info_universe",
+            "ig_carousel_bumped_organic_impression_client_universe",
+            "ig_end_of_feed_universe",
+            "ig_android_igtv_autoplay_on_prepare",
+            "ig_android_igtv_browse_long_press",
+            "ig_android_igtv_explore2x2_viewer",
+            "ig_android_igtv_pip",
+            "ig_android_igtv_player_follow_button",
+            "ig_android_igtv_refresh_tv_guide_interval",
+            "ig_android_igtv_stories_preview",
+            "ig_android_igtv_whitelisted_for_web",
+            "ig_android_biz_story_to_fb_page_improvement",
+            "ig_android_contact_point_upload_rate_limit_killswitch",
+            "ig_android_country_code_fix_universe",
+            "ig_android_dual_destination_quality_improvement",
+            "ig_android_explore_discover_people_entry_point_universe",
+            "ig_android_fb_follow_server_linkage_universe",
+            "ig_android_fb_link_ui_polish_universe",
+            "ig_android_fb_profile_integration_universe",
+            "ig_android_fbc_upsell_on_dp_first_load",
+            "ig_android_ig_personal_account_to_fb_page_linkage_backfill",
+            "ig_android_inline_editing_local_prefill",
+            "ig_android_interest_follows_universe",
+            "ig_android_invite_list_button_redesign_universe",
+            "ig_android_login_onetap_upsell_universe",
+            "ig_android_new_follower_removal_universe",
+            "ig_android_persistent_nux",
+            "ig_android_qp_kill_switch",
+            "ig_android_recommend_accounts_destination_routing_fix",
+            "ig_android_self_following_v2_universe",
+            "ig_android_self_story_button_non_fbc_accounts",
+            "ig_android_self_story_setting_option_in_menu",
+            "ig_android_separate_empty_feed_su_universe",
+            "ig_android_show_create_content_pages_universe",
+            "ig_android_show_self_followers_after_becoming_private_universe",
+            "ig_android_suggested_users_background",
+            "ig_android_test_not_signing_address_book_unlink_endpoint",
+            "ig_android_test_remove_button_main_cta_self_followers_universe",
+            "ig_android_unfollow_from_main_feed_v2",
+            "ig_android_unfollow_reciprocal_universe",
+            "ig_android_unify_graph_management_actions",
+            "ig_android_whats_app_contact_invite_universe",
+            "ig_android_xposting_feed_to_stories_reshares_universe",
+            "ig_android_xposting_newly_fbc_people",
+            "ig_android_xposting_reel_memory_share_universe",
+            "ig_android_zero_rating_carrier_signal",
+            "ig_fb_graph_differentiation",
+            "ig_graph_evolution_holdout_universe",
+            "ig_graph_management_h2_2019_universe",
+            "ig_graph_management_production_h2_2019_holdout_universe",
+            "ig_inventory_connections",
+            "ig_pacing_overriding_universe",
+            "ig_sim_api_analytics_reporting",
+            "ig_xposting_biz_feed_to_story_reshare",
+            "ig_xposting_mention_reshare_stories",
+            "instagram_ns_qp_prefetch_universe",
+            "ig_android_analytics_background_uploader_schedule",
+            "ig_android_appstate_logger",
+            "ig_android_apr_lazy_build_request_infra",
+            "ig_android_camera_leak",
+            "ig_android_crash_fix_detach_from_gl_context",
+            "ig_android_dead_code_detection",
+            "ig_android_disk_usage_logging_universe",
+            "ig_android_dropframe_manager",
+            "ig_android_image_upload_quality_universe",
+            "ig_android_mainfeed_generate_prefetch_background",
+            "ig_android_media_remodel",
+            "ig_android_memory_use_logging_universe",
+            "ig_android_network_perf_qpl_ppr",
+            "ig_android_qpl_class_marker",
+            "ig_android_react_native_email_sms_settings_universe",
+            "ig_android_sharedpreferences_qpl_logging",
+            "ig_disable_fsync_universe",
+            "ig_mprotect_code_universe",
+            "ig_prefetch_scheduler_backtest",
+            "ig_traffic_routing_universe",
+            "ig_android_qr_code_nametag",
+            "ig_android_qr_code_scanner",
+            "ig_android_live_egl10_compat",
+            "ig_android_live_realtime_comments_universe",
+            "ig_android_live_subscribe_user_level_universe",
+            "ig_android_tango_cpu_overuse_universe",
+            "igqe_pending_tagged_posts",
+            "ig_android_enable_zero_rating",
+            "ig_android_sso_kototoro_app_universe",
+            "ig_android_sso_use_trustedapp_universe",
+            "ig_android_whitehat_options_universe",
+            "ig_android_payments_growth_promote_payments_in_payments",
+            "ig_android_purx_native_checkout_universe",
+            "ig_android_shopping_pdp_post_purchase_sharing",
+            "ig_payment_checkout_cvv",
+            "ig_payment_checkout_info",
+            "ig_payments_billing_address",
+            "ig_android_profile_thumbnail_impression",
+            "ig_android_user_url_deeplink_fbpage_endpoint",
+            "instagram_android_profile_follow_cta_context_feed",
+            "ig_android_mqtt_cookie_auth_memcache_universe",
+            "ig_android_realtime_mqtt_logging",
+            "ig_rti_inapp_notifications_universe",
+            "ig_android_live_webrtc_livewith_params",
+            "saved_collections_cache_universe",
+            "ig_android_search_nearby_places_universe",
+            "ig_search_hashtag_content_advisory_remove_snooze",
+            "ig_android_shopping_bag_null_state_v1",
+            "ig_android_shopping_bag_optimization_universe",
+            "ig_android_shopping_checkout_signaling",
+            "ig_android_shopping_product_metadata_on_product_tiles_universe",
+            "ig_android_wishlist_reconsideration_universe",
+            "ig_biz_post_approval_nux_universe",
+            "ig_commerce_platform_ptx_bloks_universe",
+            "ig_shopping_bag_universe",
+            "ig_shopping_checkout_improvements_universe",
+            "ig_shopping_checkout_improvements_v2_universe",
+            "ig_shopping_checkout_mvp_experiment",
+            "ig_shopping_insights_wc_copy_update_android",
+            "ig_shopping_size_selector_redesign",
+            "instagram_shopping_hero_carousel_visual_variant_consolidation",
+            "ig_android_audience_control",
+            "ig_android_camera_upsell_dialog",
+            "ig_android_create_mode_memories_see_all",
+            "ig_android_create_mode_tap_to_cycle",
+            "ig_android_create_mode_templates",
+            "ig_android_feed_post_sticker",
+            "ig_android_frx_creation_question_responses_reporting",
+            "ig_android_frx_highlight_cover_reporting_qe",
+            "ig_android_music_browser_redesign",
+            "ig_android_publisher_stories_migration",
+            "ig_android_rainbow_hashtags",
+            "ig_android_recipient_picker",
+            "ig_android_reel_tray_item_impression_logging_viewpoint",
+            "ig_android_save_all",
+            "ig_android_stories_blacklist",
+            "ig_android_stories_boomerang_v2_universe",
+            "ig_android_stories_context_sheets_universe",
+            "ig_android_stories_gallery_sticker_universe",
+            "ig_android_stories_gallery_video_segmentation",
+            "ig_android_stories_layout_universe",
+            "ig_android_stories_music_awareness_universe",
+            "ig_android_stories_music_lyrics",
+            "ig_android_stories_music_overlay",
+            "ig_android_stories_music_search_typeahead",
+            "ig_android_stories_project_eclipse",
+            "ig_android_stories_question_sticker_music_format",
+            "ig_android_stories_quick_react_gif_universe",
+            "ig_android_stories_share_extension_video_segmentation",
+            "ig_android_stories_sundial_creation_universe",
+            "ig_android_stories_video_prefetch_kb",
+            "ig_android_stories_vpvd_container_module_fix",
+            "ig_android_stories_weblink_creation",
+            "ig_android_story_bottom_sheet_clips_single_audio_mas",
+            "ig_android_story_bottom_sheet_music_mas",
+            "ig_android_story_bottom_sheet_top_clips_mas",
+            "ig_android_xposting_dual_destination_shortcut_fix",
+            "ig_stories_allow_camera_actions_while_recording",
+            "ig_stories_rainbow_ring",
+            "ig_threads_clear_notifications_on_has_seen",
+            "ig_threads_sanity_check_thread_viewkeys",
+            "ig_android_action_sheet_migration_universe",
+            "ig_android_emoji_util_universe_3",
+            "ig_android_recyclerview_binder_group_enabled_universe",
+            "ig_emoji_render_counter_logging_universe",
+            "ig_android_delete_ssim_compare_img_soon",
+            "ig_android_reel_raven_video_segmented_upload_universe",
+            "ig_android_render_output_surface_timeout_universe",
+            "ig_android_sidecar_segmented_streaming_universe",
+            "ig_android_stories_video_seeking_audio_bug_fix",
+            "ig_android_video_abr_universe",
+            "ig_android_video_call_finish_universe",
+            "ig_android_video_exoplayer_2",
+            "ig_android_video_fit_scale_type_igtv",
+            "ig_android_video_product_specific_abr",
+            "ig_android_video_qp_logger_universe",
+            "ig_android_video_raven_bitrate_ladder_universe",
+            "ig_android_video_raven_passthrough",
+            "ig_android_video_raven_streaming_upload_universe",
+            "ig_android_video_streaming_upload_universe",
+            "ig_android_video_upload_hevc_encoding_universe",
+            "ig_android_video_upload_quality_qe1",
+            "ig_android_video_upload_transform_matrix_fix_universe",
+            "ig_android_video_visual_quality_score_based_abr",
+            "ig_video_experimental_encoding_consumption_universe",
+            "ig_android_vc_background_call_toast_universe",
+            "ig_android_vc_capture_universe",
+            "ig_android_vc_codec_settings",
+            "ig_android_vc_cowatch_config_universe",
+            "ig_android_vc_cowatch_media_share_universe",
+            "ig_android_vc_cowatch_universe",
+            "ig_android_vc_cpu_overuse_universe",
+            "ig_android_vc_explicit_intent_for_notification",
+            "ig_android_vc_face_effects_universe",
+            "ig_android_vc_join_timeout_universe",
+            "ig_android_vc_migrate_to_bluetooth_v2_universe",
+            "ig_android_vc_missed_call_call_back_action_universe",
+            "ig_android_vc_shareable_moments_universe",
+            "ig_android_comment_warning_non_english_universe",
+            "ig_android_feed_post_warning_universe",
+            "ig_android_image_pdq_calculation",
+            "ig_android_logged_in_delta_migration",
+            "ig_android_wellbeing_support_frx_cowatch_reporting",
+            "ig_android_wellbeing_support_frx_hashtags_reporting",
+            "ig_android_wellbeing_timeinapp_v1_universe",
+            "ig_challenge_general_v2",
+            "ig_ei_option_setting_universe",
+        )
+    )
+    LOGIN_EXPERIMENTS: str = "".join(
+        (
+            "ig_android_device_detection_info_upload",
+            "ig_android_device_info_foreground_reporting",
+            "ig_android_suma_landing_page",
+            "ig_android_device_based_country_verification",
+            "ig_android_direct_add_direct_to_android_native_photo_share_sheet",
+            "ig_android_direct_main_tab_universe_v2",
+            "ig_account_identity_logged_out_signals_global_holdout_universe",
+            "ig_android_fb_account_linking_sampling_freq_universe",
+            "ig_android_login_identifier_fuzzy_match",
+            "ig_android_nux_add_email_device",
+            "ig_android_passwordless_account_password_creation_universe",
+            "ig_android_reg_modularization_universe",
+            "ig_android_retry_create_account_universe",
+            "ig_growth_android_profile_pic_prefill_with_fb_pic_2",
+            "ig_android_quickcapture_keep_screen_on",
+            "ig_android_gmail_oauth_in_reg",
+            "ig_android_reg_nux_headers_cleanup_universe",
+            "ig_android_smartlock_hints_universe",
+            "ig_android_security_intent_switchoff",
+            "ig_android_sim_info_upload",
+            "ig_android_caption_typeahead_fix_on_o_universe",
+            "ig_android_video_render_codec_low_memory_gc",
+            "ig_android_device_verification_fb_signup",
+            "ig_android_device_verification_separate_endpoint",
+        )
+    )

+ 8 - 5
mauigpapi/state/cookies.py

@@ -13,12 +13,14 @@
 #
 # 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
+from __future__ import annotations
+
 from http.cookies import Morsel, SimpleCookie
 
 from aiohttp import CookieJar
 from yarl import URL
-from mautrix.types import Serializable, JSON
+
+from mautrix.types import JSON, Serializable
 
 from ..errors import IGCookieNotFoundError
 
@@ -36,11 +38,12 @@ class Cookies(Serializable):
             morsel.key: {
                 **{k: v for k, v in morsel.items() if v},
                 "value": morsel.value,
-            } for morsel in self.jar
+            }
+            for morsel in self.jar
         }
 
     @classmethod
-    def deserialize(cls, raw: JSON) -> 'Cookies':
+    def deserialize(cls, raw: JSON) -> Cookies:
         cookie = SimpleCookie()
         for key, data in raw.items():
             cookie[key] = data.pop("value")
@@ -68,7 +71,7 @@ class Cookies(Serializable):
         filtered = self.jar.filter_cookies(ig_url)
         return filtered.get(key)
 
-    def get_value(self, key: str) -> Optional[str]:
+    def get_value(self, key: str) -> str | None:
         cookie = self.get(key)
         return cookie.value if cookie else None
 

+ 1 - 1
mauigpapi/state/device.py

@@ -15,11 +15,11 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from typing import Optional, Union
 from uuid import UUID
+import json
 import pkgutil
 import random
 import string
 import time
-import json
 
 from attr import dataclass
 import attr

+ 8 - 6
mauigpapi/state/state.py

@@ -22,13 +22,13 @@ from attr import dataclass
 
 from mautrix.types import SerializableAttrs, field
 
-from ..errors import IGNoCheckpointError, IGCookieNotFoundError, IGUserIDNotFoundError
+from ..errors import IGCookieNotFoundError, IGNoCheckpointError, IGUserIDNotFoundError
 from ..types import ChallengeStateResponse
-from .device import AndroidDevice
-from .session import AndroidSession
 from .application import AndroidApplication
-from .experiments import AndroidExperiments
 from .cookies import Cookies
+from .device import AndroidDevice
+from .experiments import AndroidExperiments
+from .session import AndroidSession
 
 
 @dataclass
@@ -57,8 +57,10 @@ class AndroidState(SerializableAttrs):
 
     @property
     def user_agent(self) -> str:
-        return (f"Instagram {self.application.APP_VERSION} Android ({self.device.descriptor}; "
-                f"{self.device.language}; {self.application.APP_VERSION_CODE})")
+        return (
+            f"Instagram {self.application.APP_VERSION} Android ({self.device.descriptor}; "
+            f"{self.device.language}; {self.application.APP_VERSION_CODE})"
+        )
 
     @property
     def user_id(self) -> str:

+ 97 - 27
mauigpapi/types/__init__.py

@@ -1,29 +1,99 @@
+from .account import (
+    BaseFullResponseUser,
+    BaseResponseUser,
+    CurrentUser,
+    CurrentUserResponse,
+    EntityText,
+    FriendshipStatus,
+    HDProfilePictureVersion,
+    ProfileEditParams,
+    UserIdentifier,
+)
+from .challenge import ChallengeStateData, ChallengeStateResponse
+from .direct_inbox import DMInbox, DMInboxCursor, DMInboxResponse, DMThreadResponse
+from .error import (
+    CheckpointChallenge,
+    CheckpointResponse,
+    LoginErrorResponse,
+    LoginErrorResponseButton,
+    LoginPhoneVerificationSettings,
+    LoginRequiredResponse,
+    LoginTwoFactorInfo,
+    SpamResponse,
+)
+from .login import LoginResponse, LoginResponseNametag, LoginResponseUser, LogoutResponse
+from .mqtt import (
+    ActivityIndicatorData,
+    AppPresenceEvent,
+    AppPresenceEventPayload,
+    ClientConfigUpdateEvent,
+    ClientConfigUpdatePayload,
+    CommandResponse,
+    CommandResponsePayload,
+    IrisPayload,
+    IrisPayloadData,
+    LiveVideoComment,
+    LiveVideoCommentEvent,
+    LiveVideoCommentPayload,
+    LiveVideoSystemComment,
+    MessageSyncEvent,
+    MessageSyncMessage,
+    Operation,
+    PubsubBasePayload,
+    PubsubEvent,
+    PubsubPayload,
+    PubsubPayloadData,
+    PubsubPublishMetadata,
+    ReactionStatus,
+    RealtimeDirectData,
+    RealtimeDirectEvent,
+    RealtimeZeroProvisionPayload,
+    ThreadAction,
+    ThreadSyncEvent,
+    TypingStatus,
+    ZeroProductProvisioningEvent,
+)
 from .qe import AndroidExperiment, QeSyncExperiment, QeSyncExperimentParam, QeSyncResponse
-from .error import (SpamResponse, CheckpointResponse, CheckpointChallenge,
-                    LoginRequiredResponse, LoginErrorResponse, LoginErrorResponseButton,
-                    LoginPhoneVerificationSettings, LoginTwoFactorInfo)
-from .login import LoginResponseUser, LoginResponseNametag, LoginResponse, LogoutResponse
-from .account import (CurrentUser, EntityText, HDProfilePictureVersion, CurrentUserResponse,
-                      FriendshipStatus, UserIdentifier, BaseFullResponseUser, BaseResponseUser,
-                      ProfileEditParams)
-from .direct_inbox import DMInboxResponse, DMInboxCursor, DMInbox, DMThreadResponse
-from .upload import (UploadPhotoResponse, UploadVideoResponse, FinishUploadResponse,
-                     ShareVoiceResponse, ShareVoiceResponseMessage)
-from .thread_item import (ThreadItemType, ThreadItemActionLog, ViewMode, CreativeConfig, MediaType,
-                          CreateModeAttribution, ImageVersion, ImageVersions, VisualMedia, Caption,
-                          RegularMediaItem, MediaShareItem, ReplayableMediaItem, VideoVersion,
-                          AudioInfo, VoiceMediaItem, AnimatedMediaImage, AnimatedMediaImages,
-                          AnimatedMediaItem, ThreadItem, VoiceMediaData, Reaction, Reactions,
-                          Location, ExpiredMediaItem, ReelMediaShareItem, ReelShareItem, LinkItem,
-                          ReelShareType, ReelShareReactionInfo, SharingFrictionInfo, LinkContext)
-from .thread import Thread, ThreadUser, ThreadItem, ThreadUserLastSeenAt, ThreadTheme
-from .mqtt import (Operation, ThreadAction, ReactionStatus, TypingStatus, CommandResponsePayload,
-                   CommandResponse, IrisPayloadData, IrisPayload, MessageSyncMessage,
-                   MessageSyncEvent, PubsubBasePayload, PubsubPublishMetadata, PubsubPayloadData,
-                   ActivityIndicatorData, PubsubEvent, PubsubPayload, AppPresenceEventPayload,
-                   AppPresenceEvent, ZeroProductProvisioningEvent, RealtimeZeroProvisionPayload,
-                   ClientConfigUpdatePayload, ClientConfigUpdateEvent, RealtimeDirectData,
-                   RealtimeDirectEvent, LiveVideoSystemComment, LiveVideoCommentEvent,
-                   LiveVideoComment, LiveVideoCommentPayload, ThreadSyncEvent)
-from .challenge import ChallengeStateResponse, ChallengeStateData
+from .thread import Thread, ThreadItem, ThreadTheme, ThreadUser, ThreadUserLastSeenAt
+from .thread_item import (
+    AnimatedMediaImage,
+    AnimatedMediaImages,
+    AnimatedMediaItem,
+    AudioInfo,
+    Caption,
+    CreateModeAttribution,
+    CreativeConfig,
+    ExpiredMediaItem,
+    ImageVersion,
+    ImageVersions,
+    LinkContext,
+    LinkItem,
+    Location,
+    MediaShareItem,
+    MediaType,
+    Reaction,
+    Reactions,
+    ReelMediaShareItem,
+    ReelShareItem,
+    ReelShareReactionInfo,
+    ReelShareType,
+    RegularMediaItem,
+    ReplayableMediaItem,
+    SharingFrictionInfo,
+    ThreadItem,
+    ThreadItemActionLog,
+    ThreadItemType,
+    VideoVersion,
+    ViewMode,
+    VisualMedia,
+    VoiceMediaData,
+    VoiceMediaItem,
+)
+from .upload import (
+    FinishUploadResponse,
+    ShareVoiceResponse,
+    ShareVoiceResponseMessage,
+    UploadPhotoResponse,
+    UploadVideoResponse,
+)
 from .user import SearchResultUser, UserSearchResponse

+ 1 - 1
mauigpapi/types/account.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, List, Optional, Dict
+from typing import Any, Dict, List, Optional
 
 from attr import dataclass
 import attr

+ 1 - 0
mauigpapi/types/challenge.py

@@ -14,6 +14,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
+
 from attr import dataclass
 
 from mautrix.types import SerializableAttrs

+ 2 - 1
mauigpapi/types/direct_inbox.py

@@ -13,9 +13,10 @@
 #
 # 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, Optional
+from typing import Any, List, Optional
 
 from attr import dataclass
+
 from mautrix.types import SerializableAttrs
 
 from .thread import Thread, ThreadUser

+ 1 - 1
mauigpapi/types/error.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, List
+from typing import List, Optional
 
 from attr import dataclass
 

+ 1 - 1
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, List
+from typing import Any, List, Optional
 
 from attr import dataclass
 

+ 3 - 3
mauigpapi/types/mqtt.py

@@ -19,11 +19,11 @@ import json
 from attr import dataclass
 import attr
 
-from mautrix.types import SerializableAttrs, SerializableEnum, JSON
+from mautrix.types import JSON, SerializableAttrs, SerializableEnum
 
+from .account import BaseResponseUser
 from .thread import Thread
 from .thread_item import ThreadItem
-from .account import BaseResponseUser
 
 
 class Operation(SerializableEnum):
@@ -132,7 +132,7 @@ class ActivityIndicatorData(SerializableAttrs):
     activity_status: TypingStatus
 
     @classmethod
-    def deserialize(cls, data: JSON) -> 'ActivityIndicatorData':
+    def deserialize(cls, data: JSON) -> "ActivityIndicatorData":
         # The ActivityIndicatorData in PubsubPayloadData is actually a string,
         # so we need to unmarshal it first.
         if isinstance(data, str):

+ 8 - 6
mauigpapi/types/qe.py

@@ -13,10 +13,10 @@
 #
 # 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, Optional, Any, Dict
+from typing import Any, Dict, List, Optional
 import json
 
-from attr import dataclass, attrib
+from attr import attrib, dataclass
 
 from mautrix.types import SerializableAttrs
 
@@ -51,10 +51,12 @@ class QeSyncExperiment(SerializableAttrs):
     logging_id: Optional[str] = None
 
     def parse(self) -> AndroidExperiment:
-        return AndroidExperiment(group=self.group, additional=self.additional_params,
-                                 logging_id=self.logging_id,
-                                 params={param.name: _try_parse(param.value)
-                                         for param in self.params})
+        return AndroidExperiment(
+            group=self.group,
+            additional=self.additional_params,
+            logging_id=self.logging_id,
+            params={param.name: _try_parse(param.value) for param in self.params},
+        )
 
 
 @dataclass

+ 3 - 2
mauigpapi/types/thread.py

@@ -13,10 +13,11 @@
 #
 # 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 Any, Dict, List, Optional
 
-import attr
 from attr import dataclass
+import attr
+
 from mautrix.types import SerializableAttrs
 
 from .account import BaseResponseUser

+ 8 - 7
mauigpapi/types/thread_item.py

@@ -16,9 +16,10 @@
 from typing import List, Optional, Union
 import logging
 
-import attr
 from attr import dataclass
-from mautrix.types import SerializableAttrs, SerializableEnum, JSON, SerializerError, Obj
+import attr
+
+from mautrix.types import JSON, Obj, SerializableAttrs, SerializableEnum, SerializerError
 from mautrix.types.util.serializable_attrs import _dict_to_attrs
 
 from .account import BaseResponseUser, UserIdentifier
@@ -297,7 +298,7 @@ class VisualMedia(ReplayableMediaItem, SerializableAttrs):
     media: Union[RegularMediaItem, ExpiredMediaItem]
 
     @classmethod
-    def deserialize(cls, data: JSON) -> 'VisualMedia':
+    def deserialize(cls, data: JSON) -> "VisualMedia":
         data = {**data}
         if "id" not in data["media"]:
             data["media"] = ExpiredMediaItem.deserialize(data["media"])
@@ -412,7 +413,7 @@ class ReelShareItem(SerializableAttrs):
     mentioned_user_id: Optional[int] = None
 
     @classmethod
-    def deserialize(cls, data: JSON) -> 'ReelShareItem':
+    def deserialize(cls, data: JSON) -> "ReelShareItem":
         data = {**data}
         if "id" not in data["media"]:
             data["media"] = ExpiredMediaItem.deserialize(data["media"])
@@ -440,7 +441,7 @@ class StoryShareItem(SerializableAttrs):
     reason: Optional[int] = None  # 3 = expired?
 
     @classmethod
-    def deserialize(cls, data: JSON) -> 'StoryShareItem':
+    def deserialize(cls, data: JSON) -> "StoryShareItem":
         data = {**data}
         if "media" not in data:
             data["media"] = ExpiredMediaItem()
@@ -479,7 +480,7 @@ class ThreadItem(SerializableAttrs):
     show_forward_attribution: Optional[bool] = None
     action_log: Optional[ThreadItemActionLog] = None
 
-    replied_to_message: Optional['ThreadItem'] = None
+    replied_to_message: Optional["ThreadItem"] = None
 
     media: Optional[RegularMediaItem] = None
     voice_media: Optional[VoiceMediaItem] = None
@@ -497,7 +498,7 @@ class ThreadItem(SerializableAttrs):
     profile: Optional[ProfileItem] = None
 
     @classmethod
-    def deserialize(cls, data: JSON, catch_errors: bool = True) -> Union['ThreadItem', Obj]:
+    def deserialize(cls, data: JSON, catch_errors: bool = True) -> Union["ThreadItem", Obj]:
         if not catch_errors:
             return _dict_to_attrs(cls, data)
         try:

+ 1 - 1
mauigpapi/types/user.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, List
+from typing import List, Optional
 
 from attr import dataclass
 

+ 17 - 13
mautrix_instagram/__main__.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2021 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,22 +13,24 @@
 #
 # 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
-import logging
+from __future__ import annotations
+
+from typing import Any
 import asyncio
+import logging
 
-from mautrix.types import UserID, RoomID
 from mautrix.bridge import Bridge
+from mautrix.types import RoomID, UserID
 
+from . import commands
 from .config import Config
-from .db import upgrade_table, init as init_db
-from .user import User
+from .db import init as init_db, upgrade_table
+from .matrix import MatrixHandler
 from .portal import Portal
 from .puppet import Puppet
-from .matrix import MatrixHandler
-from .version import version, linkified_version
+from .user import User
+from .version import linkified_version, version
 from .web import ProvisioningAPI
-from . import commands
 
 
 class InstagramBridge(Bridge):
@@ -47,7 +49,7 @@ class InstagramBridge(Bridge):
     matrix: MatrixHandler
     provisioning_api: ProvisioningAPI
 
-    periodic_reconnect_task: Optional[asyncio.Task]
+    periodic_reconnect_task: asyncio.Task | None
 
     def preinit(self) -> None:
         self.periodic_reconnect_task = None
@@ -60,8 +62,9 @@ class InstagramBridge(Bridge):
     def prepare_bridge(self) -> None:
         super().prepare_bridge()
         cfg = self.config["bridge.provisioning"]
-        self.provisioning_api = ProvisioningAPI(shared_secret=cfg["shared_secret"],
-                                                device_seed=self.config["instagram.device_seed"])
+        self.provisioning_api = ProvisioningAPI(
+            shared_secret=cfg["shared_secret"], device_seed=self.config["instagram.device_seed"]
+        )
         self.az.app.add_subapp(cfg["prefix"], self.provisioning_api.app)
 
     async def start(self) -> None:
@@ -143,7 +146,7 @@ class InstagramBridge(Bridge):
     async def count_logged_in_users(self) -> int:
         return len([user for user in User.by_igpk.values() if user.igpk])
 
-    async def manhole_global_namespace(self, user_id: UserID) -> Dict[str, Any]:
+    async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
         return {
             **await super().manhole_global_namespace(user_id),
             "User": User,
@@ -151,4 +154,5 @@ class InstagramBridge(Bridge):
             "Puppet": Puppet,
         }
 
+
 InstagramBridge().run()

+ 58 - 32
mautrix_instagram/commands/auth.py

@@ -1,5 +1,5 @@
-# mautrix-twitter - A Matrix-Twitter DM puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,28 +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 typing import Tuple, TYPE_CHECKING
+from __future__ import annotations
+
 import hashlib
 import hmac
 
-from mautrix.bridge.commands import HelpSection, command_handler
-from mauigpapi.state import AndroidState
+from mauigpapi.errors import (
+    IGBad2FACodeError,
+    IGChallengeWrongCodeError,
+    IGCheckpointError,
+    IGLoginBadPasswordError,
+    IGLoginInvalidUserError,
+    IGLoginTwoFactorRequiredError,
+)
 from mauigpapi.http import AndroidAPI
-from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGBad2FACodeError, IGCheckpointError,
-                              IGChallengeWrongCodeError)
+from mauigpapi.state import AndroidState
 from mauigpapi.types import BaseResponseUser
+from mautrix.bridge.commands import HelpSection, command_handler
 
+from .. import user as u
 from .typehint import CommandEvent
 
-if TYPE_CHECKING:
-    from ..user import User
-
 SECTION_AUTH = HelpSection("Authentication", 10, "")
 
 
-async def get_login_state(user: 'User', username: str, seed: str
-                          ) -> Tuple[AndroidAPI, AndroidState]:
+async def get_login_state(
+    user: u.User, username: str, seed: str
+) -> tuple[AndroidAPI, AndroidState]:
     if user.command_status and user.command_status["action"] == "Login":
         api: AndroidAPI = user.command_status["api"]
         state: AndroidState = user.command_status["state"]
@@ -52,8 +57,13 @@ async def get_login_state(user: 'User', username: str, seed: str
     return api, state
 
 
-@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
-                 help_text="Log in to Instagram", help_args="<_username_> <_password_>")
+@command_handler(
+    needs_auth=False,
+    management_only=True,
+    help_section=SECTION_AUTH,
+    help_text="Log in to Instagram",
+    help_args="<_username_> <_password_>",
+)
 async def login(evt: CommandEvent) -> None:
     if await evt.sender.is_logged_in():
         await evt.reply("You're already logged in")
@@ -74,8 +84,10 @@ async def login(evt: CommandEvent) -> None:
         elif tfa_info.sms_two_factor_on:
             msg += f"Send the code sent to {tfa_info.obfuscated_phone_number} here."
         else:
-            msg += ("Unfortunately, none of your two-factor authentication methods are currently "
-                    "supported by the bridge.")
+            msg += (
+                "Unfortunately, none of your two-factor authentication methods are currently "
+                "supported by the bridge."
+            )
             return
         evt.sender.command_status = {
             **evt.sender.command_status,
@@ -91,8 +103,10 @@ async def login(evt: CommandEvent) -> None:
             **evt.sender.command_status,
             "next": enter_login_security_code,
         }
-        await evt.reply("Username and password accepted, but Instagram wants to verify it's really"
-                        " you. Please confirm the login and enter the security code here.")
+        await evt.reply(
+            "Username and password accepted, but Instagram wants to verify it's really"
+            " you. Please confirm the login and enter the security code here."
+        )
     except IGLoginInvalidUserError:
         await evt.reply("Invalid username")
     except IGLoginBadPasswordError:
@@ -111,19 +125,24 @@ async def enter_login_2fa(evt: CommandEvent) -> None:
     username = evt.sender.command_status["username"]
     is_totp = evt.sender.command_status["is_totp"]
     try:
-        resp = await api.two_factor_login(username, code="".join(evt.args), identifier=identifier,
-                                          is_totp=is_totp)
+        resp = await api.two_factor_login(
+            username, code="".join(evt.args), identifier=identifier, is_totp=is_totp
+        )
     except IGBad2FACodeError:
-        await evt.reply("Invalid 2-factor authentication code. Please try again "
-                        "or use `$cmdprefix+sp cancel` to cancel.")
+        await evt.reply(
+            "Invalid 2-factor authentication code. Please try again "
+            "or use `$cmdprefix+sp cancel` to cancel."
+        )
     except IGCheckpointError:
         await api.challenge_auto(reset=True)
         evt.sender.command_status = {
             **evt.sender.command_status,
             "next": enter_login_security_code,
         }
-        await evt.reply("2-factor authentication code accepted, but Instagram wants to verify it's"
-                        " really you. Please confirm the login and enter the security code here.")
+        await evt.reply(
+            "2-factor authentication code accepted, but Instagram wants to verify it's"
+            " really you. Please confirm the login and enter the security code here."
+        )
     except Exception as e:
         evt.log.exception("Failed to log in")
         await evt.reply(f"Failed to log in: {e}")
@@ -146,8 +165,10 @@ async def enter_login_security_code(evt: CommandEvent) -> None:
         evt.sender.command_status = None
     else:
         if not resp.logged_in_user:
-            evt.log.error(f"Didn't get logged_in_user in challenge response "
-                          f"after entering security code: {resp.serialize()}")
+            evt.log.error(
+                f"Didn't get logged_in_user in challenge response "
+                f"after entering security code: {resp.serialize()}"
+            )
             await evt.reply("An unknown error occurred. Please check the bridge logs.")
             return
         evt.sender.command_status = None
@@ -158,14 +179,19 @@ async def _post_login(evt: CommandEvent, state: AndroidState, user: BaseResponse
     evt.sender.state = state
     pl = state.device.payload
     manufacturer, model = pl["manufacturer"], pl["model"]
-    await evt.reply(f"Successfully logged in as {user.full_name} ([@{user.username}]"
-                    f"(https://instagram.com/{user.username}), user ID: {user.pk}).\n\n"
-                    f"The bridge will show up on Instagram as {manufacturer} {model}.")
+    await evt.reply(
+        f"Successfully logged in as {user.full_name} ([@{user.username}]"
+        f"(https://instagram.com/{user.username}), user ID: {user.pk}).\n\n"
+        f"The bridge will show up on Instagram as {manufacturer} {model}."
+    )
     await evt.sender.try_connect()
 
 
-@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_text="Disconnect the bridge from"
-                                                                       "your Instagram account")
+@command_handler(
+    needs_auth=True,
+    help_section=SECTION_AUTH,
+    help_text="Disconnect the bridge from your Instagram account",
+)
 async def logout(evt: CommandEvent) -> None:
     await evt.sender.logout()
     await evt.reply("Successfully logged out")

+ 44 - 16
mautrix_instagram/commands/conn.py

@@ -1,5 +1,5 @@
-# mautrix-twitter - A Matrix-Twitter DM puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -15,21 +15,30 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 from mauigpapi.errors import IGNotLoggedInError
 from mautrix.bridge.commands import HelpSection, command_handler
+
 from .typehint import CommandEvent
 
 SECTION_CONNECTION = HelpSection("Connection management", 15, "")
 
 
-@command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
-                 help_text="Mark this room as your bridge notice room")
+@command_handler(
+    needs_auth=False,
+    management_only=True,
+    help_section=SECTION_CONNECTION,
+    help_text="Mark this room as your bridge notice room",
+)
 async def set_notice_room(evt: CommandEvent) -> None:
     evt.sender.notice_room = evt.room_id
     await evt.sender.update()
     await evt.reply("This room has been marked as your bridge notice room")
 
 
-@command_handler(needs_auth=False, management_only=True, help_section=SECTION_CONNECTION,
-                 help_text="Check if you're logged into Instagram")
+@command_handler(
+    needs_auth=False,
+    management_only=True,
+    help_section=SECTION_CONNECTION,
+    help_text="Check if you're logged into Instagram",
+)
 async def ping(evt: CommandEvent) -> None:
     if not await evt.sender.is_logged_in():
         await evt.reply("You're not logged into Instagram")
@@ -38,14 +47,19 @@ async def ping(evt: CommandEvent) -> None:
         user_info = await evt.sender.client.current_user()
     except IGNotLoggedInError as e:
         # TODO maybe don't always log out?
-        evt.log.exception(f"Got error checking current user for %s, logging out. %s",
-                          evt.sender.mxid, e.body.json())
+        evt.log.exception(
+            f"Got error checking current user for %s, logging out. %s",
+            evt.sender.mxid,
+            e.body.json(),
+        )
         await evt.reply("You have been logged out")
         await evt.sender.logout()
     else:
         user = user_info.user
-        await evt.reply(f"You're logged in as {user.full_name} ([@{user.username}]"
-                        f"(https://instagram.com/{user.username}), user ID: {user.pk})")
+        await evt.reply(
+            f"You're logged in as {user.full_name} ([@{user.username}]"
+            f"(https://instagram.com/{user.username}), user ID: {user.pk})"
+        )
     if evt.sender.is_connected:
         await evt.reply("MQTT connection is active")
     elif evt.sender.mqtt and evt.sender._listen_task:
@@ -54,15 +68,25 @@ async def ping(evt: CommandEvent) -> None:
         await evt.reply("MQTT not connected")
 
 
-@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION,
-                 help_text="Reconnect to Instagram and synchronize portals", aliases=["sync"])
+@command_handler(
+    needs_auth=True,
+    management_only=False,
+    help_section=SECTION_CONNECTION,
+    help_text="Reconnect to Instagram and synchronize portals",
+    aliases=["sync"],
+)
 async def refresh(evt: CommandEvent) -> None:
     await evt.sender.refresh()
     await evt.reply("Synchronization complete")
 
 
-@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION,
-                 help_text="Connect to Instagram", aliases=["reconnect"])
+@command_handler(
+    needs_auth=True,
+    management_only=False,
+    help_section=SECTION_CONNECTION,
+    help_text="Connect to Instagram",
+    aliases=["reconnect"],
+)
 async def connect(evt: CommandEvent) -> None:
     if evt.sender.is_connected:
         await evt.reply("You're already connected to Instagram.")
@@ -71,8 +95,12 @@ async def connect(evt: CommandEvent) -> None:
     await evt.reply("Restarted connection to Instagram.")
 
 
-@command_handler(needs_auth=True, management_only=False, help_section=SECTION_CONNECTION,
-                 help_text="Disconnect from Instagram")
+@command_handler(
+    needs_auth=True,
+    management_only=False,
+    help_section=SECTION_CONNECTION,
+    help_text="Disconnect from Instagram",
+)
 async def disconnect(evt: CommandEvent) -> None:
     if not evt.sender.mqtt:
         await evt.reply("You're not connected to Instagram.")

+ 13 - 6
mautrix_instagram/commands/misc.py

@@ -1,5 +1,5 @@
-# mautrix-twitter - A Matrix-Twitter DM puppeting bridge
-# Copyright (C) 2020 Tulir Asokan
+# mautrix-instagram - A Matrix-Instagram puppeting bridge.
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -21,8 +21,13 @@ from .typehint import CommandEvent
 SECTION_MISC = HelpSection("Miscellaneous", 40, "")
 
 
-@command_handler(needs_auth=True, management_only=False, help_section=SECTION_MISC,
-                 help_text="Search for Instagram users", help_args="<_query_>")
+@command_handler(
+    needs_auth=True,
+    management_only=False,
+    help_section=SECTION_MISC,
+    help_text="Search for Instagram users",
+    help_args="<_query_>",
+)
 async def search(evt: CommandEvent) -> None:
     if len(evt.args) < 1:
         await evt.reply("**Usage:** `$cmdprefix+sp search <query>`")
@@ -35,6 +40,8 @@ async def search(evt: CommandEvent) -> None:
     for user in resp.users[:10]:
         puppet = await pu.Puppet.get_by_pk(user.pk, create=True)
         await puppet.update_info(user, evt.sender)
-        response_list.append(f"* [{puppet.name}](https://matrix.to/#/{puppet.mxid})"
-                             f" ([@{user.username}](https://instagram.com/{user.username}))")
+        response_list.append(
+            f"* [{puppet.name}](https://matrix.to/#/{puppet.mxid})"
+            f" ([@{user.username}](https://instagram.com/{user.username}))"
+        )
     await evt.reply("\n".join(response_list))

+ 2 - 2
mautrix_instagram/commands/typehint.py

@@ -8,5 +8,5 @@ if TYPE_CHECKING:
 
 
 class CommandEvent(BaseCommandEvent):
-    bridge: 'InstagramBridge'
-    sender: 'User'
+    bridge: "InstagramBridge"
+    sender: "User"

+ 8 - 6
mautrix_instagram/config.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,13 +13,15 @@
 #
 # 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, NamedTuple
+from __future__ import annotations
+
+from typing import Any, NamedTuple
 import os
 
-from mautrix.types import UserID
-from mautrix.client import Client
-from mautrix.util.config import ConfigUpdateHelper, ForbiddenKey, ForbiddenDefault
 from mautrix.bridge.config import BaseBridgeConfig
+from mautrix.client import Client
+from mautrix.types import UserID
+from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
 
 Permissions = NamedTuple("Permissions", relay=bool, user=bool, admin=bool, level=str)
 
@@ -32,7 +34,7 @@ class Config(BaseBridgeConfig):
             return super().__getitem__(key)
 
     @property
-    def forbidden_defaults(self) -> List[ForbiddenDefault]:
+    def forbidden_defaults(self) -> list[ForbiddenDefault]:
         return [
             *super().forbidden_defaults,
             ForbiddenDefault("appservice.database", "postgres://username:password@hostname/db"),

+ 4 - 4
mautrix_instagram/db/__init__.py

@@ -1,11 +1,11 @@
 from mautrix.util.async_db import Database
 
-from .upgrade import upgrade_table
-from .user import User
-from .puppet import Puppet
-from .portal import Portal
 from .message import Message
+from .portal import Portal
+from .puppet import Puppet
 from .reaction import Reaction
+from .upgrade import upgrade_table
+from .user import User
 
 
 def init(db: Database) -> None:

+ 21 - 12
mautrix_instagram/db/message.py

@@ -13,14 +13,16 @@
 #
 # 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 __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
 
 from attr import dataclass
 
-from mautrix.types import RoomID, EventID
+from mautrix.types import EventID, RoomID
 from mautrix.util.async_db import Database
 
-fake_db = Database("") if TYPE_CHECKING else None
+fake_db = Database.create("") if TYPE_CHECKING else None
 
 
 @dataclass
@@ -34,8 +36,10 @@ class Message:
     sender: int
 
     async def insert(self) -> None:
-        q = ("INSERT INTO message (mxid, mx_room, item_id, receiver, sender) "
-             "VALUES ($1, $2, $3, $4, $5)")
+        q = (
+            "INSERT INTO message (mxid, mx_room, item_id, receiver, sender) "
+            "VALUES ($1, $2, $3, $4, $5)"
+        )
         await self.db.execute(q, self.mxid, self.mx_room, self.item_id, self.receiver, self.sender)
 
     async def delete(self) -> None:
@@ -47,18 +51,23 @@ class Message:
         await cls.db.execute("DELETE FROM message WHERE mx_room=$1", room_id)
 
     @classmethod
-    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Message']:
-        row = await cls.db.fetchrow("SELECT mxid, mx_room, item_id, receiver, sender "
-                                    "FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room)
+    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Message | None:
+        q = (
+            "SELECT mxid, mx_room, item_id, receiver, sender "
+            "FROM message WHERE mxid=$1 AND mx_room=$2"
+        )
+        row = await cls.db.fetchrow(q, mxid, mx_room)
         if not row:
             return None
         return cls(**row)
 
     @classmethod
-    async def get_by_item_id(cls, item_id: str, receiver: int) -> Optional['Message']:
-        row = await cls.db.fetchrow("SELECT mxid, mx_room, item_id, receiver, sender "
-                                    "FROM message WHERE item_id=$1 AND receiver=$2",
-                                    item_id, receiver)
+    async def get_by_item_id(cls, item_id: str, receiver: int) -> Message | None:
+        q = (
+            "SELECT mxid, mx_room, item_id, receiver, sender "
+            "FROM message WHERE item_id=$1 AND receiver=$2"
+        )
+        row = await cls.db.fetchrow(q, item_id, receiver)
         if not row:
             return None
         return cls(**row)

+ 85 - 46
mautrix_instagram/db/portal.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,15 +13,17 @@
 #
 # 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, List, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
 
 from attr import dataclass
 import asyncpg
 
-from mautrix.types import RoomID, ContentURI, UserID
+from mautrix.types import ContentURI, RoomID, UserID
 from mautrix.util.async_db import Database
 
-fake_db = Database("") if TYPE_CHECKING else None
+fake_db = Database.create("") if TYPE_CHECKING else None
 
 
 @dataclass
@@ -30,80 +32,117 @@ class Portal:
 
     thread_id: str
     receiver: int
-    other_user_pk: Optional[int]
-    mxid: Optional[RoomID]
-    name: Optional[str]
-    avatar_url: Optional[ContentURI]
+    other_user_pk: int | None
+    mxid: RoomID | None
+    name: str | None
+    avatar_url: ContentURI | None
     encrypted: bool
     name_set: bool
     avatar_set: bool
-    relay_user_id: Optional[UserID]
+    relay_user_id: UserID | None
 
     async def insert(self) -> None:
-        q = ("INSERT INTO portal (thread_id, receiver, other_user_pk, mxid, name, avatar_url, "
-             "                    encrypted, name_set, avatar_set, relay_user_id) "
-             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)")
-        await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
-                              self.mxid, self.name, self.avatar_url, self.encrypted,
-                              self.name_set, self.avatar_set, self.relay_user_id)
+        q = (
+            "INSERT INTO portal (thread_id, receiver, other_user_pk, mxid, name, avatar_url, "
+            "                    encrypted, name_set, avatar_set, relay_user_id) "
+            "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
+        )
+        await self.db.execute(
+            q,
+            self.thread_id,
+            self.receiver,
+            self.other_user_pk,
+            self.mxid,
+            self.name,
+            self.avatar_url,
+            self.encrypted,
+            self.name_set,
+            self.avatar_set,
+            self.relay_user_id,
+        )
 
     async def update(self) -> None:
-        q = ("UPDATE portal SET other_user_pk=$3, mxid=$4, name=$5, avatar_url=$6, encrypted=$7,"
-             "                  name_set=$8, avatar_set=$9, relay_user_id=$10 "
-             "WHERE thread_id=$1 AND receiver=$2")
-        await self.db.execute(q, self.thread_id, self.receiver, self.other_user_pk,
-                              self.mxid, self.name, self.avatar_url, self.encrypted,
-                              self.name_set, self.avatar_set, self.relay_user_id)
+        q = (
+            "UPDATE portal SET other_user_pk=$3, mxid=$4, name=$5, avatar_url=$6, encrypted=$7,"
+            "                  name_set=$8, avatar_set=$9, relay_user_id=$10 "
+            "WHERE thread_id=$1 AND receiver=$2"
+        )
+        await self.db.execute(
+            q,
+            self.thread_id,
+            self.receiver,
+            self.other_user_pk,
+            self.mxid,
+            self.name,
+            self.avatar_url,
+            self.encrypted,
+            self.name_set,
+            self.avatar_set,
+            self.relay_user_id,
+        )
 
     @classmethod
-    def _from_row(cls, row: asyncpg.Record) -> 'Portal':
+    def _from_row(cls, row: asyncpg.Record) -> Portal:
         return cls(**row)
 
     @classmethod
-    async def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
-        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set, relay_user_id "
-             "FROM portal WHERE mxid=$1")
+    async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
+        q = (
+            "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+            "       name_set, avatar_set, relay_user_id "
+            "FROM portal WHERE mxid=$1"
+        )
         row = await cls.db.fetchrow(q, mxid)
         if not row:
             return None
         return cls._from_row(row)
 
     @classmethod
-    async def get_by_thread_id(cls, thread_id: str, receiver: int,
-                               rec_must_match: bool = True) -> Optional['Portal']:
-        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set, relay_user_id "
-             "FROM portal WHERE thread_id=$1 AND receiver=$2")
+    async def get_by_thread_id(
+        cls, thread_id: str, receiver: int, rec_must_match: bool = True
+    ) -> Portal | None:
+        q = (
+            "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+            "       name_set, avatar_set, relay_user_id "
+            "FROM portal WHERE thread_id=$1 AND receiver=$2"
+        )
         if not rec_must_match:
-            q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-                 "       name_set, avatar_set "
-                 "FROM portal WHERE thread_id=$1 AND (receiver=$2 OR receiver=0)")
+            q = (
+                "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+                "       name_set, avatar_set "
+                "FROM portal WHERE thread_id=$1 AND (receiver=$2 OR receiver=0)"
+            )
         row = await cls.db.fetchrow(q, thread_id, receiver)
         if not row:
             return None
         return cls._from_row(row)
 
     @classmethod
-    async def find_private_chats_of(cls, receiver: int) -> List['Portal']:
-        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set, relay_user_id "
-             "FROM portal WHERE receiver=$1 AND other_user_pk IS NOT NULL")
+    async def find_private_chats_of(cls, receiver: int) -> list[Portal]:
+        q = (
+            "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+            "       name_set, avatar_set, relay_user_id "
+            "FROM portal WHERE receiver=$1 AND other_user_pk IS NOT NULL"
+        )
         rows = await cls.db.fetch(q, receiver)
         return [cls._from_row(row) for row in rows]
 
     @classmethod
-    async def find_private_chats_with(cls, other_user: int) -> List['Portal']:
-        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set, relay_user_id "
-             "FROM portal WHERE other_user_pk=$1")
+    async def find_private_chats_with(cls, other_user: int) -> list[Portal]:
+        q = (
+            "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+            "       name_set, avatar_set, relay_user_id "
+            "FROM portal WHERE other_user_pk=$1"
+        )
         rows = await cls.db.fetch(q, other_user)
         return [cls._from_row(row) for row in rows]
 
     @classmethod
-    async def all_with_room(cls) -> List['Portal']:
-        q = ("SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
-             "       name_set, avatar_set, relay_user_id "
-             "FROM portal WHERE mxid IS NOT NULL")
+    async def all_with_room(cls) -> list[Portal]:
+        q = (
+            "SELECT thread_id, receiver, other_user_pk, mxid, name, avatar_url, encrypted, "
+            "       name_set, avatar_set, relay_user_id "
+            "FROM portal WHERE mxid IS NOT NULL"
+        )
         rows = await cls.db.fetch(q)
         return [cls._from_row(row) for row in rows]

+ 64 - 39
mautrix_instagram/db/puppet.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,16 +13,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, ClassVar, List, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
 
 from attr import dataclass
 from yarl import URL
 import asyncpg
 
-from mautrix.types import UserID, SyncToken, ContentURI
+from mautrix.types import ContentURI, SyncToken, UserID
 from mautrix.util.async_db import Database
 
-fake_db = Database("") if TYPE_CHECKING else None
+fake_db = Database.create("") if TYPE_CHECKING else None
 
 
 @dataclass
@@ -30,72 +32,95 @@ class Puppet:
     db: ClassVar[Database] = fake_db
 
     pk: int
-    name: Optional[str]
-    username: Optional[str]
-    photo_id: Optional[str]
-    photo_mxc: Optional[ContentURI]
+    name: str | None
+    username: str | None
+    photo_id: str | None
+    photo_mxc: ContentURI | None
     name_set: bool
     avatar_set: bool
 
     is_registered: bool
 
-    custom_mxid: Optional[UserID]
-    access_token: Optional[str]
-    next_batch: Optional[SyncToken]
-    base_url: Optional[URL]
+    custom_mxid: UserID | None
+    access_token: str | None
+    next_batch: SyncToken | None
+    base_url: URL | None
 
     @property
-    def _base_url_str(self) -> Optional[str]:
+    def _base_url_str(self) -> str | None:
         return str(self.base_url) if self.base_url else None
 
+    @property
+    def _fields(self):
+        return (
+            self.pk,
+            self.name,
+            self.username,
+            self.photo_id,
+            self.photo_mxc,
+            self.name_set,
+            self.avatar_set,
+            self.is_registered,
+            self.custom_mxid,
+            self.access_token,
+            self.next_batch,
+            self._base_url_str,
+        )
+
     async def insert(self) -> None:
-        q = ("INSERT INTO puppet (pk, name, username, photo_id, photo_mxc, name_set, avatar_set,"
-             "                    is_registered, custom_mxid, access_token, next_batch, base_url) "
-             "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)")
-        await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
-                              self.name_set, self.avatar_set, self.is_registered, self.custom_mxid,
-                              self.access_token, self.next_batch, self._base_url_str)
+        q = (
+            "INSERT INTO puppet (pk, name, username, photo_id, photo_mxc, name_set, avatar_set,"
+            "                    is_registered, custom_mxid, access_token, next_batch, base_url) "
+            "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)"
+        )
+        await self.db.execute(q, *self._fields)
 
     async def update(self) -> None:
-        q = ("UPDATE puppet SET name=$2, username=$3, photo_id=$4, photo_mxc=$5, name_set=$6,"
-             "                  avatar_set=$7, is_registered=$8, custom_mxid=$9, access_token=$10,"
-             "                  next_batch=$11, base_url=$12 "
-             "WHERE pk=$1")
-        await self.db.execute(q, self.pk, self.name, self.username, self.photo_id, self.photo_mxc,
-                              self.name_set, self.avatar_set, self.is_registered, self.custom_mxid,
-                              self.access_token, self.next_batch, self._base_url_str)
+        q = (
+            "UPDATE puppet SET name=$2, username=$3, photo_id=$4, photo_mxc=$5, name_set=$6,"
+            "                  avatar_set=$7, is_registered=$8, custom_mxid=$9, access_token=$10,"
+            "                  next_batch=$11, base_url=$12 "
+            "WHERE pk=$1"
+        )
+        await self.db.execute(q, *self._fields)
 
     @classmethod
-    def _from_row(cls, row: asyncpg.Record) -> 'Puppet':
+    def _from_row(cls, row: asyncpg.Record) -> Puppet:
         data = {**row}
         base_url_str = data.pop("base_url")
         base_url = URL(base_url_str) if base_url_str is not None else None
         return cls(base_url=base_url, **data)
 
     @classmethod
-    async def get_by_pk(cls, pk: int) -> Optional['Puppet']:
-        q = ("SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
-             "       custom_mxid, access_token, next_batch, base_url "
-             "FROM puppet WHERE pk=$1")
+    async def get_by_pk(cls, pk: int) -> Puppet | None:
+        q = (
+            "SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
+            "       custom_mxid, access_token, next_batch, base_url "
+            "FROM puppet WHERE pk=$1"
+        )
         row = await cls.db.fetchrow(q, pk)
         if not row:
             return None
         return cls._from_row(row)
 
     @classmethod
-    async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
-        q = ("SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
-             "       custom_mxid, access_token, next_batch, base_url "
-             "FROM puppet WHERE custom_mxid=$1")
+    async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
+        q = (
+            "SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
+            "       custom_mxid, access_token, next_batch, base_url "
+            "FROM puppet WHERE custom_mxid=$1"
+        )
         row = await cls.db.fetchrow(q, mxid)
         if not row:
             return None
         return cls._from_row(row)
 
     @classmethod
-    async def all_with_custom_mxid(cls) -> List['Puppet']:
-        q = ("SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
-             "       custom_mxid, access_token, next_batch, base_url "
-             "FROM puppet WHERE custom_mxid IS NOT NULL")
+    async def all_with_custom_mxid(cls) -> list[Puppet]:
+        q = (
+            "SELECT pk, name, username, photo_id, photo_mxc, name_set, avatar_set, is_registered,"
+            "       custom_mxid, access_token, next_batch, base_url "
+            "FROM puppet WHERE custom_mxid IS NOT NULL"
+        )
         rows = await cls.db.fetch(q)
         return [cls._from_row(row) for row in rows]

+ 45 - 21
mautrix_instagram/db/reaction.py

@@ -13,14 +13,16 @@
 #
 # 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, List, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
 
 from attr import dataclass
 
-from mautrix.types import RoomID, EventID
+from mautrix.types import EventID, RoomID
 from mautrix.util.async_db import Database
 
-fake_db = Database("") if TYPE_CHECKING else None
+fake_db = Database.create("") if TYPE_CHECKING else None
 
 
 @dataclass
@@ -35,35 +37,55 @@ class Reaction:
     reaction: str
 
     async def insert(self) -> None:
-        q = ("INSERT INTO reaction (mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction) "
-             "VALUES ($1, $2, $3, $4, $5, $6)")
-        await self.db.execute(q, self.mxid, self.mx_room, self.ig_item_id, self.ig_receiver,
-                              self.ig_sender, self.reaction)
+        q = (
+            "INSERT INTO reaction (mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction) "
+            "VALUES ($1, $2, $3, $4, $5, $6)"
+        )
+        await self.db.execute(
+            q,
+            self.mxid,
+            self.mx_room,
+            self.ig_item_id,
+            self.ig_receiver,
+            self.ig_sender,
+            self.reaction,
+        )
 
     async def edit(self, mx_room: RoomID, mxid: EventID, reaction: str) -> None:
-        await self.db.execute("UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$3 "
-                              "WHERE ig_item_id=$4 AND ig_receiver=$5 AND ig_sender=$6",
-                              mxid, mx_room, reaction, self.ig_item_id, self.ig_receiver,
-                              self.ig_sender)
+        q = (
+            "UPDATE reaction SET mxid=$1, mx_room=$2, reaction=$3 "
+            "WHERE ig_item_id=$4 AND ig_receiver=$5 AND ig_sender=$6"
+        )
+        await self.db.execute(
+            mxid, mx_room, reaction, self.ig_item_id, self.ig_receiver, self.ig_sender
+        )
 
     async def delete(self) -> None:
         q = "DELETE FROM reaction WHERE ig_item_id=$1 AND ig_receiver=$2 AND ig_sender=$3"
         await self.db.execute(q, self.ig_item_id, self.ig_receiver, self.ig_sender)
 
     @classmethod
-    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Optional['Reaction']:
-        q = ("SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
-             "FROM reaction WHERE mxid=$1 AND mx_room=$2")
+    async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
+        q = (
+            "SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
+            "FROM reaction WHERE mxid=$1 AND mx_room=$2"
+        )
         row = await cls.db.fetchrow(q, mxid, mx_room)
         if not row:
             return None
         return cls(**row)
 
     @classmethod
-    async def get_by_item_id(cls, ig_item_id: str, ig_receiver: int, ig_sender: int,
-                             ) -> Optional['Reaction']:
-        q = ("SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
-             "FROM reaction WHERE ig_item_id=$1 AND ig_sender=$2 AND ig_receiver=$3")
+    async def get_by_item_id(
+        cls,
+        ig_item_id: str,
+        ig_receiver: int,
+        ig_sender: int,
+    ) -> Reaction | None:
+        q = (
+            "SELECT mxid, mx_room, ig_item_id, ig_receiver, ig_sender, reaction "
+            "FROM reaction WHERE ig_item_id=$1 AND ig_sender=$2 AND ig_receiver=$3"
+        )
         row = await cls.db.fetchrow(q, ig_item_id, ig_sender, ig_receiver)
         if not row:
             return None
@@ -75,8 +97,10 @@ class Reaction:
         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")
+    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]

+ 24 - 12
mautrix_instagram/db/upgrade.py

@@ -22,7 +22,8 @@ upgrade_table = UpgradeTable()
 
 @upgrade_table.register(description="Initial revision")
 async def upgrade_v1(conn: Connection) -> None:
-    await conn.execute("""CREATE TABLE portal (
+    await conn.execute(
+        """CREATE TABLE portal (
         thread_id     TEXT,
         receiver      BIGINT,
         other_user_pk BIGINT,
@@ -30,14 +31,18 @@ async def upgrade_v1(conn: Connection) -> None:
         name          TEXT,
         encrypted     BOOLEAN NOT NULL DEFAULT false,
         PRIMARY KEY (thread_id, receiver)
-    )""")
-    await conn.execute("""CREATE TABLE "user" (
+    )"""
+    )
+    await conn.execute(
+        """CREATE TABLE "user" (
         mxid        TEXT PRIMARY KEY,
         igpk        BIGINT,
         state       jsonb,
         notice_room TEXT
-    )""")
-    await conn.execute("""CREATE TABLE puppet (
+    )"""
+    )
+    await conn.execute(
+        """CREATE TABLE puppet (
         pk            BIGINT PRIMARY KEY,
         name          TEXT,
         username      TEXT,
@@ -50,16 +55,20 @@ async def upgrade_v1(conn: Connection) -> None:
         access_token  TEXT,
         next_batch    TEXT,
         base_url      TEXT
-    )""")
-    await conn.execute("""CREATE TABLE user_portal (
+    )"""
+    )
+    await conn.execute(
+        """CREATE TABLE user_portal (
         "user"          BIGINT,
         portal          TEXT,
         portal_receiver BIGINT,
         in_community    BOOLEAN NOT NULL DEFAULT false,
         FOREIGN KEY (portal, portal_receiver) REFERENCES portal(thread_id, receiver)
             ON UPDATE CASCADE ON DELETE CASCADE
-    )""")
-    await conn.execute("""CREATE TABLE message (
+    )"""
+    )
+    await conn.execute(
+        """CREATE TABLE message (
         mxid     TEXT NOT NULL,
         mx_room  TEXT NOT NULL,
         item_id  TEXT,
@@ -67,8 +76,10 @@ async def upgrade_v1(conn: Connection) -> None:
         sender   BIGINT NOT NULL,
         PRIMARY KEY (item_id, receiver),
         UNIQUE (mxid, mx_room)
-    )""")
-    await conn.execute("""CREATE TABLE reaction (
+    )"""
+    )
+    await conn.execute(
+        """CREATE TABLE reaction (
         mxid        TEXT NOT NULL,
         mx_room     TEXT NOT NULL,
         ig_item_id  TEXT,
@@ -79,7 +90,8 @@ async def upgrade_v1(conn: Connection) -> None:
         FOREIGN KEY (ig_item_id, ig_receiver) REFERENCES message(item_id, receiver)
             ON DELETE CASCADE ON UPDATE CASCADE,
         UNIQUE (mxid, mx_room)
-    )""")
+    )"""
+    )
 
 
 @upgrade_table.register(description="Add name_set and avatar_set to portal table")

+ 24 - 19
mautrix_instagram/db/user.py

@@ -13,16 +13,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, ClassVar, List, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, ClassVar
 
 from attr import dataclass
 import asyncpg
 
 from mauigpapi.state import AndroidState
-from mautrix.types import UserID, RoomID
+from mautrix.types import RoomID, UserID
 from mautrix.util.async_db import Database
 
-fake_db = Database("") if TYPE_CHECKING else None
+fake_db = Database.create("") if TYPE_CHECKING else None
 
 
 @dataclass
@@ -30,29 +32,30 @@ class User:
     db: ClassVar[Database] = fake_db
 
     mxid: UserID
-    igpk: Optional[int]
-    state: Optional[AndroidState]
-    notice_room: Optional[RoomID]
+    igpk: int | None
+    state: AndroidState | None
+    notice_room: RoomID | None
 
     async def insert(self) -> None:
-        q = ('INSERT INTO "user" (mxid, igpk, state, notice_room) '
-             'VALUES ($1, $2, $3, $4)')
-        await self.db.execute(q, self.mxid, self.igpk,
-                              self.state.json() if self.state else None, self.notice_room)
+        q = 'INSERT INTO "user" (mxid, igpk, state, notice_room) VALUES ($1, $2, $3, $4)'
+        await self.db.execute(
+            q, self.mxid, self.igpk, self.state.json() if self.state else None, self.notice_room
+        )
 
     async def update(self) -> None:
-        await self.db.execute('UPDATE "user" SET igpk=$2, state=$3, notice_room=$4 '
-                              'WHERE mxid=$1', self.mxid, self.igpk,
-                              self.state.json() if self.state else None, self.notice_room)
+        q = 'UPDATE "user" SET igpk=$2, state=$3, notice_room=$4 WHERE mxid=$1'
+        await self.db.execute(
+            q, self.mxid, self.igpk, self.state.json() if self.state else None, self.notice_room
+        )
 
     @classmethod
-    def _from_row(cls, row: asyncpg.Record) -> 'User':
+    def _from_row(cls, row: asyncpg.Record) -> User:
         data = {**row}
         state_str = data.pop("state")
         return cls(state=AndroidState.parse_json(state_str) if state_str else None, **data)
 
     @classmethod
-    async def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
+    async def get_by_mxid(cls, mxid: UserID) -> User | None:
         q = 'SELECT mxid, igpk, state, notice_room FROM "user" WHERE mxid=$1'
         row = await cls.db.fetchrow(q, mxid)
         if not row:
@@ -60,7 +63,7 @@ class User:
         return cls._from_row(row)
 
     @classmethod
-    async def get_by_igpk(cls, igpk: int) -> Optional['User']:
+    async def get_by_igpk(cls, igpk: int) -> User | None:
         q = 'SELECT mxid, igpk, state, notice_room FROM "user" WHERE igpk=$1'
         row = await cls.db.fetchrow(q, igpk)
         if not row:
@@ -68,8 +71,10 @@ class User:
         return cls._from_row(row)
 
     @classmethod
-    async def all_logged_in(cls) -> List['User']:
-        q = ("SELECT mxid, igpk, state, notice_room "
-             'FROM "user" WHERE igpk IS NOT NULL AND state IS NOT NULL')
+    async def all_logged_in(cls) -> list[User]:
+        q = (
+            "SELECT mxid, igpk, state, notice_room "
+            'FROM "user" WHERE igpk IS NOT NULL AND state IS NOT NULL'
+        )
         rows = await cls.db.fetch(q)
         return [cls._from_row(row) for row in rows]

+ 3 - 4
mautrix_instagram/get_version.py

@@ -1,6 +1,6 @@
-import subprocess
-import shutil
 import os
+import shutil
+import subprocess
 
 from . import __version__
 
@@ -34,8 +34,7 @@ else:
     git_revision_url = None
     git_tag = None
 
-git_tag_url = (f"https://github.com/mautrix/instagram/releases/tag/{git_tag}"
-               if git_tag else None)
+git_tag_url = f"https://github.com/mautrix/instagram/releases/tag/{git_tag}" if git_tag else None
 
 if git_tag and __version__ == git_tag[1:].replace("-", ""):
     version = __version__

+ 50 - 24
mautrix_instagram/matrix.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,23 +13,37 @@
 #
 # 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, Union, TYPE_CHECKING
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
 
 from mautrix.bridge import BaseMatrixHandler
-from mautrix.types import (Event, ReactionEvent, RoomID, EventID, UserID, ReactionEventContent,
-                           RelationType, EventType, ReceiptEvent, TypingEvent, PresenceEvent,
-                           RedactionEvent, SingleReceiptEventContent)
+from mautrix.types import (
+    Event,
+    EventID,
+    EventType,
+    PresenceEvent,
+    ReactionEvent,
+    ReactionEventContent,
+    ReceiptEvent,
+    RedactionEvent,
+    RelationType,
+    RoomID,
+    SingleReceiptEventContent,
+    TypingEvent,
+    UserID,
+)
 from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
 
-from .db import Message as DBMessage
 from . import portal as po, user as u
+from .db import Message as DBMessage
 
 if TYPE_CHECKING:
     from .__main__ import InstagramBridge
 
 
 class MatrixHandler(BaseMatrixHandler):
-    def __init__(self, bridge: 'InstagramBridge') -> None:
+    def __init__(self, bridge: "InstagramBridge") -> None:
         prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
         homeserver = bridge.config["homeserver.domain"]
         self.user_id_prefix = f"@{prefix}"
@@ -37,13 +51,14 @@ class MatrixHandler(BaseMatrixHandler):
 
         super().__init__(bridge=bridge)
 
-    async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
+    async def send_welcome_message(self, room_id: RoomID, inviter: u.User) -> None:
         await super().send_welcome_message(room_id, inviter)
         if not inviter.notice_room:
             inviter.notice_room = room_id
             await inviter.update()
-            await self.az.intent.send_notice(room_id, "This room has been marked as your "
-                                                      "Instagram bridge notice room.")
+            await self.az.intent.send_notice(
+                room_id, "This room has been marked as your Instagram bridge notice room."
+            )
 
     async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
         portal = await po.Portal.get_by_mxid(room_id)
@@ -57,8 +72,9 @@ class MatrixHandler(BaseMatrixHandler):
         await portal.handle_matrix_leave(user)
 
     @staticmethod
-    async def handle_redaction(room_id: RoomID, user_id: UserID, event_id: EventID,
-                               redaction_event_id: EventID) -> None:
+    async def handle_redaction(
+        room_id: RoomID, user_id: UserID, event_id: EventID, redaction_event_id: EventID
+    ) -> None:
         user = await u.User.get_by_mxid(user_id)
         if not user:
             return
@@ -77,11 +93,14 @@ class MatrixHandler(BaseMatrixHandler):
         await portal.handle_matrix_redaction(user, event_id, redaction_event_id)
 
     @classmethod
-    async def handle_reaction(cls, room_id: RoomID, user_id: UserID, event_id: EventID,
-                              content: ReactionEventContent) -> None:
+    async def handle_reaction(
+        cls, room_id: RoomID, user_id: UserID, event_id: EventID, content: ReactionEventContent
+    ) -> None:
         if content.relates_to.rel_type != RelationType.ANNOTATION:
-            cls.log.debug(f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected "
-                          f"relation type {content.relates_to.rel_type}")
+            cls.log.debug(
+                f"Ignoring m.reaction event in {room_id} from {user_id} with unexpected "
+                f"relation type {content.relates_to.rel_type}"
+            )
             return
         user = await u.User.get_by_mxid(user_id)
         if not user:
@@ -91,11 +110,17 @@ class MatrixHandler(BaseMatrixHandler):
         if not portal:
             return
 
-        await portal.handle_matrix_reaction(user, event_id, content.relates_to.event_id,
-                                            content.relates_to.key)
-
-    async def handle_read_receipt(self, user: 'u.User', portal: 'po.Portal', event_id: EventID,
-                                  data: SingleReceiptEventContent) -> None:
+        await portal.handle_matrix_reaction(
+            user, event_id, content.relates_to.event_id, content.relates_to.key
+        )
+
+    async def handle_read_receipt(
+        self,
+        user: u.User,
+        portal: po.Portal,
+        event_id: EventID,
+        data: SingleReceiptEventContent,
+    ) -> None:
         message = await DBMessage.get_by_mxid(event_id, portal.mxid)
         if not message or message.is_internal:
             return
@@ -103,7 +128,7 @@ class MatrixHandler(BaseMatrixHandler):
         await user.mqtt.mark_seen(portal.thread_id, message.item_id)
 
     @staticmethod
-    async def handle_typing(room_id: RoomID, typing: List[UserID]) -> None:
+    async def handle_typing(room_id: RoomID, typing: list[UserID]) -> None:
         portal = await po.Portal.get_by_mxid(room_id)
         if not portal:
             return
@@ -117,8 +142,9 @@ class MatrixHandler(BaseMatrixHandler):
             evt: ReactionEvent
             await self.handle_reaction(evt.room_id, evt.sender, evt.event_id, evt.content)
 
-    async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
-                                     ) -> None:
+    async def handle_ephemeral_event(
+        self, evt: ReceiptEvent | PresenceEvent | TypingEvent
+    ) -> None:
         if evt.type == EventType.TYPING:
             await self.handle_typing(evt.room_id, evt.content.user_ids)
         else:

File diff suppressed because it is too large
+ 394 - 201
mautrix_instagram/portal.py


+ 80 - 42
mautrix_instagram/puppet.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,28 +13,30 @@
 #
 # 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, AsyncIterable, Awaitable, AsyncGenerator, TYPE_CHECKING, cast
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
 import os.path
 
 from yarl import URL
 
 from mauigpapi.types import BaseResponseUser
-from mautrix.bridge import BasePuppet, async_getter_lock
 from mautrix.appservice import IntentAPI
-from mautrix.types import ContentURI, UserID, SyncToken, RoomID
+from mautrix.bridge import BasePuppet, async_getter_lock
+from mautrix.types import ContentURI, RoomID, SyncToken, UserID
 from mautrix.util.simple_template import SimpleTemplate
 
-from .db import Puppet as DBPuppet
-from .config import Config
 from . import portal as p, user as u
+from .config import Config
+from .db import Puppet as DBPuppet
 
 if TYPE_CHECKING:
     from .__main__ import InstagramBridge
 
 
 class Puppet(DBPuppet, BasePuppet):
-    by_pk: Dict[int, 'Puppet'] = {}
-    by_custom_mxid: Dict[UserID, 'Puppet'] = {}
+    by_pk: dict[int, Puppet] = {}
+    by_custom_mxid: dict[UserID, Puppet] = {}
     hs_domain: str
     mxid_template: SimpleTemplate[int]
 
@@ -43,15 +45,35 @@ class Puppet(DBPuppet, BasePuppet):
     default_mxid_intent: IntentAPI
     default_mxid: UserID
 
-    def __init__(self, pk: int, name: Optional[str] = None, username: Optional[str] = None,
-                 photo_id: Optional[str] = None, photo_mxc: Optional[ContentURI] = None,
-                 name_set: bool = False, avatar_set: bool = False, is_registered: bool = False,
-                 custom_mxid: Optional[UserID] = None, access_token: Optional[str] = None,
-                 next_batch: Optional[SyncToken] = None, base_url: Optional[URL] = None) -> None:
-        super().__init__(pk=pk, name=name, username=username, photo_id=photo_id, name_set=name_set,
-                         photo_mxc=photo_mxc, avatar_set=avatar_set, is_registered=is_registered,
-                         custom_mxid=custom_mxid, access_token=access_token, next_batch=next_batch,
-                         base_url=base_url)
+    def __init__(
+        self,
+        pk: int,
+        name: str | None = None,
+        username: str | None = None,
+        photo_id: str | None = None,
+        photo_mxc: ContentURI | None = None,
+        name_set: bool = False,
+        avatar_set: bool = False,
+        is_registered: bool = False,
+        custom_mxid: UserID | None = None,
+        access_token: str | None = None,
+        next_batch: SyncToken | None = None,
+        base_url: URL | None = None,
+    ) -> None:
+        super().__init__(
+            pk=pk,
+            name=name,
+            username=username,
+            photo_id=photo_id,
+            name_set=name_set,
+            photo_mxc=photo_mxc,
+            avatar_set=avatar_set,
+            is_registered=is_registered,
+            custom_mxid=custom_mxid,
+            access_token=access_token,
+            next_batch=next_batch,
+            base_url=base_url,
+        )
         self.log = self.log.getChild(str(pk))
 
         self.default_mxid = self.get_mxid_from_id(pk)
@@ -59,20 +81,29 @@ class Puppet(DBPuppet, BasePuppet):
         self.intent = self._fresh_intent()
 
     @classmethod
-    def init_cls(cls, bridge: 'InstagramBridge') -> AsyncIterable[Awaitable[None]]:
+    def init_cls(cls, bridge: "InstagramBridge") -> AsyncIterable[Awaitable[None]]:
         cls.config = bridge.config
         cls.loop = bridge.loop
         cls.mx = bridge.matrix
         cls.az = bridge.az
         cls.hs_domain = cls.config["homeserver.domain"]
-        cls.mxid_template = SimpleTemplate(cls.config["bridge.username_template"], "userid",
-                                           prefix="@", suffix=f":{cls.hs_domain}", type=int)
+        cls.mxid_template = SimpleTemplate(
+            cls.config["bridge.username_template"],
+            "userid",
+            prefix="@",
+            suffix=f":{cls.hs_domain}",
+            type=int,
+        )
         cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
-        cls.homeserver_url_map = {server: URL(url) for server, url
-                                  in cls.config["bridge.double_puppet_server_map"].items()}
+        cls.homeserver_url_map = {
+            server: URL(url)
+            for server, url in cls.config["bridge.double_puppet_server_map"].items()
+        }
         cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
-        cls.login_shared_secret_map = {server: secret.encode("utf-8") for server, secret
-                                       in cls.config["bridge.login_shared_secret_map"].items()}
+        cls.login_shared_secret_map = {
+            server: secret.encode("utf-8")
+            for server, secret in cls.config["bridge.login_shared_secret_map"].items()
+        }
         cls.login_device_name = "Instagram Bridge"
         return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
 
@@ -80,16 +111,19 @@ class Puppet(DBPuppet, BasePuppet):
     def igpk(self) -> int:
         return self.pk
 
-    def intent_for(self, portal: 'p.Portal') -> IntentAPI:
+    def intent_for(self, portal: p.Portal) -> IntentAPI:
         if portal.other_user_pk == self.pk:
             return self.default_mxid_intent
         return self.intent
 
-    def need_backfill_invite(self, portal: 'p.Portal') -> bool:
-        return (portal.other_user_pk != self.pk and (self.is_real_user or portal.is_direct)
-                and self.config["bridge.backfill.invite_own_puppet"])
+    def need_backfill_invite(self, portal: p.Portal) -> bool:
+        return (
+            portal.other_user_pk != self.pk
+            and (self.is_real_user or portal.is_direct)
+            and self.config["bridge.backfill.invite_own_puppet"]
+        )
 
-    async def update_info(self, info: BaseResponseUser, source: 'u.User') -> None:
+    async def update_info(self, info: BaseResponseUser, source: u.User) -> None:
         update = False
         update = await self._update_name(info) or update
         update = await self._update_avatar(info, source) or update
@@ -98,8 +132,9 @@ class Puppet(DBPuppet, BasePuppet):
 
     @classmethod
     def _get_displayname(cls, info: BaseResponseUser) -> str:
-        return cls.config["bridge.displayname_template"].format(displayname=info.full_name or info.username,
-                                                                id=info.pk, username=info.username)
+        return cls.config["bridge.displayname_template"].format(
+            displayname=info.full_name or info.username, id=info.pk, username=info.username
+        )
 
     async def _update_name(self, info: BaseResponseUser) -> bool:
         name = self._get_displayname(info)
@@ -114,9 +149,12 @@ class Puppet(DBPuppet, BasePuppet):
             return True
         return False
 
-    async def _update_avatar(self, info: BaseResponseUser, source: 'u.User') -> bool:
-        pic_id = (f"id_{info.profile_pic_id}.jpg" if info.profile_pic_id
-                  else os.path.basename(URL(info.profile_pic_url).path))
+    async def _update_avatar(self, info: BaseResponseUser, source: u.User) -> bool:
+        pic_id = (
+            f"id_{info.profile_pic_id}.jpg"
+            if info.profile_pic_id
+            else os.path.basename(URL(info.profile_pic_url).path)
+        )
         if pic_id != self.photo_id or not self.avatar_set:
             self.photo_id = pic_id
             if info.has_anonymous_profile_picture:
@@ -125,9 +163,9 @@ class Puppet(DBPuppet, BasePuppet):
                 async with source.client.raw_http_get(info.profile_pic_url) as resp:
                     content_type = resp.headers["Content-Type"]
                     resp_data = await resp.read()
-                mxc = await self.default_mxid_intent.upload_media(data=resp_data,
-                                                                  mime_type=content_type,
-                                                                  filename=pic_id)
+                mxc = await self.default_mxid_intent.upload_media(
+                    data=resp_data, mime_type=content_type, filename=pic_id
+                )
             try:
                 await self.default_mxid_intent.set_avatar_url(mxc)
                 self.avatar_set = True
@@ -153,7 +191,7 @@ class Puppet(DBPuppet, BasePuppet):
         await self.update()
 
     @classmethod
-    async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
+    async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Puppet | None:
         pk = cls.get_id_from_mxid(mxid)
         if pk:
             return await cls.get_by_pk(pk, create=create)
@@ -161,7 +199,7 @@ class Puppet(DBPuppet, BasePuppet):
 
     @classmethod
     @async_getter_lock
-    async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
+    async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
         try:
             return cls.by_custom_mxid[mxid]
         except KeyError:
@@ -175,7 +213,7 @@ class Puppet(DBPuppet, BasePuppet):
         return None
 
     @classmethod
-    def get_id_from_mxid(cls, mxid: UserID) -> Optional[int]:
+    def get_id_from_mxid(cls, mxid: UserID) -> int | None:
         return cls.mxid_template.parse(mxid)
 
     @classmethod
@@ -184,7 +222,7 @@ class Puppet(DBPuppet, BasePuppet):
 
     @classmethod
     @async_getter_lock
-    async def get_by_pk(cls, pk: int, *, create: bool = True) -> Optional['Puppet']:
+    async def get_by_pk(cls, pk: int, *, create: bool = True) -> Puppet | None:
         try:
             return cls.by_pk[pk]
         except KeyError:
@@ -204,7 +242,7 @@ class Puppet(DBPuppet, BasePuppet):
         return None
 
     @classmethod
-    async def all_with_custom_mxid(cls) -> AsyncGenerator['Puppet', None]:
+    async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
         puppets = await super().all_with_custom_mxid()
         puppet: cls
         for index, puppet in enumerate(puppets):

+ 122 - 73
mautrix_instagram/user.py

@@ -1,5 +1,5 @@
 # mautrix-instagram - A Matrix-Instagram puppeting bridge.
-# Copyright (C) 2020 Tulir Asokan
+# Copyright (C) 2022 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU Affero General Public License as published by
@@ -13,28 +13,42 @@
 #
 # 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 (Dict, Optional, AsyncIterable, Awaitable, AsyncGenerator, List, TYPE_CHECKING,
-                    cast)
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
 import asyncio
 import logging
 import time
 
-from mauigpapi import AndroidAPI, AndroidState, AndroidMQTT
+from mauigpapi import AndroidAPI, AndroidMQTT, AndroidState
+from mauigpapi.errors import (
+    IGNotLoggedInError,
+    IGUserIDNotFoundError,
+    IrisSubscribeError,
+    MQTTNotConnected,
+    MQTTNotLoggedIn,
+)
 from mauigpapi.mqtt import Connect, Disconnect, GraphQLSubscription, SkywalkerSubscription
-from mauigpapi.types import (CurrentUser, MessageSyncEvent, Operation, RealtimeDirectEvent,
-                             ActivityIndicatorData, TypingStatus, ThreadSyncEvent, Thread)
-from mauigpapi.errors import (IGNotLoggedInError, MQTTNotLoggedIn, MQTTNotConnected,
-                              IrisSubscribeError, IGUserIDNotFoundError)
-from mautrix.bridge import BaseUser, async_getter_lock
-from mautrix.types import UserID, RoomID, EventID, TextMessageEventContent, MessageType
+from mauigpapi.types import (
+    ActivityIndicatorData,
+    CurrentUser,
+    MessageSyncEvent,
+    Operation,
+    RealtimeDirectEvent,
+    Thread,
+    ThreadSyncEvent,
+    TypingStatus,
+)
 from mautrix.appservice import AppService
+from mautrix.bridge import BaseUser, async_getter_lock
+from mautrix.types import EventID, MessageType, RoomID, TextMessageEventContent, UserID
 from mautrix.util.bridge_state import BridgeState, BridgeStateEvent
 from mautrix.util.logging import TraceLogger
-from mautrix.util.opt_prometheus import Summary, Gauge, async_time
+from mautrix.util.opt_prometheus import Gauge, Summary, async_time
 
-from .db import User as DBUser, Portal as DBPortal
+from . import portal as po, puppet as pu
 from .config import Config
-from . import puppet as pu, portal as po
+from .db import Portal as DBPortal, User as DBUser
 
 if TYPE_CHECKING:
     from .__main__ import InstagramBridge
@@ -45,41 +59,47 @@ METRIC_RTD = Summary("bridge_on_rtd", "calls to handle_rtd")
 METRIC_LOGGED_IN = Gauge("bridge_logged_in", "Users logged into the bridge")
 METRIC_CONNECTED = Gauge("bridge_connected", "Bridged users connected to Instagram")
 
-BridgeState.human_readable_errors.update({
-    "ig-connection-error": "Instagram disconnected unexpectedly",
-    "ig-auth-error": "Authentication error from Instagram: {message}",
-    "ig-disconnected": None,
-    "ig-no-mqtt": "You're not connected to Instagram",
-    "logged-out": "You're not logged into Instagram",
-})
+BridgeState.human_readable_errors.update(
+    {
+        "ig-connection-error": "Instagram disconnected unexpectedly",
+        "ig-auth-error": "Authentication error from Instagram: {message}",
+        "ig-disconnected": None,
+        "ig-no-mqtt": "You're not connected to Instagram",
+        "logged-out": "You're not logged into Instagram",
+    }
+)
 
 
 class User(DBUser, BaseUser):
     ig_base_log: TraceLogger = logging.getLogger("mau.instagram")
-    _activity_indicator_ids: Dict[str, int] = {}
-    by_mxid: Dict[UserID, 'User'] = {}
-    by_igpk: Dict[int, 'User'] = {}
+    _activity_indicator_ids: dict[str, int] = {}
+    by_mxid: dict[UserID, User] = {}
+    by_igpk: dict[int, User] = {}
     config: Config
     az: AppService
     loop: asyncio.AbstractEventLoop
 
-    client: Optional[AndroidAPI]
-    mqtt: Optional[AndroidMQTT]
-    _listen_task: Optional[asyncio.Task] = None
+    client: AndroidAPI | None
+    mqtt: AndroidMQTT | None
+    _listen_task: asyncio.Task | None = None
 
     permission_level: str
-    username: Optional[str]
+    username: str | None
 
     _notice_room_lock: asyncio.Lock
     _notice_send_lock: asyncio.Lock
     _is_logged_in: bool
     _is_connected: bool
     shutdown: bool
-    remote_typing_status: Optional[TypingStatus]
-
-    def __init__(self, mxid: UserID, igpk: Optional[int] = None,
-                 state: Optional[AndroidState] = None, notice_room: Optional[RoomID] = None
-                 ) -> None:
+    remote_typing_status: TypingStatus | None
+
+    def __init__(
+        self,
+        mxid: UserID,
+        igpk: int | None = None,
+        state: AndroidState | None = None,
+        notice_room: RoomID | None = None,
+    ) -> None:
         super().__init__(mxid=mxid, igpk=igpk, state=state, notice_room=notice_room)
         BaseUser.__init__(self)
         self._notice_room_lock = asyncio.Lock()
@@ -97,7 +117,7 @@ class User(DBUser, BaseUser):
         self.remote_typing_status = None
 
     @classmethod
-    def init_cls(cls, bridge: 'InstagramBridge') -> AsyncIterable[Awaitable[None]]:
+    def init_cls(cls, bridge: "InstagramBridge") -> AsyncIterable[Awaitable[None]]:
         cls.bridge = bridge
         cls.config = bridge.config
         cls.az = bridge.az
@@ -109,7 +129,7 @@ class User(DBUser, BaseUser):
     async def is_logged_in(self) -> bool:
         return bool(self.client) and self._is_logged_in
 
-    async def get_puppet(self) -> Optional[pu.Puppet]:
+    async def get_puppet(self) -> pu.Puppet | None:
         if not self.igpk:
             return None
         return await pu.Puppet.get_by_pk(self.igpk)
@@ -135,9 +155,12 @@ class User(DBUser, BaseUser):
             resp = await client.current_user()
         except IGNotLoggedInError as e:
             self.log.warning(f"Failed to connect to Instagram: {e}, logging out")
-            await self.send_bridge_notice(f"You have been logged out of Instagram: {e!s}",
-                                          important=True, error_code="ig-auth-error",
-                                          error_message=str(e))
+            await self.send_bridge_notice(
+                f"You have been logged out of Instagram: {e!s}",
+                important=True,
+                error_code="ig-auth-error",
+                error_message=str(e),
+            )
             await self.logout(from_error=True)
             return
         self.client = client
@@ -147,8 +170,9 @@ class User(DBUser, BaseUser):
         self._track_metric(METRIC_LOGGED_IN, True)
         self.by_igpk[self.igpk] = self
 
-        self.mqtt = AndroidMQTT(self.state, loop=self.loop,
-                                log=self.ig_base_log.getChild("mqtt").getChild(self.mxid))
+        self.mqtt = AndroidMQTT(
+            self.state, loop=self.loop, log=self.ig_base_log.getChild("mqtt").getChild(self.mxid)
+        )
         self.mqtt.add_event_handler(Connect, self.on_connect)
         self.mqtt.add_event_handler(Disconnect, self.on_disconnect)
         self.mqtt.add_event_handler(MessageSyncEvent, self.handle_message)
@@ -205,7 +229,7 @@ class User(DBUser, BaseUser):
         if self.username:
             state.remote_name = f"@{self.username}"
 
-    async def get_bridge_states(self) -> List[BridgeState]:
+    async def get_bridge_states(self) -> list[BridgeState]:
         if not self.state:
             return []
         state = BridgeState(state_event=BridgeStateEvent.UNKNOWN_ERROR)
@@ -215,13 +239,19 @@ class User(DBUser, BaseUser):
             state.state_event = BridgeStateEvent.TRANSIENT_DISCONNECT
         return [state]
 
-    async def send_bridge_notice(self, text: str, edit: Optional[EventID] = None,
-                                 state_event: Optional[BridgeStateEvent] = None,
-                                 important: bool = False, error_code: Optional[str] = None,
-                                 error_message: Optional[str] = None) -> Optional[EventID]:
+    async def send_bridge_notice(
+        self,
+        text: str,
+        edit: EventID | None = None,
+        state_event: BridgeStateEvent | None = None,
+        important: bool = False,
+        error_code: str | None = None,
+        error_message: str | None = None,
+    ) -> EventID | None:
         if state_event:
-            await self.push_bridge_state(state_event, error=error_code,
-                                         message=error_message if error_code else text)
+            await self.push_bridge_state(
+                state_event, error=error_code, message=error_message if error_code else text
+            )
         if self.config["bridge.disable_bridge_notices"]:
             return None
         if not important and not self.config["bridge.unimportant_bridge_notices"]:
@@ -230,8 +260,9 @@ class User(DBUser, BaseUser):
         event_id = None
         try:
             self.log.debug("Sending bridge notice: %s", text)
-            content = TextMessageEventContent(body=text, msgtype=(MessageType.TEXT if important
-                                                                  else MessageType.NOTICE))
+            content = TextMessageEventContent(
+                body=text, msgtype=(MessageType.TEXT if important else MessageType.NOTICE)
+            )
             if edit:
                 content.set_edit(edit)
             # This is locked to prevent notices going out in the wrong order
@@ -261,7 +292,7 @@ class User(DBUser, BaseUser):
             self.log.exception("Exception while syncing")
             await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR)
 
-    async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]:
+    async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
         return {
             pu.Puppet.get_mxid_from_id(portal.other_user_pk): [portal.mxid]
             for portal in await DBPortal.find_private_chats_of(self.igpk)
@@ -324,8 +355,9 @@ class User(DBUser, BaseUser):
         if not self._listen_task:
             await self.start_listen(resp.seq_id, resp.snapshot_at_ms)
 
-    async def start_listen(self, seq_id: Optional[int] = None, snapshot_at_ms: Optional[int] = None
-                           ) -> None:
+    async def start_listen(
+        self, seq_id: int | None = None, snapshot_at_ms: int | None = None
+    ) -> None:
         self.shutdown = False
         if not seq_id:
             resp = await self.client.get_inbox(limit=1)
@@ -336,31 +368,45 @@ class User(DBUser, BaseUser):
     async def listen(self, seq_id: int, snapshot_at_ms: int) -> None:
         try:
             await self.mqtt.listen(
-                graphql_subs={GraphQLSubscription.app_presence(),
-                              GraphQLSubscription.direct_typing(self.state.user_id),
-                              GraphQLSubscription.direct_status()},
-                skywalker_subs={SkywalkerSubscription.direct_sub(self.state.user_id),
-                                SkywalkerSubscription.live_sub(self.state.user_id)},
-                seq_id=seq_id, snapshot_at_ms=snapshot_at_ms)
+                graphql_subs={
+                    GraphQLSubscription.app_presence(),
+                    GraphQLSubscription.direct_typing(self.state.user_id),
+                    GraphQLSubscription.direct_status(),
+                },
+                skywalker_subs={
+                    SkywalkerSubscription.direct_sub(self.state.user_id),
+                    SkywalkerSubscription.live_sub(self.state.user_id),
+                },
+                seq_id=seq_id,
+                snapshot_at_ms=snapshot_at_ms,
+            )
         except IrisSubscribeError as e:
             self.log.warning(f"Got IrisSubscribeError {e}, refreshing...")
             await self.refresh()
         except (MQTTNotConnected, MQTTNotLoggedIn) as e:
-            await self.send_bridge_notice(f"Error in listener: {e}", important=True,
-                                          state_event=BridgeStateEvent.UNKNOWN_ERROR,
-                                          error_code="ig-connection-error")
+            await self.send_bridge_notice(
+                f"Error in listener: {e}",
+                important=True,
+                state_event=BridgeStateEvent.UNKNOWN_ERROR,
+                error_code="ig-connection-error",
+            )
             self.mqtt.disconnect()
         except Exception:
             self.log.exception("Fatal error in listener")
-            await self.send_bridge_notice("Fatal error in listener (see logs for more info)",
-                                          state_event=BridgeStateEvent.UNKNOWN_ERROR,
-                                          important=True, error_code="ig-connection-error")
+            await self.send_bridge_notice(
+                "Fatal error in listener (see logs for more info)",
+                state_event=BridgeStateEvent.UNKNOWN_ERROR,
+                important=True,
+                error_code="ig-connection-error",
+            )
             self.mqtt.disconnect()
         else:
             if not self.shutdown:
-                await self.send_bridge_notice("Instagram connection closed without error",
-                                              state_event=BridgeStateEvent.UNKNOWN_ERROR,
-                                              error_code="ig-disconnected")
+                await self.send_bridge_notice(
+                    "Instagram connection closed without error",
+                    state_event=BridgeStateEvent.UNKNOWN_ERROR,
+                    error_code="ig-disconnected",
+                )
         finally:
             self._listen_task = None
             self._is_connected = False
@@ -418,8 +464,10 @@ class User(DBUser, BaseUser):
             self.log.debug("Got info for unknown portal, creating room")
             await portal.create_matrix_room(self, resp.thread)
             if not portal.mxid:
-                self.log.warning("Room creation appears to have failed, "
-                                 f"dropping message in {evt.message.thread_id}")
+                self.log.warning(
+                    "Room creation appears to have failed, "
+                    f"dropping message in {evt.message.thread_id}"
+                )
                 return
         self.log.trace(f"Received message sync event {evt.message}")
         sender = await pu.Puppet.get_by_pk(evt.message.user_id) if evt.message.user_id else None
@@ -464,8 +512,9 @@ class User(DBUser, BaseUser):
         is_typing = evt.value.activity_status != TypingStatus.OFF
         if puppet.pk == self.igpk:
             self.remote_typing_status = TypingStatus.TEXT if is_typing else TypingStatus.OFF
-        await puppet.intent_for(portal).set_typing(portal.mxid, is_typing=is_typing,
-                                                   timeout=evt.value.ttl)
+        await puppet.intent_for(portal).set_typing(
+            portal.mxid, is_typing=is_typing, timeout=evt.value.ttl
+        )
 
     # endregion
     # region Database getters
@@ -477,7 +526,7 @@ class User(DBUser, BaseUser):
 
     @classmethod
     @async_getter_lock
-    async def get_by_mxid(cls, mxid: UserID, *, create: bool = True) -> Optional['User']:
+    async def get_by_mxid(cls, mxid: UserID, *, create: bool = True) -> User | None:
         # Never allow ghosts to be users
         if pu.Puppet.get_id_from_mxid(mxid):
             return None
@@ -501,7 +550,7 @@ class User(DBUser, BaseUser):
 
     @classmethod
     @async_getter_lock
-    async def get_by_igpk(cls, igpk: int) -> Optional['User']:
+    async def get_by_igpk(cls, igpk: int) -> User | None:
         try:
             return cls.by_igpk[igpk]
         except KeyError:
@@ -515,7 +564,7 @@ class User(DBUser, BaseUser):
         return None
 
     @classmethod
-    async def all_logged_in(cls) -> AsyncGenerator['User', None]:
+    async def all_logged_in(cls) -> AsyncGenerator[User, None]:
         users = await super().all_logged_in()
         user: cls
         for index, user in enumerate(users):

+ 15 - 4
mautrix_instagram/util/color_log.py

@@ -13,8 +13,12 @@
 #
 # 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 mautrix.util.logging.color import (ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR,
-                                        RESET)
+from mautrix.util.logging.color import (
+    MXID_COLOR,
+    PREFIX,
+    RESET,
+    ColorFormatter as BaseColorFormatter,
+)
 
 MAUIGPAPI_COLOR = PREFIX + "35;1m"  # magenta
 
@@ -25,6 +29,13 @@ class ColorFormatter(BaseColorFormatter):
             return MAUIGPAPI_COLOR + module + RESET
         elif module.startswith("mau.instagram"):
             mau, instagram, subtype, user_id = module.split(".", 3)
-            return (MAUIGPAPI_COLOR + f"{mau}.{instagram}.{subtype}" + RESET
-                    + "." + MXID_COLOR + user_id + RESET)
+            return (
+                MAUIGPAPI_COLOR
+                + f"{mau}.{instagram}.{subtype}"
+                + RESET
+                + "."
+                + MXID_COLOR
+                + user_id
+                + RESET
+            )
         return super()._color_name(module)

+ 1 - 1
mautrix_instagram/version.py

@@ -1 +1 @@
-from .get_version import git_tag, git_revision, version, linkified_version
+from .get_version import git_revision, git_tag, linkified_version, version

+ 109 - 61
mautrix_instagram/web/provisioning_api.py

@@ -14,22 +14,28 @@
 # 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 Awaitable, Dict, Tuple
-import logging
 import asyncio
 import json
+import logging
 
 from aiohttp import web
 
-from mauigpapi import AndroidState, AndroidAPI
+from mauigpapi import AndroidAPI, AndroidState
+from mauigpapi.errors import (
+    IGBad2FACodeError,
+    IGChallengeWrongCodeError,
+    IGCheckpointError,
+    IGLoginBadPasswordError,
+    IGLoginInvalidUserError,
+    IGLoginTwoFactorRequiredError,
+    IGNotLoggedInError,
+)
 from mauigpapi.types import BaseResponseUser
-from mauigpapi.errors import (IGLoginTwoFactorRequiredError, IGLoginBadPasswordError,
-                              IGLoginInvalidUserError, IGBad2FACodeError, IGNotLoggedInError,
-                              IGCheckpointError, IGChallengeWrongCodeError)
-from mautrix.types import UserID, JSON
+from mautrix.types import JSON, UserID
 from mautrix.util.logging import TraceLogger
 
-from ..commands.auth import get_login_state
 from .. import user as u
+from ..commands.auth import get_login_state
 
 
 class ProvisioningAPI:
@@ -66,29 +72,33 @@ class ProvisioningAPI:
         }
 
     def _missing_key_error(self, err: KeyError) -> None:
-        raise web.HTTPBadRequest(text=json.dumps({"error": f"Missing key {err}"}),
-                                 headers=self._headers)
+        raise web.HTTPBadRequest(
+            text=json.dumps({"error": f"Missing key {err}"}), headers=self._headers
+        )
 
     async def login_options(self, _: web.Request) -> web.Response:
         return web.Response(status=200, headers=self._headers)
 
-    def check_token(self, request: web.Request) -> Awaitable['u.User']:
+    def check_token(self, request: web.Request) -> Awaitable["u.User"]:
         try:
             token = request.headers["Authorization"]
-            token = token[len("Bearer "):]
+            token = token[len("Bearer ") :]
         except KeyError:
-            raise web.HTTPBadRequest(text='{"error": "Missing Authorization header"}',
-                                     headers=self._headers)
+            raise web.HTTPBadRequest(
+                text='{"error": "Missing Authorization header"}', headers=self._headers
+            )
         except IndexError:
-            raise web.HTTPBadRequest(text='{"error": "Malformed Authorization header"}',
-                                     headers=self._headers)
+            raise web.HTTPBadRequest(
+                text='{"error": "Malformed Authorization header"}', headers=self._headers
+            )
         if token != self.shared_secret:
             raise web.HTTPForbidden(text='{"error": "Invalid token"}', headers=self._headers)
         try:
             user_id = request.query["user_id"]
         except KeyError:
-            raise web.HTTPBadRequest(text='{"error": "Missing user_id query param"}',
-                                     headers=self._headers)
+            raise web.HTTPBadRequest(
+                text='{"error": "Missing user_id query param"}', headers=self._headers
+            )
 
         return u.User.get_by_mxid(UserID(user_id))
 
@@ -104,11 +114,17 @@ class ProvisioningAPI:
                 resp = await user.client.current_user()
             except IGNotLoggedInError as e:
                 # TODO maybe don't always log out?
-                self.log.exception(f"Got error checking current user for %s, logging out. %s",
-                                   user.mxid, e.body.json())
-                await user.send_bridge_notice(f"You have been logged out of Instagram: {e!s}",
-                                              important=True, error_code="ig-auth-error",
-                                              error_message=str(e))
+                self.log.exception(
+                    f"Got error checking current user for %s, logging out. %s",
+                    user.mxid,
+                    e.body.json(),
+                )
+                await user.send_bridge_notice(
+                    f"You have been logged out of Instagram: {e!s}",
+                    important=True,
+                    error_code="ig-auth-error",
+                    error_message=str(e),
+                )
                 await user.logout(from_error=True)
             else:
                 data["instagram"] = resp.user.serialize()
@@ -131,10 +147,14 @@ class ProvisioningAPI:
         try:
             resp = await api.login(username, password)
         except IGLoginTwoFactorRequiredError as e:
-            return web.json_response(data={
-                "status": "two-factor",
-                "response": e.body.serialize(),
-            }, status=202, headers=self._acao_headers)
+            return web.json_response(
+                data={
+                    "status": "two-factor",
+                    "response": e.body.serialize(),
+                },
+                status=202,
+                headers=self._acao_headers,
+            )
         except IGCheckpointError as e:
             try:
                 await api.challenge_auto(reset=True)
@@ -147,26 +167,36 @@ class ProvisioningAPI:
                     status=403,
                     headers=self._acao_headers,
                 )
-            return web.json_response(data={
-                "status": "checkpoint",
-                "response": e.body.serialize(),
-            }, status=202, headers=self._acao_headers)
+            return web.json_response(
+                data={
+                    "status": "checkpoint",
+                    "response": e.body.serialize(),
+                },
+                status=202,
+                headers=self._acao_headers,
+            )
         except IGLoginInvalidUserError:
-            return web.json_response(data={"error": "Invalid username",
-                                           "status": "invalid-username"},
-                                     status=404, headers=self._acao_headers)
+            return web.json_response(
+                data={"error": "Invalid username", "status": "invalid-username"},
+                status=404,
+                headers=self._acao_headers,
+            )
         except IGLoginBadPasswordError:
-            return web.json_response(data={"error": "Incorrect password",
-                                           "status": "incorrect-password"},
-                                     status=403, headers=self._acao_headers)
+            return web.json_response(
+                data={"error": "Incorrect password", "status": "incorrect-password"},
+                status=403,
+                headers=self._acao_headers,
+            )
         return await self._finish_login(user, state, resp.logged_in_user)
 
-    async def _get_user(self, request: web.Request, check_state: bool = False
-                        ) -> Tuple['u.User', JSON]:
+    async def _get_user(
+        self, request: web.Request, check_state: bool = False
+    ) -> Tuple["u.User", JSON]:
         user = await self.check_token(request)
         if check_state and (not user.command_status or user.command_status["action"] != "Login"):
-            raise web.HTTPNotFound(text='{"error": "No 2-factor login in progress"}',
-                                   headers=self._headers)
+            raise web.HTTPNotFound(
+                text='{"error": "No 2-factor login in progress"}', headers=self._headers
+            )
 
         try:
             data = await request.json()
@@ -188,13 +218,18 @@ class ProvisioningAPI:
         api: AndroidAPI = user.command_status["api"]
         state: AndroidState = user.command_status["state"]
         try:
-            resp = await api.two_factor_login(username, code=code, identifier=identifier,
-                                              is_totp=is_totp)
+            resp = await api.two_factor_login(
+                username, code=code, identifier=identifier, is_totp=is_totp
+            )
         except IGBad2FACodeError:
-            return web.json_response(data={
-                "error": "Incorrect 2-factor authentication code",
-                "status": "incorrect-2fa-code",
-            }, status=403, headers=self._acao_headers)
+            return web.json_response(
+                data={
+                    "error": "Incorrect 2-factor authentication code",
+                    "status": "incorrect-2fa-code",
+                },
+                status=403,
+                headers=self._acao_headers,
+            )
         except IGCheckpointError as e:
             try:
                 await api.challenge_auto(reset=True)
@@ -207,10 +242,14 @@ class ProvisioningAPI:
                     status=403,
                     headers=self._acao_headers,
                 )
-            return web.json_response(data={
-                "status": "checkpoint",
-                "response": e.body.serialize(),
-            }, status=202, headers=self._acao_headers)
+            return web.json_response(
+                data={
+                    "status": "checkpoint",
+                    "response": e.body.serialize(),
+                },
+                status=202,
+                headers=self._acao_headers,
+            )
         return await self._finish_login(user, state, resp.logged_in_user)
 
     async def login_checkpoint(self, request: web.Request) -> web.Response:
@@ -226,23 +265,32 @@ class ProvisioningAPI:
         try:
             resp = await api.challenge_send_security_code(code=code)
         except IGChallengeWrongCodeError:
-            return web.json_response(data={
-                "error": "Incorrect challenge code",
-                "status": "incorrect-challenge-code",
-            }, status=403, headers=self._acao_headers)
+            return web.json_response(
+                data={
+                    "error": "Incorrect challenge code",
+                    "status": "incorrect-challenge-code",
+                },
+                status=403,
+                headers=self._acao_headers,
+            )
         return await self._finish_login(user, state, resp.logged_in_user)
 
-    async def _finish_login(self, user: 'u.User', state: AndroidState, resp_user: BaseResponseUser
-                            ) -> web.Response:
+    async def _finish_login(
+        self, user: "u.User", state: AndroidState, resp_user: BaseResponseUser
+    ) -> web.Response:
         user.state = state
         pl = state.device.payload
         manufacturer, model = pl["manufacturer"], pl["model"]
         await user.try_connect()
-        return web.json_response(data={
-            "status": "logged-in",
-            "device_displayname": f"{manufacturer} {model}",
-            "user": resp_user.serialize() if resp_user else None,
-        }, status=200, headers=self._acao_headers)
+        return web.json_response(
+            data={
+                "status": "logged-in",
+                "device_displayname": f"{manufacturer} {model}",
+                "user": resp_user.serialize() if resp_user else None,
+            },
+            status=200,
+            headers=self._acao_headers,
+        )
 
     async def logout(self, request: web.Request) -> web.Response:
         user = await self.check_token(request)

+ 12 - 0
pyproject.toml

@@ -0,0 +1,12 @@
+[tool.isort]
+profile = "black"
+force_to_top = "typing"
+from_first = true
+combine_as_imports = true
+known_first_party = "mautrix"
+line_length = 99
+
+[tool.black]
+line-length = 99
+target-version = ["py38"]
+required-version = "21.12b0"

Some files were not shown because too many files changed in this diff